test: Migrate 12-canvas-actions tests to Playwright (#18442)

This commit is contained in:
shortstacked
2025-08-18 10:32:38 +01:00
committed by GitHub
parent b3a9a0d097
commit cbf935af91
8 changed files with 378 additions and 494 deletions

View File

@@ -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/)

View File

@@ -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);
});
});

View File

@@ -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<void> {
await this.n8n.canvas.executeNode(nodeName);
await this.n8n.notifications.waitForNotificationAndClose('Node executed successfully');
}
/**
* Copy selected nodes and verify success toast
*/
async copySelectedNodesWithToast(): Promise<void> {
await this.n8n.canvas.copyNodes();
await this.n8n.notifications.waitForNotificationAndClose('Copied to clipboard');
}
/**
* Select all nodes and copy them
*/
async selectAllAndCopy(): Promise<void> {
await this.n8n.canvas.selectAll();
await this.copySelectedNodesWithToast();
}
}

View File

@@ -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';

View File

@@ -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<void> {
await this.clickButtonByName('Save');
await this.saveWorkflowButton().click();
}
async fillNodeCreatorSearchBar(text: string): Promise<void> {
await this.fillByTestId('node-creator-search-bar', text);
await this.nodeCreatorSearchBar().fill(text);
}
async clickNodeCreatorItemName(text: string): Promise<void> {
@@ -69,14 +61,14 @@ export class CanvasPage extends BasePage {
async addNodeAndCloseNDV(text: string, subItemText?: string): Promise<void> {
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<void> {
async addNodeWithSubItem(searchText: string, subItemText: string): Promise<void> {
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<void> {
async pinNode(nodeName: string): Promise<void> {
await this.nodeByName(nodeName).click({ button: 'right' });
await this.page.getByTestId('context-menu').getByText('Pin').click();
}
async unpinNodeByNameUsingContextMenu(nodeName: string): Promise<void> {
async unpinNode(nodeName: string): Promise<void> {
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<void> {
await this.clickCanvasPlusButton();
await this.fillNodeCreatorSearchBar(nodeName);
await this.clickNodeCreatorItemName(nodeName);
}
async clickNodePlusEndpoint(nodeName: string): Promise<void> {
await this.canvasNodePlusEndpointByName(nodeName).click();
}
async executeNode(nodeName: string): Promise<void> {
await this.nodeByName(nodeName).hover();
await this.nodeExecuteButton(nodeName).click();
}
async selectAll(): Promise<void> {
await this.page.keyboard.press('ControlOrMeta+a');
}
async copyNodes(): Promise<void> {
await this.page.keyboard.press('ControlOrMeta+c');
}
async deselectAll(): Promise<void> {
await this.canvasPane().click({ position: { x: 10, y: 10 } });
}
getNodeLeftPosition(nodeLocator: Locator): Promise<number> {
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<void> {
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<void> {
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<void> {
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<void> {
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]);
}
}

View File

@@ -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);
});
});

View File

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

View File

@@ -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);