mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
test: Migrate 1-workflows to Playwright (#17360)
This commit is contained in:
270
cypress-playwright-migration.md
Normal file
270
cypress-playwright-migration.md
Normal 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/)
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
534
packages/testing/playwright/CONTRIBUTING.md
Normal file
534
packages/testing/playwright/CONTRIBUTING.md
Normal 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);
|
||||
});
|
||||
```
|
||||
@@ -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
|
||||
@@ -38,4 +38,7 @@ test('chaos test @mode:multi-main @chaostest', ...) // Isolated per worker
|
||||
- **pages**: Page Object Models for UI interactions
|
||||
- **services**: API helpers for E2E controller, REST calls, etc.
|
||||
- **utils**: Utility functions (string manipulation, helpers, etc.)
|
||||
- **workflows**: Test workflow JSON files for import/reuse
|
||||
- **workflows**: Test workflow JSON files for import/reuse
|
||||
|
||||
## Writing Tests
|
||||
For guidelines on writing new tests, see [CONTRIBUTING.md](./CONTRIBUTING.md).
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ type WorkerFixtures = {
|
||||
dbSetup: undefined;
|
||||
chaos: ContainerTestHelpers;
|
||||
n8nContainer: N8NStack;
|
||||
containerConfig: ContainerConfig; // Configuration for container creation
|
||||
containerConfig: ContainerConfig;
|
||||
};
|
||||
|
||||
interface ContainerConfig {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
27
packages/testing/playwright/pages/WorkflowSharingModal.ts
Normal file
27
packages/testing/playwright/pages/WorkflowSharingModal.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
55
packages/testing/playwright/workflows/Test_workflow_2.json
Normal file
55
packages/testing/playwright/workflows/Test_workflow_2.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user