diff --git a/cypress-playwright-migration.md b/cypress-playwright-migration.md new file mode 100644 index 0000000000..bdbd2440e2 --- /dev/null +++ b/cypress-playwright-migration.md @@ -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/) \ No newline at end of file diff --git a/cypress/e2e/1-workflows.cy.ts b/cypress/e2e/1-workflows.cy.ts index b447d1aa96..8025a306eb 100644 --- a/cypress/e2e/1-workflows.cy.ts +++ b/cypress/e2e/1-workflows.cy.ts @@ -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); }); diff --git a/packages/testing/playwright/CONTRIBUTING.md b/packages/testing/playwright/CONTRIBUTING.md new file mode 100644 index 0000000000..110f824357 --- /dev/null +++ b/packages/testing/playwright/CONTRIBUTING.md @@ -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 { + await this.nodeDeleteButton(nodeName).click(); +} + +async openNode(nodeName: string): Promise { + await this.nodeByName(nodeName).dblclick(); +} +``` + +#### 3. Query Methods (`async`, return data) +```typescript +// From CanvasPage.ts +async getPinnedNodeNames(): Promise { + 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 { + 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 { + await this.clickButtonByName('Debug in editor'); + } + + async clickLastExecutionItem(): Promise { + const executionItem = this.getLastExecutionItem(); + await executionItem.click(); + } + + // ❌ AVOID: Mixed concerns (this should be in a composable) + async handlePinnedNodesConfirmation(action: 'Unpin' | 'Cancel'): Promise { + // 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 { + 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 +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); +}); +``` diff --git a/packages/testing/playwright/README.md b/packages/testing/playwright/README.md index 1206e2cc8e..b87d437eaa 100644 --- a/packages/testing/playwright/README.md +++ b/packages/testing/playwright/README.md @@ -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 \ No newline at end of file +- **workflows**: Test workflow JSON files for import/reuse + +## Writing Tests +For guidelines on writing new tests, see [CONTRIBUTING.md](./CONTRIBUTING.md). diff --git a/packages/testing/playwright/composables/WorkflowComposer.ts b/packages/testing/playwright/composables/WorkflowComposer.ts index 263884244f..b458c96cbd 100644 --- a/packages/testing/playwright/composables/WorkflowComposer.ts +++ b/packages/testing/playwright/composables/WorkflowComposer.ts @@ -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 }; + } } diff --git a/packages/testing/playwright/fixtures/base.ts b/packages/testing/playwright/fixtures/base.ts index 38fecfad0b..ce36e83fdf 100644 --- a/packages/testing/playwright/fixtures/base.ts +++ b/packages/testing/playwright/fixtures/base.ts @@ -20,7 +20,7 @@ type WorkerFixtures = { dbSetup: undefined; chaos: ContainerTestHelpers; n8nContainer: N8NStack; - containerConfig: ContainerConfig; // Configuration for container creation + containerConfig: ContainerConfig; }; interface ContainerConfig { diff --git a/packages/testing/playwright/package.json b/packages/testing/playwright/package.json index f03e0e8de5..00126e407c 100644 --- a/packages/testing/playwright/package.json +++ b/packages/testing/playwright/package.json @@ -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" }, diff --git a/packages/testing/playwright/pages/CanvasPage.ts b/packages/testing/playwright/pages/CanvasPage.ts index 500eb42c21..ff8a9576f0 100644 --- a/packages/testing/playwright/pages/CanvasPage.ts +++ b/packages/testing/playwright/pages/CanvasPage.ts @@ -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 { await this.page.getByRole('radio', { name: 'Executions' }).click(); } + + async setWorkflowName(name: string): Promise { + 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'); + } } diff --git a/packages/testing/playwright/pages/ProjectWorkflowsPage.ts b/packages/testing/playwright/pages/ProjectWorkflowsPage.ts deleted file mode 100644 index ba08dc021a..0000000000 --- a/packages/testing/playwright/pages/ProjectWorkflowsPage.ts +++ /dev/null @@ -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(); - } -} diff --git a/packages/testing/playwright/pages/SidebarPage.ts b/packages/testing/playwright/pages/SidebarPage.ts index d466e75487..e11b1f64a0 100644 --- a/packages/testing/playwright/pages/SidebarPage.ts +++ b/packages/testing/playwright/pages/SidebarPage.ts @@ -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'); } diff --git a/packages/testing/playwright/pages/WorkflowSharingModal.ts b/packages/testing/playwright/pages/WorkflowSharingModal.ts new file mode 100644 index 0000000000..f791ebb4e0 --- /dev/null +++ b/packages/testing/playwright/pages/WorkflowSharingModal.ts @@ -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(); + } +} diff --git a/packages/testing/playwright/pages/WorkflowsPage.ts b/packages/testing/playwright/pages/WorkflowsPage.ts index 85f042a965..b38cf82bf7 100644 --- a/packages/testing/playwright/pages/WorkflowsPage.ts +++ b/packages/testing/playwright/pages/WorkflowsPage.ts @@ -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]); } } diff --git a/packages/testing/playwright/pages/n8nPage.ts b/packages/testing/playwright/pages/n8nPage.ts index 40f65ee657..8904a879dd 100644 --- a/packages/testing/playwright/pages/n8nPage.ts +++ b/packages/testing/playwright/pages/n8nPage.ts @@ -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); diff --git a/packages/testing/playwright/playwright.config.ts b/packages/testing/playwright/playwright.config.ts index 5e4b342924..19b55a48da 100644 --- a/packages/testing/playwright/playwright.config.ts +++ b/packages/testing/playwright/playwright.config.ts @@ -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, diff --git a/packages/testing/playwright/tests/1-workflows.spec.ts b/packages/testing/playwright/tests/1-workflows.spec.ts index a544f1ae2e..1887a88566 100644 --- a/packages/testing/playwright/tests/1-workflows.spec.ts +++ b/packages/testing/playwright/tests/1-workflows.spec.ts @@ -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(); }); }); diff --git a/packages/testing/playwright/tests/28-debug.spec.ts b/packages/testing/playwright/tests/28-debug.spec.ts index 2cd5cd8fd5..8d8475ee85 100644 --- a/packages/testing/playwright/tests/28-debug.spec.ts +++ b/packages/testing/playwright/tests/28-debug.spec.ts @@ -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(); diff --git a/packages/testing/playwright/tests/39-projects.spec.ts b/packages/testing/playwright/tests/39-projects.spec.ts index d382b98cc0..9c86c80438 100644 --- a/packages/testing/playwright/tests/39-projects.spec.ts +++ b/packages/testing/playwright/tests/39-projects.spec.ts @@ -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 diff --git a/packages/testing/playwright/tests/pdf.spec.ts b/packages/testing/playwright/tests/pdf.spec.ts index 74cd0ac730..107f6ed6c1 100644 --- a/packages/testing/playwright/tests/pdf.spec.ts +++ b/packages/testing/playwright/tests/pdf.spec.ts @@ -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'), diff --git a/packages/testing/playwright/workflows/Test_workflow_2.json b/packages/testing/playwright/workflows/Test_workflow_2.json new file mode 100644 index 0000000000..ea5a000174 --- /dev/null +++ b/packages/testing/playwright/workflows/Test_workflow_2.json @@ -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" + } + ] +}