test: Migrate 1-workflows to Playwright (#17360)

This commit is contained in:
shortstacked
2025-08-01 10:27:48 +01:00
committed by GitHub
parent 38cef9d133
commit b99b93a637
19 changed files with 1248 additions and 68 deletions

View File

@@ -0,0 +1,270 @@
# 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

@@ -10,7 +10,9 @@ const workflowSharingModal = new WorkflowSharingModal();
const multipleWorkflowsCount = 5;
describe('Workflows', () => {
// Migrated to Playwright
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
describe.skip('Workflows', () => {
beforeEach(() => {
cy.visit(WorkflowsPage.url);
});

View File

@@ -0,0 +1,534 @@
# n8n Playwright Test Contribution Guide
> For running tests, see [README.md](./README.md)
## 🚀 Quick Start for Test Development
### Prerequisites
- **VS Code/Cursor Extension**: Install "Playwright Test for VSCode"
- **Local n8n Instance**: Local server or Docker
### Configuration
Add to your `/.vscode/settings.json`:
```json
{
"playwright.env": {
"N8N_BASE_URL": "http://localhost:5679", // URL to test against (Don't use 5678 as that can wipe your dev instance DB)
"SHOW_BROWSER": "true", // Show browser (useful with n8n.page.pause())
"RESET_E2E_DB": "true" // Reset DB for fresh state
}
}
```
### Running Tests
1. **Initial Setup**: Click "Run global setup" in Playwright extension to reset database
2. **Run Tests**: Click play button next to any test in the IDE
3. **Debug**: Add `await n8n.page.pause()` to hijack test execution
Troubleshooting:
- Why can't I run my test from the UI?
- The tests are separated by groups for tests that can run in parallel or tests that need a DB reset each time. You can select the project in the test explorer.
- Not all my tests ran from the CLI
- Currently the DB reset tests are a "dependency" of the parallel tests, this is to stop them running at the same time. So if the parallel tests fail the sequential tests won't run.
---
## 🏗️ Architecture Overview
Our test architecture follows a strict four-layer approach:
```
Tests (*.spec.ts)
↓ uses
Composables (*Composer.ts) - Business workflows
↓ orchestrates
Page Objects (*Page.ts) - UI interactions
↓ extends
BasePage - Common utilities
```
### Core Principle: Separation of Concerns
- **BasePage**: Generic interaction methods
- **Page Objects**: Element locators and simple actions
- **Composables**: Complex business workflows
- **Tests**: Readable scenarios using composables
---
## 📐 Lexical Conventions
### Page Objects: Three Types of Methods
#### 1. Element Getters (No `async`, return `Locator`)
```typescript
// From WorkflowsPage.ts
getSearchBar() {
return this.page.getByTestId('resources-list-search');
}
getWorkflowByName(name: string) {
return this.getWorkflowItems().filter({ hasText: name });
}
// From CanvasPage.ts
nodeByName(nodeName: string): Locator {
return this.page.locator(`[data-test-id="canvas-node"][data-node-name="${nodeName}"]`);
}
saveWorkflowButton(): Locator {
return this.page.getByRole('button', { name: 'Save' });
}
```
#### 2. Simple Actions (`async`, return `void`)
```typescript
// From WorkflowsPage.ts
async clickAddWorklowButton() {
await this.clickByTestId('add-resource-workflow');
}
async searchWorkflows(searchTerm: string) {
await this.clickByTestId('resources-list-search');
await this.fillByTestId('resources-list-search', searchTerm);
}
// From CanvasPage.ts
async deleteNodeByName(nodeName: string): Promise<void> {
await this.nodeDeleteButton(nodeName).click();
}
async openNode(nodeName: string): Promise<void> {
await this.nodeByName(nodeName).dblclick();
}
```
#### 3. Query Methods (`async`, return data)
```typescript
// From CanvasPage.ts
async getPinnedNodeNames(): Promise<string[]> {
const pinnedNodesLocator = this.page
.getByTestId('canvas-node')
.filter({ has: this.page.getByTestId('canvas-node-status-pinned') });
const names: string[] = [];
const count = await pinnedNodesLocator.count();
for (let i = 0; i < count; i++) {
const node = pinnedNodesLocator.nth(i);
const name = await node.getAttribute('data-node-name');
if (name) {
names.push(name);
}
}
return names;
}
// From NotificationsPage.ts
async getNotificationCount(text?: string | RegExp): Promise<number> {
try {
const notifications = text
? this.notificationContainerByText(text)
: this.page.getByRole('alert');
return await notifications.count();
} catch {
return 0;
}
}
```
### Composables: Business Workflows
```typescript
// From WorkflowComposer.ts
export class WorkflowComposer {
async executeWorkflowAndWaitForNotification(notificationMessage: string) {
const responsePromise = this.n8n.page.waitForResponse(
(response) =>
response.url().includes('/rest/workflows/') &&
response.url().includes('/run') &&
response.request().method() === 'POST',
);
await this.n8n.canvas.clickExecuteWorkflowButton();
await responsePromise;
await this.n8n.notifications.waitForNotificationAndClose(notificationMessage);
}
async createWorkflow(name?: string) {
await this.n8n.workflows.clickAddWorklowButton();
const workflowName = name ?? 'My New Workflow';
await this.n8n.canvas.setWorkflowName(workflowName);
await this.n8n.canvas.saveWorkflow();
}
}
// From ProjectComposer.ts
export class ProjectComposer {
async createProject(projectName?: string) {
await this.n8n.page.getByTestId('universal-add').click();
await Promise.all([
this.n8n.page.waitForResponse('**/rest/projects/*'),
this.n8n.page.getByTestId('navigation-menu-item').filter({ hasText: 'Project' }).click(),
]);
await this.n8n.notifications.waitForNotificationAndClose('saved successfully');
await this.n8n.page.waitForLoadState();
const projectNameUnique = projectName ?? `Project ${Date.now()}`;
await this.n8n.projectSettings.fillProjectName(projectNameUnique);
await this.n8n.projectSettings.clickSaveButton();
const projectId = this.extractProjectIdFromPage('projects', 'settings');
return { projectName: projectNameUnique, projectId };
}
}
```
---
## 📁 File Structure & Naming
```
tests/
├── composables/ # Multi-page business workflows
│ ├── CanvasComposer.ts
│ ├── ProjectComposer.ts
│ └── WorkflowComposer.ts
├── pages/ # Page object models
│ ├── BasePage.ts
│ ├── CanvasPage.ts
│ ├── CredentialsPage.ts
│ ├── ExecutionsPage.ts
│ ├── NodeDisplayViewPage.ts
│ ├── NotificationsPage.ts
│ ├── ProjectSettingsPage.ts
│ ├── ProjectWorkflowsPage.ts
│ ├── SidebarPage.ts
│ ├── WorkflowSharingModal.ts
│ └── WorkflowsPage.ts
├── fixtures/ # Test fixtures and setup
├── services/ # API helpers
├── utils/ # Helper functions
├── config/ # Constants and configuration
│ ├── constants.ts
│ ├── intercepts.ts
│ └── test-users.ts
└── *.spec.ts # Test files
```
### Naming Conventions
| Type | Pattern | Example |
|------|---------|---------|
| **Page Objects** | `{PageName}Page.ts` | `CredentialsPage.ts` |
| **Composables** | `{Domain}Composer.ts` | `WorkflowComposer.ts` |
| **Test Files** | `{number}-{feature}.spec.ts` | `1-workflows.spec.ts` |
| **Test IDs** | `kebab-case` | `data-test-id="save-button"` |
---
## ✅ Implementation Checklist
### When Adding a Page Object Method
```typescript
// From ExecutionsPage.ts - Good example
export class ExecutionsPage extends BasePage {
// ✅ Getter: Returns Locator, no async
getExecutionItems(): Locator {
return this.page.locator('div.execution-card');
}
getLastExecutionItem(): Locator {
const executionItems = this.getExecutionItems();
return executionItems.nth(0);
}
// ✅ Action: Async, descriptive verb, returns void
async clickDebugInEditorButton(): Promise<void> {
await this.clickButtonByName('Debug in editor');
}
async clickLastExecutionItem(): Promise<void> {
const executionItem = this.getLastExecutionItem();
await executionItem.click();
}
// ❌ AVOID: Mixed concerns (this should be in a composable)
async handlePinnedNodesConfirmation(action: 'Unpin' | 'Cancel'): Promise<void> {
// This involves business logic and should be moved to a composable
}
}
```
### When Creating a Composable
```typescript
// From CanvasComposer.ts - Good example
export class CanvasComposer {
/**
* Pin the data on a node. Then close the node.
* @param nodeName - The name of the node to pin the data on.
*/
async pinNodeData(nodeName: string) {
await this.n8n.canvas.openNode(nodeName);
await this.n8n.ndv.togglePinData();
await this.n8n.ndv.close();
}
}
// From ProjectComposer.ts - Good example with return data
export class ProjectComposer {
async addCredentialToProject(
projectName: string,
credentialType: string,
credentialFieldName: string,
credentialValue: string,
) {
await this.n8n.sideBar.openNewCredentialDialogForProject(projectName);
await this.n8n.credentials.openNewCredentialDialogFromCredentialList(credentialType);
await this.n8n.credentials.fillCredentialField(credentialFieldName, credentialValue);
await this.n8n.credentials.saveCredential();
await this.n8n.notifications.waitForNotificationAndClose('Credential successfully created');
await this.n8n.credentials.closeCredentialDialog();
}
}
```
### When Writing Tests
```typescript
// ✅ GOOD: From 1-workflows.spec.ts
test('should create a new workflow using add workflow button', async ({ n8n }) => {
await n8n.workflows.clickAddWorklowButton();
const workflowName = `Test Workflow ${Date.now()}`;
await n8n.canvas.setWorkflowName(workflowName);
await n8n.canvas.clickSaveWorkflowButton();
await expect(
n8n.notifications.notificationContainerByText('Workflow successfully created'),
).toBeVisible();
});
// ✅ GOOD: From 28-debug.spec.ts - Using helper functions
async function createBasicWorkflow(n8n, url = URLS.FAILING) {
await n8n.workflows.clickAddWorklowButton();
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('HTTP Request');
await n8n.ndv.fillParameterInput('URL', url);
await n8n.ndv.close();
await n8n.canvas.clickSaveWorkflowButton();
await n8n.notifications.waitForNotificationAndClose(NOTIFICATIONS.WORKFLOW_CREATED);
}
test('should enter debug mode for failed executions', async ({ n8n }) => {
await createBasicWorkflow(n8n, URLS.FAILING);
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(NOTIFICATIONS.PROBLEM_IN_NODE);
await importExecutionForDebugging(n8n);
expect(n8n.page.url()).toContain('/debug');
});
```
---
## 🎯 Best Practices
### 1. Always Use BasePage Methods
```typescript
// ✅ GOOD - From NodeDisplayViewPage.ts
async fillParameterInput(labelName: string, value: string) {
await this.getParameterByLabel(labelName).getByTestId('parameter-input-field').fill(value);
}
async clickBackToCanvasButton() {
await this.clickByTestId('back-to-canvas');
}
// ❌ AVOID
async badExample() {
await this.page.getByTestId('back-to-canvas').click();
}
```
### 2. Keep Page Objects Simple
```typescript
// ✅ GOOD - From CredentialsPage.ts
export class CredentialsPage extends BasePage {
async openCredentialSelector() {
await this.page.getByRole('combobox', { name: 'Select Credential' }).click();
}
async createNewCredential() {
await this.clickByText('Create new credential');
}
async fillCredentialField(fieldName: string, value: string) {
const field = this.page
.getByTestId(`parameter-input-${fieldName}`)
.getByTestId('parameter-input-field');
await field.click();
await field.fill(value);
}
}
```
### 3. Use Constants for Repeated Values
```typescript
// From constants.ts
export const MANUAL_TRIGGER_NODE_NAME = 'Manual Trigger';
export const MANUAL_TRIGGER_NODE_DISPLAY_NAME = 'When clicking 'Execute workflow'';
export const CODE_NODE_NAME = 'Code';
export const SET_NODE_NAME = 'Set';
export const HTTP_REQUEST_NODE_NAME = 'HTTP Request';
// From 28-debug.spec.ts
const NOTIFICATIONS = {
WORKFLOW_CREATED: 'Workflow successfully created',
EXECUTION_IMPORTED: 'Execution data imported',
PROBLEM_IN_NODE: 'Problem in node',
SUCCESSFUL: 'Successful',
DATA_NOT_IMPORTED: "Some execution data wasn't imported",
};
```
### 4. Handle Dynamic Data
```typescript
// From test-users.ts
export const INSTANCE_OWNER_CREDENTIALS: UserCredentials = {
email: 'nathan@n8n.io',
password: DEFAULT_USER_PASSWORD,
firstName: randFirstName(),
lastName: randLastName(),
};
// From tests
const projectName = `Test Project ${Date.now()}`;
const workflowName = `Archive Test ${Date.now()}`;
```
### 5. Proper Waiting Strategies
```typescript
// ✅ GOOD - From ProjectComposer.ts
await Promise.all([
this.n8n.page.waitForResponse('**/rest/projects/*'),
this.n8n.page.getByTestId('navigation-menu-item').filter({ hasText: 'Project' }).click(),
]);
// From NotificationsPage.ts
async waitForNotification(text: string | RegExp, options: { timeout?: number } = {}): Promise<boolean> {
const { timeout = 5000 } = options;
try {
const notification = this.notificationContainerByText(text).first();
await notification.waitFor({ state: 'visible', timeout });
return true;
} catch {
return false;
}
}
```
---
## 🚨 Common Anti-Patterns
### ❌ Don't Mix Concerns
```typescript
// BAD: From WorkflowsPage.ts - Should be in composable
async archiveWorkflow(workflowItem: Locator) {
await workflowItem.getByTestId('workflow-card-actions').click();
await this.getArchiveMenuItem().click();
}
// GOOD: Simple page object method
async clickArchiveMenuItem() {
await this.getArchiveMenuItem().click();
}
```
### ❌ Don't Use Raw Selectors in Tests
```typescript
// BAD: From 1-workflows.spec.ts
await expect(n8n.page.getByText('No workflows found')).toBeVisible();
// GOOD: Add getter to page object
await expect(n8n.workflows.getEmptyStateMessage()).toBeVisible();
```
### ❌ Don't Create Overly Specific Methods
```typescript
// BAD: Too specific
async createAndSaveNewCredentialForNotionApi(apiKey: string) {
// Too specific! Break it down
}
// GOOD: From CredentialsPage.ts - Reusable parts
async openNewCredentialDialogFromCredentialList(credentialType: string): Promise<void>
async fillCredentialField(fieldName: string, value: string)
async saveCredential()
```
---
## 📝 Code Review Checklist
Before submitting your PR, ensure:
- [ ] All page object methods follow the getter/action/query pattern
- [ ] Complex workflows are in composables, not page objects
- [ ] Tests use composables, not low-level page methods
- [ ] Used `BasePage` methods instead of raw Playwright selectors
- [ ] Added JSDoc comments for non-obvious methods
- [ ] Test names clearly describe the business scenario
- [ ] No `waitForTimeout` - used proper Playwright waiting
- [ ] Constants used for repeated strings
- [ ] Dynamic data includes timestamps to avoid conflicts
- [ ] Methods are small and focused on one responsibility
---
## 🔍 Real Implementation Example
Here's a complete example from our codebase showing all layers:
```typescript
// 1. Page Object (ProjectSettingsPage.ts)
export class ProjectSettingsPage extends BasePage {
// Simple action methods only
async fillProjectName(name: string) {
await this.page.getByTestId('project-settings-name-input').locator('input').fill(name);
}
async clickSaveButton() {
await this.clickButtonByName('Save');
}
}
// 2. Composable (ProjectComposer.ts)
export class ProjectComposer {
async createProject(projectName?: string) {
await this.n8n.page.getByTestId('universal-add').click();
await Promise.all([
this.n8n.page.waitForResponse('**/rest/projects/*'),
this.n8n.page.getByTestId('navigation-menu-item').filter({ hasText: 'Project' }).click(),
]);
await this.n8n.notifications.waitForNotificationAndClose('saved successfully');
await this.n8n.page.waitForLoadState();
const projectNameUnique = projectName ?? `Project ${Date.now()}`;
await this.n8n.projectSettings.fillProjectName(projectNameUnique);
await this.n8n.projectSettings.clickSaveButton();
const projectId = this.extractProjectIdFromPage('projects', 'settings');
return { projectName: projectNameUnique, projectId };
}
}
// 3. Test (39-projects.spec.ts)
test('should filter credentials by project ID', async ({ n8n, api }) => {
const { projectName, projectId } = await n8n.projectComposer.createProject();
await n8n.projectComposer.addCredentialToProject(
projectName,
'Notion API',
'apiKey',
NOTION_API_KEY,
);
const credentials = await getCredentialsForProject(api, projectId);
expect(credentials).toHaveLength(1);
});
```

View File

@@ -2,8 +2,8 @@
## Quick Start
```bash
pnpm test # Run all tests (fresh containers)
pnpm run test:local # Run against http://localhost:5678
pnpm test # Run all tests (fresh containers, pnpm build:local from root first to ensure local containers)
pnpm test:local # Creates isolated n8n instance on port 5679 and runs the tests against it
```
## Test Commands
@@ -39,3 +39,6 @@ test('chaos test @mode:multi-main @chaostest', ...) // Isolated per worker
- **services**: API helpers for E2E controller, REST calls, etc.
- **utils**: Utility functions (string manipulation, helpers, etc.)
- **workflows**: Test workflow JSON files for import/reuse
## Writing Tests
For guidelines on writing new tests, see [CONTRIBUTING.md](./CONTRIBUTING.md).

View File

@@ -26,4 +26,31 @@ export class WorkflowComposer {
await responsePromise;
await this.n8n.notifications.waitForNotificationAndClose(notificationMessage, { timeout });
}
/**
* Creates a new workflow by clicking the add workflow button and setting the name
* @param workflowName - The name of the workflow to create
*/
async createWorkflow(workflowName = 'My New Workflow') {
await this.n8n.workflows.clickAddWorkflowButton();
await this.n8n.canvas.setWorkflowName(workflowName);
await this.n8n.canvas.saveWorkflow();
}
/**
* Creates a new workflow by importing a JSON file
* @param fileName - The workflow JSON file name (e.g., 'test_pdf_workflow.json', will search in workflows folder)
* @param name - Optional custom name. If not provided, generates a unique name
* @returns The actual workflow name that was used
*/
async createWorkflowFromJsonFile(
fileName: string,
name?: string,
): Promise<{ workflowName: string }> {
const workflowName = name ?? `Imported Workflow ${Date.now()}`;
await this.n8n.goHome();
await this.n8n.workflows.clickAddWorkflowButton();
await this.n8n.canvas.importWorkflow(fileName, workflowName);
return { workflowName };
}
}

View File

@@ -20,7 +20,7 @@ type WorkerFixtures = {
dbSetup: undefined;
chaos: ContainerTestHelpers;
n8nContainer: N8NStack;
containerConfig: ContainerConfig; // Configuration for container creation
containerConfig: ContainerConfig;
};
interface ContainerConfig {

View File

@@ -3,8 +3,8 @@
"private": true,
"scripts": {
"test:all": "playwright test",
"test:local:reset": "N8N_BASE_URL=http://localhost:5678 RESET_E2E_DB=true playwright test --workers=4",
"test:local": "N8N_BASE_URL=http://localhost:5678 playwright test",
"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'",
"test:standard": "playwright test --project=mode:standard*",
"test:postgres": "playwright test --project=mode:postgres*",
"test:queue": "playwright test --project=mode:queue*",
@@ -15,7 +15,7 @@
"test:workflows:schema": "SCHEMA=true playwright test --project=mode:workflows",
"test:workflows:update": "playwright test --project=mode:workflows --update-snapshots",
"lint": "eslint .",
"lintfix": "eslint . --fix",
"lint:fix": "eslint . --fix",
"install-browsers:ci": "PLAYWRIGHT_BROWSERS_PATH=./ms-playwright-cache playwright install chromium --with-deps --no-shell",
"install-browsers:local": "playwright install chromium --with-deps --no-shell"
},

View File

@@ -1,12 +1,17 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
import { resolveFromRoot } from '../utils/path-helper';
export class CanvasPage extends BasePage {
saveWorkflowButton(): Locator {
return this.page.getByRole('button', { name: 'Save' });
}
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 });
}
@@ -114,4 +119,34 @@ export class CanvasPage extends BasePage {
async clickExecutionsTab(): Promise<void> {
await this.page.getByRole('radio', { name: 'Executions' }).click();
}
async setWorkflowName(name: string): Promise<void> {
await this.clickByTestId('inline-edit-preview');
await this.fillByTestId('inline-edit-input', name);
}
/**
* Import a workflow from a fixture file
* @param fixtureKey - The key of the fixture file to import
* @param workflowName - The name of the workflow to import
* Naming the file causes the workflow to save so we don't need to click save
*/
async importWorkflow(fixtureKey: string, workflowName: string) {
await this.clickByTestId('workflow-menu');
const [fileChooser] = await Promise.all([
this.page.waitForEvent('filechooser'),
this.clickByText('Import from File...'),
]);
await fileChooser.setFiles(resolveFromRoot('workflows', fixtureKey));
await this.page.waitForTimeout(250);
await this.clickByTestId('inline-edit-preview');
await this.fillByTestId('inline-edit-input', workflowName);
await this.page.getByTestId('inline-edit-input').press('Enter');
}
getWorkflowTags() {
return this.page.getByTestId('workflow-tags').locator('.el-tag');
}
}

View File

@@ -1,11 +0,0 @@
import { BasePage } from './BasePage';
export class ProjectWorkflowsPage extends BasePage {
async clickCreateWorkflowButton() {
await this.clickByTestId('add-resource-workflow');
}
async clickProjectMenuItem(projectName: string) {
await this.page.getByTestId('project-menu-item').filter({ hasText: projectName }).click();
}
}

View File

@@ -36,6 +36,10 @@ export class SidebarPage {
return this.page.getByTestId('project-menu-item');
}
async clickProjectMenuItem(projectName: string) {
await this.getProjectMenuItems().filter({ hasText: projectName }).click();
}
getAddFirstProjectButton(): Locator {
return this.page.getByTestId('add-first-project-button');
}

View File

@@ -0,0 +1,27 @@
import { BasePage } from './BasePage';
export class WorkflowSharingModal extends BasePage {
getModal() {
return this.page.getByTestId('workflowShare-modal');
}
async waitForModal() {
await this.getModal().waitFor({ state: 'visible', timeout: 5000 });
}
async addUser(email: string) {
await this.clickByTestId('project-sharing-select');
await this.page
.locator('.el-select-dropdown__item')
.filter({ hasText: email.toLowerCase() })
.click();
}
async save() {
await this.clickByTestId('workflow-sharing-modal-save-button');
}
async close() {
await this.getModal().locator('.el-dialog__close').first().click();
}
}

View File

@@ -1,5 +1,6 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
import { resolveFromRoot } from '../utils/path-helper';
export class WorkflowsPage extends BasePage {
async clickNewWorkflowCard() {
@@ -14,33 +15,135 @@ export class WorkflowsPage extends BasePage {
await this.clickByTestId('project-plus-button');
}
async clickAddWorklowButton() {
async clickAddWorkflowButton() {
await this.clickByTestId('add-resource-workflow');
}
/**
* Import a workflow from a fixture file
* @param fixtureKey - The key of the fixture file to import
* @param workflowName - The name of the workflow to import
* Naming the file causes the workflow to save so we don't need to click save
*/
async importWorkflow(fixtureKey: string, workflowName: string) {
await this.clickByTestId('workflow-menu');
const [fileChooser] = await Promise.all([
this.page.waitForEvent('filechooser'),
this.clickByText('Import from File...'),
]);
await fileChooser.setFiles(resolveFromRoot('workflows', fixtureKey));
// eslint-disable-next-line playwright/no-wait-for-timeout
await this.page.waitForTimeout(250);
await this.clickByTestId('inline-edit-preview');
await this.fillByTestId('inline-edit-input', workflowName);
await this.page.getByTestId('inline-edit-input').press('Enter');
getNewWorkflowCard() {
return this.page.getByTestId('new-workflow-card');
}
workflowTags() {
return this.page.getByTestId('workflow-tags').locator('.el-tag');
async clearSearch() {
await this.clickByTestId('resources-list-search');
await this.page.getByTestId('resources-list-search').clear();
}
getSearchBar() {
return this.page.getByTestId('resources-list-search');
}
getWorkflowFilterButton() {
return this.page.getByTestId('workflow-filter-button');
}
getWorkflowTagsDropdown() {
return this.page.getByTestId('workflow-tags-dropdown');
}
getWorkflowTagItem(tagName: string) {
return this.page.getByTestId('workflow-tag-item').filter({ hasText: tagName });
}
getWorkflowArchivedCheckbox() {
return this.page.getByTestId('workflow-archived-checkbox');
}
async unarchiveWorkflow(workflowItem: Locator) {
await workflowItem.getByTestId('workflow-card-actions').click();
await this.page.getByRole('menuitem', { name: 'Unarchive' }).click();
}
async deleteWorkflow(workflowItem: Locator) {
await workflowItem.getByTestId('workflow-card-actions').click();
await this.page.getByRole('menuitem', { name: 'Delete' }).click();
await this.page.getByRole('button', { name: 'delete' }).click();
}
async searchWorkflows(searchTerm: string) {
await this.clickByTestId('resources-list-search');
await this.fillByTestId('resources-list-search', searchTerm);
}
getWorkflowItems() {
return this.page.getByTestId('resources-list-item-workflow');
}
getWorkflowByName(name: string) {
return this.getWorkflowItems().filter({ hasText: name });
}
async shareWorkflow(workflowName: string) {
const workflow = this.getWorkflowByName(workflowName);
await workflow.getByTestId('workflow-card-actions').click();
await this.page.getByRole('menuitem', { name: 'Share' }).click();
}
getArchiveMenuItem() {
return this.page.getByRole('menuitem', { name: 'Archive' });
}
async archiveWorkflow(workflowItem: Locator) {
await workflowItem.getByTestId('workflow-card-actions').click();
await this.getArchiveMenuItem().click();
}
getFiltersButton() {
return this.page.getByTestId('resources-list-filters-trigger');
}
async openFilters() {
await this.clickByTestId('resources-list-filters-trigger');
}
async closeFilters() {
await this.clickByTestId('resources-list-filters-trigger');
}
getShowArchivedCheckbox() {
return this.page.getByTestId('show-archived-checkbox');
}
async toggleShowArchived() {
await this.openFilters();
await this.getShowArchivedCheckbox().locator('span').nth(1).click();
await this.closeFilters();
}
getStatusDropdown() {
return this.page.getByTestId('status-dropdown');
}
/**
* Select a status filter (for active/deactivated workflows)
* @param status - 'All', 'Active', or 'Deactivated'
*/
async selectStatusFilter(status: 'All' | 'Active' | 'Deactivated') {
await this.openFilters();
await this.getStatusDropdown().getByRole('combobox', { name: 'Select' }).click();
if (status === 'All') {
await this.page.getByRole('option', { name: 'All' }).click();
} else {
await this.page.getByText(status, { exact: true }).click();
}
await this.closeFilters();
}
getTagsDropdown() {
return this.page.getByTestId('tags-dropdown');
}
async filterByTags(tags: string[]) {
await this.openFilters();
await this.clickByTestId('tags-dropdown');
for (const tag of tags) {
await this.page.getByRole('option', { name: tag }).locator('span').click();
}
await this.closeFilters();
}
async filterByTag(tag: string) {
await this.filterByTags([tag]);
}
}

View File

@@ -6,8 +6,8 @@ import { ExecutionsPage } from './ExecutionsPage';
import { NodeDisplayViewPage } from './NodeDisplayViewPage';
import { NotificationsPage } from './NotificationsPage';
import { ProjectSettingsPage } from './ProjectSettingsPage';
import { ProjectWorkflowsPage } from './ProjectWorkflowsPage';
import { SidebarPage } from './SidebarPage';
import { WorkflowSharingModal } from './WorkflowSharingModal';
import { WorkflowsPage } from './WorkflowsPage';
import { CanvasComposer } from '../composables/CanvasComposer';
import { ProjectComposer } from '../composables/ProjectComposer';
@@ -19,28 +19,18 @@ export class n8nPage {
// Pages
readonly canvas: CanvasPage;
readonly ndv: NodeDisplayViewPage;
readonly projectWorkflows: ProjectWorkflowsPage;
readonly projectSettings: ProjectSettingsPage;
readonly workflows: WorkflowsPage;
readonly notifications: NotificationsPage;
readonly credentials: CredentialsPage;
readonly executions: ExecutionsPage;
readonly sideBar: SidebarPage;
// Composables
readonly workflowComposer: WorkflowComposer;
readonly workflowSharingModal: WorkflowSharingModal;
readonly projectComposer: ProjectComposer;
readonly canvasComposer: CanvasComposer;
constructor(page: Page) {
@@ -49,13 +39,13 @@ export class n8nPage {
// Pages
this.canvas = new CanvasPage(page);
this.ndv = new NodeDisplayViewPage(page);
this.projectWorkflows = new ProjectWorkflowsPage(page);
this.projectSettings = new ProjectSettingsPage(page);
this.workflows = new WorkflowsPage(page);
this.notifications = new NotificationsPage(page);
this.credentials = new CredentialsPage(page);
this.executions = new ExecutionsPage(page);
this.sideBar = new SidebarPage(page);
this.workflowSharingModal = new WorkflowSharingModal(page);
// Composables
this.workflowComposer = new WorkflowComposer(this);

View File

@@ -144,7 +144,7 @@ export default defineConfig({
video: 'on',
screenshot: 'on',
testIdAttribute: 'data-test-id',
headless: true,
headless: process.env.SHOW_BROWSER !== 'true',
viewport: { width: 1536, height: 960 },
actionTimeout: 30000,
navigationTimeout: 10000,

View File

@@ -1,11 +1,151 @@
import { test, expect } from '../fixtures/base';
// Example of importing a workflow from a file
const NOTIFICATIONS = {
CREATED: 'Workflow successfully created',
ARCHIVED: 'archived',
UNARCHIVED: 'unarchived',
DELETED: 'deleted',
};
test.describe('Workflows', () => {
test('should create a new workflow using empty state card @db:reset', async ({ n8n }) => {
test.beforeEach(async ({ n8n }) => {
await n8n.goHome();
});
test('should create a new workflow using empty state card @db:reset', async ({ n8n }) => {
await n8n.workflows.clickNewWorkflowCard();
await n8n.workflows.importWorkflow('Test_workflow_1.json', 'Empty State Card Workflow');
await expect(n8n.workflows.workflowTags()).toHaveText(['some-tag-1', 'some-tag-2']);
await n8n.canvas.importWorkflow('Test_workflow_1.json', 'Empty State Card Workflow');
await expect(n8n.canvas.getWorkflowTags()).toHaveText(['some-tag-1', 'some-tag-2']);
});
test('should create a new workflow using add workflow button', async ({ n8n }) => {
await n8n.workflows.clickAddWorkflowButton();
const workflowName = `Test Workflow ${Date.now()}`;
await n8n.canvas.setWorkflowName(workflowName);
await n8n.canvas.clickSaveWorkflowButton();
await expect(
n8n.notifications.notificationContainerByText(NOTIFICATIONS.CREATED),
).toBeVisible();
});
test('should search for workflows', async ({ n8n }) => {
const date = Date.now();
const specificName = `Specific Test ${date}`;
const genericName = `Generic Test ${date}`;
await n8n.workflowComposer.createWorkflow(specificName);
await n8n.goHome();
await n8n.workflowComposer.createWorkflow(genericName);
await n8n.goHome();
// Search for specific workflow
await n8n.workflows.searchWorkflows(specificName);
await expect(n8n.workflows.getWorkflowItems()).toHaveCount(1);
await expect(n8n.workflows.getWorkflowByName(specificName)).toBeVisible();
// Search with partial term
await n8n.workflows.clearSearch();
await n8n.workflows.searchWorkflows(date.toString());
await expect(n8n.workflows.getWorkflowItems()).toHaveCount(2);
// Search for non-existent
await n8n.workflows.clearSearch();
await n8n.workflows.searchWorkflows('NonExistentWorkflow123');
await expect(n8n.workflows.getWorkflowItems()).toHaveCount(0);
await expect(n8n.page.getByText('No workflows found')).toBeVisible();
});
test('should archive and unarchive a workflow', async ({ n8n }) => {
const workflowName = `Archive Test ${Date.now()}`;
await n8n.workflowComposer.createWorkflow(workflowName);
await n8n.goHome();
// Create a second workflow so we can still see filters
await n8n.workflowComposer.createWorkflow();
await n8n.goHome();
const workflow = n8n.workflows.getWorkflowByName(workflowName);
await n8n.workflows.archiveWorkflow(workflow);
await expect(
n8n.notifications.notificationContainerByText(NOTIFICATIONS.ARCHIVED),
).toBeVisible();
await expect(workflow).toBeHidden();
await n8n.workflows.toggleShowArchived();
await expect(workflow).toBeVisible();
await n8n.workflows.unarchiveWorkflow(workflow);
await expect(
n8n.notifications.notificationContainerByText(NOTIFICATIONS.UNARCHIVED),
).toBeVisible();
});
test('should delete an archived workflow', async ({ n8n }) => {
const workflowName = `Delete Test ${Date.now()}`;
await n8n.workflowComposer.createWorkflow(workflowName);
await n8n.goHome();
await n8n.workflowComposer.createWorkflow();
await n8n.goHome();
const workflow = n8n.workflows.getWorkflowByName(workflowName);
await n8n.workflows.archiveWorkflow(workflow);
await expect(
n8n.notifications.notificationContainerByText(NOTIFICATIONS.ARCHIVED),
).toBeVisible();
await n8n.workflows.toggleShowArchived();
await n8n.workflows.deleteWorkflow(workflow);
await expect(
n8n.notifications.notificationContainerByText(NOTIFICATIONS.DELETED),
).toBeVisible();
await expect(workflow).toBeHidden();
});
test('should filter workflows by tag @db:reset', async ({ n8n }) => {
const taggedWorkflow =
await n8n.workflowComposer.createWorkflowFromJsonFile('Test_workflow_1.json');
await n8n.workflowComposer.createWorkflowFromJsonFile('Test_workflow_2.json');
await n8n.goHome();
await n8n.workflows.filterByTag('some-tag-1');
await expect(n8n.workflows.getWorkflowByName(taggedWorkflow.workflowName)).toBeVisible();
});
test('should preserve search and filters in URL @db:reset', async ({ n8n }) => {
const date = Date.now();
await n8n.workflowComposer.createWorkflowFromJsonFile(
'Test_workflow_2.json',
`My Tagged Workflow ${date}`,
);
await n8n.goHome();
// Apply search
await n8n.workflows.searchWorkflows('Tagged');
// Apply tag filter
await n8n.workflows.filterByTag('other-tag-1');
// Verify URL contains filters
await expect(n8n.page).toHaveURL(/search=Tagged/);
// Reload and verify filters persist
await n8n.page.reload();
await expect(n8n.workflows.getSearchBar()).toHaveValue('Tagged');
await expect(n8n.workflows.getWorkflowByName(`My Tagged Workflow ${date}`)).toBeVisible();
});
test('should share a workflow', async ({ n8n }) => {
const workflowName = `Share Test ${Date.now()}`;
await n8n.workflowComposer.createWorkflow(workflowName);
await n8n.goHome();
await n8n.workflows.shareWorkflow(workflowName);
await expect(n8n.workflowSharingModal.getModal()).toBeVisible();
});
});

View File

@@ -1,4 +1,5 @@
import { test, expect } from '../fixtures/base';
import type { n8nPage } from '../pages/n8nPage';
// Example of using helper functions inside a test
test.describe('Debug mode', () => {
@@ -22,8 +23,8 @@ test.describe('Debug mode', () => {
});
// Helper function to create basic workflow
async function createBasicWorkflow(n8n, url = URLS.FAILING) {
await n8n.workflows.clickAddWorklowButton();
async function createBasicWorkflow(n8n: n8nPage, url = URLS.FAILING) {
await n8n.workflows.clickAddWorkflowButton();
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('HTTP Request');
await n8n.ndv.fillParameterInput('URL', url);
@@ -33,7 +34,7 @@ test.describe('Debug mode', () => {
}
// Helper function to import execution for debugging
async function importExecutionForDebugging(n8n) {
async function importExecutionForDebugging(n8n: n8nPage) {
await n8n.canvas.clickExecutionsTab();
await n8n.executions.clickDebugInEditorButton();
await n8n.notifications.waitForNotificationAndClose(NOTIFICATIONS.EXECUTION_IMPORTED);
@@ -105,7 +106,7 @@ test.describe('Debug mode', () => {
expect(n8n.page.url()).toContain('/debug');
});
async function attemptCopyToEditor(n8n) {
async function attemptCopyToEditor(n8n: n8nPage) {
await n8n.canvas.clickExecutionsTab();
await n8n.executions.clickLastExecutionItem();
await n8n.executions.clickCopyToEditorButton();

View File

@@ -85,7 +85,7 @@ test.describe('Projects @db:reset', () => {
await subn8n.canvas.saveWorkflow();
await subn8n.page.goto('/home/workflows');
await subn8n.projectWorkflows.clickProjectMenuItem(projectName);
await subn8n.sideBar.clickProjectMenuItem(projectName);
await subn8n.page.getByRole('link', { name: 'Workflows' }).click();
// Get Workflow Count

View File

@@ -5,8 +5,8 @@ test.describe('PDF Test', () => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip('Can read and write PDF files and extract text', async ({ n8n }) => {
await n8n.goHome();
await n8n.workflows.clickAddWorklowButton();
await n8n.workflows.importWorkflow('test_pdf_workflow.json', 'PDF Workflow');
await n8n.workflows.clickAddWorkflowButton();
await n8n.canvas.importWorkflow('test_pdf_workflow.json', 'PDF Workflow');
await n8n.canvas.clickExecuteWorkflowButton();
await expect(
n8n.notifications.notificationContainerByText('Workflow executed successfully'),

View File

@@ -0,0 +1,55 @@
{
"name": "Test workflow 2",
"nodes": [
{
"parameters": {},
"id": "624e0991-5dac-468b-b872-a9d35cb2c7d1",
"name": "On clicking 'execute'",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [360, 260]
},
{
"parameters": {
"jsCode": "// Loop over input items and add a new field\n// called 'myNewField' to the JSON of each one\nfor (const item of $input.all()) {\n item.json.myNewField = 1;\n}\n\nreturn $input.all();"
},
"id": "48823b3a-ec82-4a05-84b8-24ac2747e648",
"name": "Code",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [580, 260]
}
],
"pinData": {},
"connections": {
"On clicking 'execute'": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {},
"hash": "4d2e29ffcae2a12bdd28a7abe9681a6b",
"id": 4,
"tags": [
{
"name": "other-tag-1",
"createdAt": "2022-11-10T13:45:43.821Z",
"updatedAt": "2022-11-10T13:45:43.821Z",
"id": "8"
},
{
"name": "other-tag-2",
"createdAt": "2022-11-10T13:45:46.881Z",
"updatedAt": "2022-11-10T13:45:46.881Z",
"id": "9"
}
]
}