diff --git a/cypress-playwright-migration.md b/cypress-playwright-migration.md deleted file mode 100644 index bdbd2440e2..0000000000 --- a/cypress-playwright-migration.md +++ /dev/null @@ -1,270 +0,0 @@ -# Cypress to Playwright Migration Guide - -## Overview - -This guide outlines the systematic approach for migrating Cypress tests to Playwright in the n8n codebase, based on successful migrations and lessons learned. - -## 🎯 Migration Principles - -### 1. **Architecture First** -- Follow the established 4-layer architecture: Tests → Composables → Page Objects → BasePage -- Use existing composables and page objects before creating new ones -- Maintain separation of concerns: business logic in composables, UI interactions in page objects - -### 2. **Search Existing Patterns First** -- **ALWAYS** search for existing Playwright patterns before implementing new functionality -- Look for working examples in existing test files (e.g., `39-projects.spec.ts`) -- Check composables and page objects for existing methods -- Framework-specific patterns may differ (Cypress display names vs Playwright field names) - -### 3. **Idempotent Test Design** -- Design tests to work regardless of initial state -- Use fresh project creation for tests that need empty states -- Create test prerequisites within the test when needed -- Avoid `@db:reset` dependencies in favor of project-based isolation - -## 📋 Pre-Migration Checklist - -### 1. **Environment Setup** -```bash -# Start isolated test environment -cd packages/testing/playwright -pnpm start:isolated - -# Run tests with proper environment -N8N_BASE_URL=http://localhost:5679 npx playwright test --reporter=list -``` - -### 2. **Study Existing Patterns** -- Review `CONTRIBUTING.md` for architecture guidelines -- Examine working test files (e.g., `1-workflows.spec.ts`, `39-projects.spec.ts`) -- Check available composables in `composables/` directory -- Review page objects in `pages/` directory - -### 3. **Understand Framework Differences** -- **Cypress**: Uses display names (`'Internal Integration Secret'`) -- **Playwright**: Uses field names (`'apiKey'`) -- **Navigation**: Direct page navigation often more reliable than complex UI interactions -- **Selectors**: Prefer `data-test-id` over text-based selectors - -## 🔄 Migration Process - -### Step 1: Scaffold the Test File -```typescript -// 1. Create test file with proper imports -import { test, expect } from '../fixtures/base'; -import { - // Import constants from existing patterns - NOTION_NODE_NAME, - NEW_NOTION_ACCOUNT_NAME, - // ... other constants -} from '../config/constants'; - -// 2. Add beforeEach setup if needed -test.describe('Feature Name', () => { - test.beforeEach(async ({ api, n8n }) => { - await api.enableFeature('sharing'); - await api.enableFeature('folders'); - // ... other feature flags - await n8n.goHome(); - }); - - // 3. Scaffold all tests from Cypress file - test('should do something', async ({ n8n }) => { - // TODO: Implement based on Cypress version - console.log('Test scaffolded - ready for implementation'); - }); -}); -``` - -### Step 2: Research Existing Patterns -```bash -# Search for existing implementations -grep -r "addCredentialToProject" packages/testing/playwright/ -grep -r "createProject" packages/testing/playwright/ -grep -r "workflowComposer" packages/testing/playwright/ -``` - -### Step 3: Implement Working Tests First -- Start with tests that have clear existing patterns -- Use composables for high-level operations (project creation, navigation) -- Use direct DOM interactions for form filling when composables don't match -- Implement one test at a time and verify it works - -### Step 4: Handle Complex UI Interactions -- **Node Creation Issues**: Close NDV after adding first node to prevent overlay blocking -- **Universal Add Button**: Use direct navigation when button interactions fail -- **Modal Overlays**: Use route interception for error testing -- **Multiple Elements**: Use specific selectors to avoid strict mode violations - -## 🛠️ Common Patterns - -### Project-Based Testing -```typescript -// ✅ Good: Use existing composable -const { projectName } = await n8n.projectComposer.createProject(); -await n8n.projectComposer.addCredentialToProject( - projectName, - 'Notion API', - 'apiKey', // Use field name, not display name - 'test_value' -); -``` - -### Direct Navigation -```typescript -// ✅ Good: Direct navigation when UI interactions fail -await n8n.page.goto('/home/credentials/create'); -await n8n.page.goto('/workflow/new'); -``` - -### Error Testing with Route Interception -```typescript -// ✅ Good: Force errors for notification testing -await n8n.page.route('**/rest/credentials', route => { - route.abort(); -}); -``` - -### Node Creation with NDV Handling -```typescript -// ✅ Good: Handle NDV auto-opening -await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME); -await n8n.ndv.close(); // Close NDV that opens automatically -await n8n.canvas.addNode(NOTION_NODE_NAME); -``` - -## 🚨 Common Pitfalls - -### 1. **Not Checking Existing Patterns** -```typescript -// ❌ Bad: Implementing without checking existing patterns -await n8n.page.getByText('Internal Integration Secret').fill('value'); - -// ✅ Good: Use existing composable with correct field name -await n8n.projectComposer.addCredentialToProject( - projectName, 'Notion API', 'apiKey', 'value' -); -``` - -### 2. **Ignoring Framework Differences** -```typescript -// ❌ Bad: Assuming Cypress patterns work in Playwright -await n8n.credentialsModal.connectionParameter('Internal Integration Secret').fill('value'); - -// ✅ Good: Use Playwright field names -await n8n.page.getByTestId('parameter-input-field').fill('value'); -``` - -### 3. **Complex UI Interactions When Simple Navigation Works** -```typescript -// ❌ Bad: Complex button clicking when direct navigation works -await n8n.workflows.clickAddWorkflowButton(); -await n8n.page.waitForLoadState(); - -// ✅ Good: Direct navigation -await n8n.page.goto('/workflow/new'); -await n8n.page.waitForLoadState('networkidle'); -``` - -### 4. **Not Handling UI Blocking** -```typescript -// ❌ Bad: Not handling NDV auto-opening -await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME); -await n8n.canvas.addNode(NOTION_NODE_NAME); // This will fail - -// ✅ Good: Close NDV after first node -await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME); -await n8n.ndv.close(); -await n8n.canvas.addNode(NOTION_NODE_NAME); -``` - -## 📝 Testing Strategy - -### 1. **Start Simple** -- Begin with basic navigation and page verification tests -- Use existing composables for common operations -- Verify each test works before moving to complex scenarios - -### 2. **Incremental Implementation** -- Scaffold all tests first with placeholders -- Implement one test at a time -- Use `console.log` for placeholder tests to maintain passing test suite - -### 3. **Debugging Approach** -```typescript -// Add pauses for debugging -await n8n.page.pause(); - -// Use headed mode for visual debugging -SHOW_BROWSER=true npx playwright test - -// Use specific test selection -npx playwright test -g "test name" --reporter=list -``` - -### 4. **Verification Strategy** -- Run individual tests during development -- Run full test suite after each major change -- Use `--reporter=list` for clear output during development - -## 🔧 Environment Configuration - -### VS Code Settings -```json -{ - "playwright.env": { - "N8N_BASE_URL": "http://localhost:5679", - "SHOW_BROWSER": "true", - "RESET_E2E_DB": "true" - } -} -``` - -### Package.json Scripts -```json -{ - "scripts": { - "start:isolated": "cd ..; N8N_PORT=5679 N8N_USER_FOLDER=/tmp/n8n-test-$(date +%s) E2E_TESTS=true pnpm start", - "test:local": "RESET_E2E_DB=true N8N_BASE_URL=http://localhost:5679 start-server-and-test 'pnpm start:isolated' http://localhost:5679/favicon.ico 'sleep 1 && pnpm test:standard --workers 4 --repeat-each 5'" - } -} -``` - -## 📊 Success Metrics - -### Migration Complete When: -- [ ] All tests from Cypress file are scaffolded -- [ ] All tests pass consistently -- [ ] Tests use existing composables where appropriate -- [ ] Tests follow established patterns -- [ ] No `@db:reset` dependencies (unless absolutely necessary) -- [ ] Tests are idempotent and can run in any order -- [ ] Complex UI interactions are handled properly - -### Quality Checklist: -- [ ] Tests use proper error handling -- [ ] Tests include appropriate assertions -- [ ] Tests follow naming conventions -- [ ] Tests include proper comments -- [ ] Tests use constants for repeated values -- [ ] Tests handle dynamic data properly - -## 🎯 Best Practices Summary - -1. **Search First**: Always look for existing patterns before implementing -2. **Use Composables**: Leverage existing business logic composables -3. **Direct Navigation**: Prefer direct page navigation over complex UI interactions -4. **Handle UI Blocking**: Close modals/NDV when adding multiple nodes -5. **Framework Awareness**: Understand differences between Cypress and Playwright -6. **Incremental Approach**: Implement one test at a time -7. **Idempotent Design**: Make tests work regardless of initial state -8. **Proper Debugging**: Use pauses and headed mode for troubleshooting - -## 📚 Resources - -- [Playwright Test Documentation](https://playwright.dev/docs/intro) -- [n8n Playwright Contributing Guide](./packages/testing/playwright/CONTRIBUTING.md) -- [Existing Test Examples](./packages/testing/playwright/tests/) -- [Composables Reference](./packages/testing/playwright/composables/) -- [Page Objects Reference](./packages/testing/playwright/pages/) \ No newline at end of file diff --git a/cypress/e2e/12-canvas-actions.cy.ts b/cypress/e2e/12-canvas-actions.cy.ts deleted file mode 100644 index e5aa113f2d..0000000000 --- a/cypress/e2e/12-canvas-actions.cy.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { - MANUAL_TRIGGER_NODE_NAME, - MANUAL_TRIGGER_NODE_DISPLAY_NAME, - CODE_NODE_NAME, - SCHEDULE_TRIGGER_NODE_NAME, - EDIT_FIELDS_SET_NODE_NAME, - HTTP_REQUEST_NODE_NAME, -} from './../constants'; -import { getCanvasPane } from '../composables/workflow'; -import { successToast } from '../pages/notifications'; -import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; - -const WorkflowPage = new WorkflowPageClass(); -describe('Canvas Actions', () => { - beforeEach(() => { - WorkflowPage.actions.visit(); - }); - - it('should add first step', () => { - WorkflowPage.getters.canvasPlusButton().should('be.visible'); - WorkflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); - WorkflowPage.getters.canvasNodes().should('have.length', 1); - }); - - it('should add a connected node using plus endpoint', () => { - WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); - WorkflowPage.getters.canvasNodePlusEndpointByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); - WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible'); - WorkflowPage.getters.nodeCreatorSearchBar().type(CODE_NODE_NAME); - WorkflowPage.getters.nodeCreatorSearchBar().type('{enter}'); - cy.get('body').type('{esc}'); - WorkflowPage.getters.canvasNodes().should('have.length', 2); - WorkflowPage.getters.nodeConnections().should('have.length', 1); - }); - - it('should add a connected node dragging from node creator', () => { - WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); - WorkflowPage.getters.canvasNodePlusEndpointByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); - WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible'); - WorkflowPage.getters.nodeCreatorSearchBar().type(CODE_NODE_NAME); - cy.drag(WorkflowPage.getters.nodeCreatorNodeItems().first(), [100, 100], { - realMouse: true, - abs: true, - }); - cy.get('body').type('{esc}'); - WorkflowPage.getters.canvasNodes().should('have.length', 2); - WorkflowPage.getters.nodeConnections().should('have.length', 1); - }); - - it('should open a category when trying to drag and drop it on the canvas', () => { - WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); - WorkflowPage.getters.canvasNodePlusEndpointByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); - WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible'); - WorkflowPage.getters.nodeCreatorSearchBar().type(CODE_NODE_NAME); - cy.drag(WorkflowPage.getters.nodeCreatorActionItems().first(), [100, 100], { - realMouse: true, - abs: true, - }); - WorkflowPage.getters.nodeCreatorCategoryItems().its('length').should('be.gt', 0); - WorkflowPage.getters.canvasNodes().should('have.length', 1); - WorkflowPage.getters.nodeConnections().should('have.length', 0); - }); - - it('should add disconnected node if nothing is selected', () => { - WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); - // Deselect nodes - getCanvasPane().click({ force: true }); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - WorkflowPage.getters.canvasNodes().should('have.length', 2); - WorkflowPage.getters.nodeConnections().should('have.length', 0); - }); - - it('should add node between two connected nodes', () => { - WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME); - WorkflowPage.actions.zoomToFit(); - WorkflowPage.getters.canvasNodes().should('have.length', 3); - WorkflowPage.getters.nodeConnections().should('have.length', 2); - WorkflowPage.actions.addNodeBetweenNodes( - CODE_NODE_NAME, - EDIT_FIELDS_SET_NODE_NAME, - HTTP_REQUEST_NODE_NAME, - ); - WorkflowPage.getters.canvasNodes().should('have.length', 4); - WorkflowPage.getters.nodeConnections().should('have.length', 3); - - WorkflowPage.getters.canvasNodeByName(EDIT_FIELDS_SET_NODE_NAME).then(($editFieldsNode) => { - const editFieldsNodeLeft = WorkflowPage.getters.getNodeLeftPosition($editFieldsNode); - - WorkflowPage.getters.canvasNodeByName(HTTP_REQUEST_NODE_NAME).then(($httpNode) => { - const httpNodeLeft = WorkflowPage.getters.getNodeLeftPosition($httpNode); - expect(httpNodeLeft).to.be.lessThan(editFieldsNodeLeft); - }); - }); - }); - - it('should delete node by pressing keyboard backspace', () => { - WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); - WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - WorkflowPage.getters.canvasNodeByName(CODE_NODE_NAME).click(); - cy.get('body').type('{backspace}'); - WorkflowPage.getters.nodeConnections().should('have.length', 0); - }); - - it('should delete connections by clicking on the delete button', () => { - WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); - WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - WorkflowPage.getters.nodeConnections().first().realHover(); - WorkflowPage.actions.deleteNodeBetweenNodes(MANUAL_TRIGGER_NODE_DISPLAY_NAME, CODE_NODE_NAME); - - WorkflowPage.getters.nodeConnections().should('have.length', 0); - }); - - describe('Node hover actions', () => { - it('should execute node', () => { - WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - WorkflowPage.getters - .canvasNodes() - .last() - .findChildByTestId('execute-node-button') - .click({ force: true }); - - successToast().should('have.length', 1); - - WorkflowPage.actions.executeNode(CODE_NODE_NAME); - - successToast().should('have.length', 2); - successToast().should('contain.text', 'Node executed successfully'); - }); - - it('should disable and enable node', () => { - WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - const disableButton = WorkflowPage.getters - .canvasNodes() - .last() - .findChildByTestId('disable-node-button'); - disableButton.click({ force: true }); - WorkflowPage.getters.disabledNodes().should('have.length', 1); - disableButton.click({ force: true }); - WorkflowPage.getters.disabledNodes().should('have.length', 0); - }); - - it('should delete node', () => { - WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - WorkflowPage.getters - .canvasNodes() - .last() - .find('[data-test-id="delete-node-button"]') - .click({ force: true }); - WorkflowPage.getters.canvasNodes().should('have.length', 1); - }); - }); - - it('should copy selected nodes', () => { - WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - WorkflowPage.actions.hitSelectAll(); - - WorkflowPage.actions.hitCopy(); - successToast().should('contain', 'Copied to clipboard'); - - WorkflowPage.actions.copyNode(CODE_NODE_NAME); - successToast().should('contain', 'Copied to clipboard'); - }); - - it('should select/deselect all nodes', () => { - WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - WorkflowPage.actions.hitSelectAll(); - WorkflowPage.getters.selectedNodes().should('have.length', 2); - WorkflowPage.actions.deselectAll(); - WorkflowPage.getters.selectedNodes().should('have.length', 0); - }); - - it('should select nodes using arrow keys', () => { - WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); - WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - cy.wait(500); - cy.get('body').type('{leftArrow}'); - const selectedCanvasNodes = () => WorkflowPage.getters.canvasNodes().parent(); - - selectedCanvasNodes().first().should('have.class', 'selected'); - cy.get('body').type('{rightArrow}'); - selectedCanvasNodes().last().should('have.class', 'selected'); - }); - - it('should select nodes using shift and arrow keys', () => { - WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); - WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - cy.wait(500); - cy.get('body').type('{shift}', { release: false }).type('{leftArrow}'); - WorkflowPage.getters.selectedNodes().should('have.length', 2); - }); -}); diff --git a/packages/testing/playwright/composables/CanvasComposer.ts b/packages/testing/playwright/composables/CanvasComposer.ts index 75b2d020b3..5816409a63 100644 --- a/packages/testing/playwright/composables/CanvasComposer.ts +++ b/packages/testing/playwright/composables/CanvasComposer.ts @@ -12,4 +12,29 @@ export class CanvasComposer { await this.n8n.ndv.togglePinData(); await this.n8n.ndv.close(); } + + /** + * Execute a node and wait for success toast notification + * @param nodeName - The node to execute + */ + async executeNodeAndWaitForToast(nodeName: string): Promise { + await this.n8n.canvas.executeNode(nodeName); + await this.n8n.notifications.waitForNotificationAndClose('Node executed successfully'); + } + + /** + * Copy selected nodes and verify success toast + */ + async copySelectedNodesWithToast(): Promise { + await this.n8n.canvas.copyNodes(); + await this.n8n.notifications.waitForNotificationAndClose('Copied to clipboard'); + } + + /** + * Select all nodes and copy them + */ + async selectAllAndCopy(): Promise { + await this.n8n.canvas.selectAll(); + await this.copySelectedNodesWithToast(); + } } diff --git a/packages/testing/playwright/config/constants.ts b/packages/testing/playwright/config/constants.ts index d9d715479c..061eb788cd 100644 --- a/packages/testing/playwright/config/constants.ts +++ b/packages/testing/playwright/config/constants.ts @@ -10,7 +10,7 @@ export const CHAT_TRIGGER_NODE_DISPLAY_NAME = 'When chat message received'; export const SCHEDULE_TRIGGER_NODE_NAME = 'Schedule Trigger'; export const CODE_NODE_NAME = 'Code'; export const SET_NODE_NAME = 'Set'; -export const EDIT_FIELDS_SET_NODE_NAME = 'Edit Fields'; +export const EDIT_FIELDS_SET_NODE_NAME = 'Edit Fields (Set)'; export const LOOP_OVER_ITEMS_NODE_NAME = 'Loop Over Items'; export const IF_NODE_NAME = 'If'; export const MERGE_NODE_NAME = 'Merge'; diff --git a/packages/testing/playwright/pages/CanvasPage.ts b/packages/testing/playwright/pages/CanvasPage.ts index bc21fdd48b..2beebfc7d7 100644 --- a/packages/testing/playwright/pages/CanvasPage.ts +++ b/packages/testing/playwright/pages/CanvasPage.ts @@ -9,14 +9,6 @@ export class CanvasPage extends BasePage { return this.page.getByRole('button', { name: 'Save' }); } - workflowSaveButton(): Locator { - return this.page.getByTestId('workflow-save-button'); - } - - canvasAddButton(): Locator { - return this.page.getByTestId('canvas-add-button'); - } - nodeCreatorItemByName(text: string): Locator { return this.page.getByTestId('node-creator-item-name').getByText(text, { exact: true }); } @@ -50,11 +42,11 @@ export class CanvasPage extends BasePage { } async clickSaveWorkflowButton(): Promise { - await this.clickButtonByName('Save'); + await this.saveWorkflowButton().click(); } async fillNodeCreatorSearchBar(text: string): Promise { - await this.fillByTestId('node-creator-search-bar', text); + await this.nodeCreatorSearchBar().fill(text); } async clickNodeCreatorItemName(text: string): Promise { @@ -69,14 +61,14 @@ export class CanvasPage extends BasePage { async addNodeAndCloseNDV(text: string, subItemText?: string): Promise { if (subItemText) { - await this.addNodeToCanvasWithSubItem(text, subItemText); + await this.addNodeWithSubItem(text, subItemText); } else { await this.addNode(text); } await this.page.keyboard.press('Escape'); } - async addNodeToCanvasWithSubItem(searchText: string, subItemText: string): Promise { + async addNodeWithSubItem(searchText: string, subItemText: string): Promise { await this.addNode(searchText); await this.nodeCreatorSubItem(subItemText).click(); } @@ -97,12 +89,12 @@ export class CanvasPage extends BasePage { await this.page.getByRole('button', { name: 'Debug in editor' }).click(); } - async pinNodeByNameUsingContextMenu(nodeName: string): Promise { + async pinNode(nodeName: string): Promise { await this.nodeByName(nodeName).click({ button: 'right' }); await this.page.getByTestId('context-menu').getByText('Pin').click(); } - async unpinNodeByNameUsingContextMenu(nodeName: string): Promise { + async unpinNode(nodeName: string): Promise { await this.nodeByName(nodeName).click({ button: 'right' }); await this.page.getByText('Unpin').click(); } @@ -215,6 +207,10 @@ export class CanvasPage extends BasePage { return tags; } + getWorkflowSaveButton(): Locator { + return this.page.getByTestId('workflow-save-button'); + } + // Production Checklist methods getProductionChecklistButton(): Locator { return this.page.getByTestId('suggested-action-count'); @@ -260,7 +256,159 @@ export class CanvasPage extends BasePage { await this.getProductionChecklistActionItem(actionText).click(); } - getCanvasNodes() { + getCanvasNodes(): Locator { return this.page.getByTestId('canvas-node'); } + + nodeConnections(): Locator { + return this.page.locator('[data-test-id="edge"]'); + } + + canvasNodePlusEndpointByName(nodeName: string): Locator { + return this.page + .locator( + `[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"] [data-test-id="canvas-handle-plus"]`, + ) + .first(); + } + + nodeCreatorSearchBar(): Locator { + return this.page.getByTestId('node-creator-search-bar'); + } + + nodeCreatorNodeItems(): Locator { + return this.page.getByTestId('node-creator-node-item'); + } + + nodeCreatorActionItems(): Locator { + return this.page.getByTestId('node-creator-action-item'); + } + + nodeCreatorCategoryItems(): Locator { + return this.page.getByTestId('node-creator-category-item'); + } + + selectedNodes(): Locator { + return this.page + .locator('[data-test-id="canvas-node"]') + .locator('xpath=..') + .locator('.selected'); + } + + disabledNodes(): Locator { + return this.page.locator('[data-canvas-node-render-type][class*="disabled"]'); + } + + nodeExecuteButton(nodeName: string): Locator { + return this.nodeToolbar(nodeName).getByTestId('execute-node-button'); + } + + canvasPane(): Locator { + return this.page.getByTestId('canvas-wrapper'); + } + + // Actions + + async addInitialNodeToCanvas(nodeName: string): Promise { + await this.clickCanvasPlusButton(); + await this.fillNodeCreatorSearchBar(nodeName); + await this.clickNodeCreatorItemName(nodeName); + } + + async clickNodePlusEndpoint(nodeName: string): Promise { + await this.canvasNodePlusEndpointByName(nodeName).click(); + } + + async executeNode(nodeName: string): Promise { + await this.nodeByName(nodeName).hover(); + await this.nodeExecuteButton(nodeName).click(); + } + + async selectAll(): Promise { + await this.page.keyboard.press('ControlOrMeta+a'); + } + + async copyNodes(): Promise { + await this.page.keyboard.press('ControlOrMeta+c'); + } + + async deselectAll(): Promise { + await this.canvasPane().click({ position: { x: 10, y: 10 } }); + } + + getNodeLeftPosition(nodeLocator: Locator): Promise { + return nodeLocator.evaluate((el) => el.getBoundingClientRect().left); + } + + // Connection helpers + connectionBetweenNodes(sourceNodeName: string, targetNodeName: string): Locator { + return this.page.locator( + `[data-test-id="edge"][data-source-node-name="${sourceNodeName}"][data-target-node-name="${targetNodeName}"]`, + ); + } + + connectionToolbarBetweenNodes(sourceNodeName: string, targetNodeName: string): Locator { + return this.page.locator( + `[data-test-id="edge-label"][data-source-node-name="${sourceNodeName}"][data-target-node-name="${targetNodeName}"] [data-test-id="canvas-edge-toolbar"]`, + ); + } + + // Canvas action helpers + async addNodeBetweenNodes( + sourceNodeName: string, + targetNodeName: string, + newNodeName: string, + ): Promise { + const specificConnection = this.connectionBetweenNodes(sourceNodeName, targetNodeName); + // eslint-disable-next-line playwright/no-force-option + await specificConnection.hover({ force: true }); + + const addNodeButton = this.connectionToolbarBetweenNodes( + sourceNodeName, + targetNodeName, + ).getByTestId('add-connection-button'); + + await addNodeButton.click(); + await this.fillNodeCreatorSearchBar(newNodeName); + await this.clickNodeCreatorItemName(newNodeName); + await this.page.keyboard.press('Escape'); + } + + async deleteConnectionBetweenNodes( + sourceNodeName: string, + targetNodeName: string, + ): Promise { + const specificConnection = this.connectionBetweenNodes(sourceNodeName, targetNodeName); + // eslint-disable-next-line playwright/no-force-option + await specificConnection.hover({ force: true }); + + const deleteButton = this.connectionToolbarBetweenNodes( + sourceNodeName, + targetNodeName, + ).getByTestId('delete-connection-button'); + + await deleteButton.click(); + } + + async navigateNodesWithArrows(direction: 'left' | 'right' | 'up' | 'down'): Promise { + const keyMap = { + left: 'ArrowLeft', + right: 'ArrowRight', + up: 'ArrowUp', + down: 'ArrowDown', + }; + await this.canvasPane().focus(); + await this.page.keyboard.press(keyMap[direction]); + } + + async extendSelectionWithArrows(direction: 'left' | 'right' | 'up' | 'down'): Promise { + const keyMap = { + left: 'Shift+ArrowLeft', + right: 'Shift+ArrowRight', + up: 'Shift+ArrowUp', + down: 'Shift+ArrowDown', + }; + await this.canvasPane().focus(); + await this.page.keyboard.press(keyMap[direction]); + } } diff --git a/packages/testing/playwright/tests/ui/12-canvas-actions.spec.ts b/packages/testing/playwright/tests/ui/12-canvas-actions.spec.ts new file mode 100644 index 0000000000..a90888ccc4 --- /dev/null +++ b/packages/testing/playwright/tests/ui/12-canvas-actions.spec.ts @@ -0,0 +1,186 @@ +import { + MANUAL_TRIGGER_NODE_NAME, + MANUAL_TRIGGER_NODE_DISPLAY_NAME, + CODE_NODE_NAME, + HTTP_REQUEST_NODE_NAME, +} from '../../config/constants'; +import { test, expect } from '../../fixtures/base'; + +test.describe('Canvas Actions', () => { + test.beforeEach(async ({ n8n }) => { + await n8n.goHome(); + await n8n.workflows.clickAddWorkflowButton(); + }); + + test('should add first step', async ({ n8n }) => { + await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); + await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1); + }); + + test('should add a connected node using plus endpoint', async ({ n8n }) => { + await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); + await n8n.canvas.clickNodePlusEndpoint(MANUAL_TRIGGER_NODE_DISPLAY_NAME); + await n8n.canvas.fillNodeCreatorSearchBar(CODE_NODE_NAME); + await n8n.page.keyboard.press('Enter'); + await n8n.page.keyboard.press('Escape'); + + await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2); + await expect(n8n.canvas.nodeConnections()).toHaveCount(1); + }); + + test('should add a connected node dragging from node creator', async ({ n8n }) => { + await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); + await n8n.canvas.clickNodePlusEndpoint(MANUAL_TRIGGER_NODE_DISPLAY_NAME); + await n8n.canvas.fillNodeCreatorSearchBar(CODE_NODE_NAME); + + const sourceElement = n8n.canvas + .nodeCreatorNodeItems() + .filter({ hasText: CODE_NODE_NAME }) + .first(); + await sourceElement.dragTo(n8n.canvas.canvasPane(), { targetPosition: { x: 100, y: 100 } }); + + await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2); + await expect(n8n.canvas.nodeConnections()).toHaveCount(1); + }); + + test('should open a category when trying to drag and drop it on the canvas', async ({ n8n }) => { + await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); + await n8n.canvas.clickNodePlusEndpoint(MANUAL_TRIGGER_NODE_DISPLAY_NAME); + await n8n.canvas.fillNodeCreatorSearchBar(CODE_NODE_NAME); + + const categoryItem = n8n.canvas.nodeCreatorActionItems().first(); + await categoryItem.dragTo(n8n.canvas.canvasPane(), { + targetPosition: { x: 100, y: 100 }, + }); + + await expect(n8n.canvas.nodeCreatorCategoryItems()).toHaveCount(1); + await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1); + await expect(n8n.canvas.nodeConnections()).toHaveCount(0); + }); + + test('should add disconnected node if nothing is selected', async ({ n8n }) => { + await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); + await n8n.canvas.deselectAll(); + await n8n.canvas.addNodeAndCloseNDV(CODE_NODE_NAME); + + await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2); + await expect(n8n.canvas.nodeConnections()).toHaveCount(0); + }); + + test('should add node between two connected nodes', async ({ n8n }) => { + await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); + await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); + await n8n.canvas.addNodeAndCloseNDV(CODE_NODE_NAME); + + await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2); + await expect(n8n.canvas.nodeConnections()).toHaveCount(1); + + await n8n.canvas.addNodeBetweenNodes( + MANUAL_TRIGGER_NODE_DISPLAY_NAME, + CODE_NODE_NAME, + HTTP_REQUEST_NODE_NAME, + ); + + await expect(n8n.canvas.getCanvasNodes()).toHaveCount(3); + await expect(n8n.canvas.nodeConnections()).toHaveCount(2); + }); + + test('should delete node by pressing keyboard backspace', async ({ n8n }) => { + await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); + await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); + await n8n.page.keyboard.press('Backspace'); + + await expect(n8n.canvas.nodeConnections()).toHaveCount(0); + }); + + test('should delete connections by clicking on the delete button', async ({ n8n }) => { + await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); + await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); + await n8n.canvas.addNodeAndCloseNDV(CODE_NODE_NAME); + await n8n.canvas.deleteConnectionBetweenNodes(MANUAL_TRIGGER_NODE_DISPLAY_NAME, CODE_NODE_NAME); + + await expect(n8n.canvas.nodeConnections()).toHaveCount(0); + await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2); + }); + + test.describe('Node hover actions', () => { + test('should execute node', async ({ n8n }) => { + await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); + await n8n.canvas.executeNode(MANUAL_TRIGGER_NODE_DISPLAY_NAME); + + await expect( + n8n.notifications.notificationContainerByText('Node executed successfully'), + ).toHaveCount(1); + await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1); + }); + + test('should disable and enable node', async ({ n8n }) => { + await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); + await n8n.canvas.addNodeAndCloseNDV(CODE_NODE_NAME); + + const disableButton = n8n.canvas.nodeDisableButton(CODE_NODE_NAME); + await disableButton.click(); + + await expect(n8n.canvas.disabledNodes()).toHaveCount(1); + + await disableButton.click(); + + await expect(n8n.canvas.disabledNodes()).toHaveCount(0); + }); + + test('should delete node', async ({ n8n }) => { + await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); + await n8n.canvas.addNodeAndCloseNDV(CODE_NODE_NAME); + await n8n.canvas.deleteNodeByName(CODE_NODE_NAME); + + await expect(n8n.canvas.getCanvasNodes()).toHaveCount(1); + await expect(n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME)).toBeVisible(); + }); + }); + + test('should copy selected nodes', async ({ n8n }) => { + await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); + await n8n.canvas.addNodeAndCloseNDV(CODE_NODE_NAME); + await n8n.canvasComposer.selectAllAndCopy(); + await n8n.canvas.nodeByName(CODE_NODE_NAME).click(); + await n8n.canvasComposer.copySelectedNodesWithToast(); + + await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2); + }); + + test('should select/deselect all nodes', async ({ n8n }) => { + await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); + await n8n.canvas.addNodeAndCloseNDV(CODE_NODE_NAME); + await n8n.canvas.selectAll(); + + await expect(n8n.canvas.selectedNodes()).toHaveCount(2); + + await n8n.canvas.deselectAll(); + await expect(n8n.canvas.selectedNodes()).toHaveCount(0); + }); + + test('should select nodes using arrow keys', async ({ n8n }) => { + await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); + await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); + await n8n.canvas.addNodeAndCloseNDV(CODE_NODE_NAME); + await n8n.canvas.getCanvasNodes().first().waitFor(); + await n8n.canvas.navigateNodesWithArrows('left'); + + const selectedNodes = n8n.canvas.selectedNodes(); + await expect(selectedNodes.first()).toHaveClass(/selected/); + + await n8n.canvas.navigateNodesWithArrows('right'); + + await expect(selectedNodes.last()).toHaveClass(/selected/); + }); + + test('should select nodes using shift and arrow keys', async ({ n8n }) => { + await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME); + await n8n.canvas.nodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); + await n8n.canvas.addNodeAndCloseNDV(CODE_NODE_NAME); + await n8n.canvas.getCanvasNodes().first().waitFor(); + await n8n.canvas.extendSelectionWithArrows('left'); + + await expect(n8n.canvas.selectedNodes()).toHaveCount(2); + }); +}); diff --git a/packages/testing/playwright/tests/ui/2270-ADO-opening-webhook-ndv-marks-workflow-as-unsaved.spec.ts b/packages/testing/playwright/tests/ui/2270-ADO-opening-webhook-ndv-marks-workflow-as-unsaved.spec.ts index 331320c7c1..dac542a485 100644 --- a/packages/testing/playwright/tests/ui/2270-ADO-opening-webhook-ndv-marks-workflow-as-unsaved.spec.ts +++ b/packages/testing/playwright/tests/ui/2270-ADO-opening-webhook-ndv-marks-workflow-as-unsaved.spec.ts @@ -15,6 +15,6 @@ test.describe('ADO-2270 Save button resets on webhook node open', () => { await n8n.ndv.clickBackToCanvasButton(); - await expect(n8n.canvas.workflowSaveButton()).toContainText('Saved'); + await expect(n8n.canvas.getWorkflowSaveButton()).toContainText('Saved'); }); }); diff --git a/packages/testing/playwright/tests/ui/39-projects.spec.ts b/packages/testing/playwright/tests/ui/39-projects.spec.ts index 2f7eebb891..5f7bdcdecc 100644 --- a/packages/testing/playwright/tests/ui/39-projects.spec.ts +++ b/packages/testing/playwright/tests/ui/39-projects.spec.ts @@ -63,10 +63,7 @@ test.describe('Projects @db:reset', () => { n8n.page.getByText('Workflow successfully created', { exact: false }), ).toBeVisible(); - await n8n.canvas.addNodeToCanvasWithSubItem( - EXECUTE_WORKFLOW_NODE_NAME, - 'Execute A Sub Workflow', - ); + await n8n.canvas.addNodeWithSubItem(EXECUTE_WORKFLOW_NODE_NAME, 'Execute A Sub Workflow'); const subWorkflowPagePromise = n8n.page.waitForEvent('popup'); @@ -77,7 +74,7 @@ test.describe('Projects @db:reset', () => { await subn8n.ndv.clickBackToCanvasButton(); await subn8n.canvas.deleteNodeByName('Replace me with your logic'); - await subn8n.canvas.addNodeToCanvasWithSubItem(NOTION_NODE_NAME, 'Append a block'); + await subn8n.canvas.addNodeWithSubItem(NOTION_NODE_NAME, 'Append a block'); await subn8n.credentials.createAndSaveNewCredential('apiKey', NOTION_API_KEY);