chore: 清理macOS同步产生的重复文件

详细说明:
- 删除了352个带数字后缀的重复文件
- 更新.gitignore防止未来产生此类文件
- 这些文件是由iCloud或其他同步服务冲突产生的
- 不影响项目功能,仅清理冗余文件
This commit is contained in:
Yep_Q
2025-09-08 12:06:01 +08:00
parent 1564396449
commit d6f48d6d14
365 changed files with 2039 additions and 68301 deletions

View File

@@ -1,567 +0,0 @@
# 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 supports both UI-driven and API-driven testing:
### UI Testing (Four-Layer Approach)
```
Tests (*.spec.ts)
↓ uses
Composables (*Composer.ts) - Business workflows
↓ orchestrates
Page Objects (*Page.ts) - UI interactions
↓ extends
BasePage - Common utilities
```
### API Testing (Two-Layer Approach)
```
Tests (*.spec.ts)
↓ uses
API Services (ApiHelpers + specialized helpers)
```
### Core Principle: Separation of Concerns
- **BasePage**: Generic interaction methods
- **Page Objects**: Element locators and simple actions
- **Composables**: Complex business workflows
- **API Services**: REST API interactions, workflow management
- **Tests**: Readable scenarios using composables or API services
---
## 📐 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
#### UI 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');
});
```
#### API Tests
```typescript
// ✅ GOOD: API-driven workflow testing
test('should create workflow via API, activate it, trigger webhook externally @auth:owner', async ({ api }) => {
const workflowDefinition = JSON.parse(
readFileSync(resolveFromRoot('workflows', 'simple-webhook-test.json'), 'utf8'),
);
const createdWorkflow = await api.workflowApi.createWorkflow(workflowDefinition);
await api.workflowApi.setActive(createdWorkflow.id, true);
const testPayload = { message: 'Hello from Playwright test' };
const webhookResponse = await api.workflowApi.triggerWebhook('test-webhook', { data: testPayload });
expect(webhookResponse.ok()).toBe(true);
const execution = await api.workflowApi.waitForExecution(createdWorkflow.id, 10000);
expect(execution.status).toBe('success');
const executionDetails = await api.workflowApi.getExecution(execution.id);
expect(executionDetails.data).toContain('Hello from Playwright test');
});
```
---
## 🎯 Best Practices
### 1. Always Use BasePage Methods
```typescript
// ✅ GOOD - From NodeDisplayViewPage.ts
async fillParameterInput(labelName: string, value: string) {
await this.getParameterByLabel(labelName).getByTestId('parameter-input-field').fill(value);
}
async clickBackToCanvasButton() {
await this.clickByTestId('back-to-canvas');
}
// ❌ AVOID
async badExample() {
await this.page.getByTestId('back-to-canvas').click();
}
```
### 2. Keep Page Objects Simple
```typescript
// ✅ GOOD - From CredentialsPage.ts
export class CredentialsPage extends BasePage {
async openCredentialSelector() {
await this.page.getByRole('combobox', { name: 'Select Credential' }).click();
}
async createNewCredential() {
await this.clickByText('Create new credential');
}
async fillCredentialField(fieldName: string, value: string) {
const field = this.page
.getByTestId(`parameter-input-${fieldName}`)
.getByTestId('parameter-input-field');
await field.click();
await field.fill(value);
}
}
```
### 3. Use Constants for Repeated Values
```typescript
// From constants.ts
export const MANUAL_TRIGGER_NODE_NAME = 'Manual Trigger';
export const MANUAL_TRIGGER_NODE_DISPLAY_NAME = 'When clicking 'Execute workflow'';
export const CODE_NODE_NAME = 'Code';
export const SET_NODE_NAME = 'Set';
export const HTTP_REQUEST_NODE_NAME = 'HTTP Request';
// From 28-debug.spec.ts
const NOTIFICATIONS = {
WORKFLOW_CREATED: 'Workflow successfully created',
EXECUTION_IMPORTED: 'Execution data imported',
PROBLEM_IN_NODE: 'Problem in node',
SUCCESSFUL: 'Successful',
DATA_NOT_IMPORTED: "Some execution data wasn't imported",
};
```
### 4. Handle Dynamic Data
```typescript
// From test-users.ts
export const INSTANCE_OWNER_CREDENTIALS: UserCredentials = {
email: 'nathan@n8n.io',
password: DEFAULT_USER_PASSWORD,
firstName: randFirstName(),
lastName: randLastName(),
};
// From tests
const projectName = `Test Project ${Date.now()}`;
const workflowName = `Archive Test ${Date.now()}`;
```
### 5. Proper Waiting Strategies
```typescript
// ✅ GOOD - From ProjectComposer.ts
await Promise.all([
this.n8n.page.waitForResponse('**/rest/projects/*'),
this.n8n.page.getByTestId('navigation-menu-item').filter({ hasText: 'Project' }).click(),
]);
// From NotificationsPage.ts
async waitForNotification(text: string | RegExp, options: { timeout?: number } = {}): Promise<boolean> {
const { timeout = 5000 } = options;
try {
const notification = this.notificationContainerByText(text).first();
await notification.waitFor({ state: 'visible', timeout });
return true;
} catch {
return false;
}
}
```
---
## 🚨 Common Anti-Patterns
### ❌ Don't Mix Concerns
```typescript
// BAD: From WorkflowsPage.ts - Should be in composable
async archiveWorkflow(workflowItem: Locator) {
await workflowItem.getByTestId('workflow-card-actions').click();
await this.getArchiveMenuItem().click();
}
// GOOD: Simple page object method
async clickArchiveMenuItem() {
await this.getArchiveMenuItem().click();
}
```
### ❌ Don't Use Raw Selectors in Tests
```typescript
// BAD: From 1-workflows.spec.ts
await expect(n8n.page.getByText('No workflows found')).toBeVisible();
// GOOD: Add getter to page object
await expect(n8n.workflows.getEmptyStateMessage()).toBeVisible();
```
### ❌ Don't Create Overly Specific Methods
```typescript
// BAD: Too specific
async createAndSaveNewCredentialForNotionApi(apiKey: string) {
// Too specific! Break it down
}
// GOOD: From CredentialsPage.ts - Reusable parts
async openNewCredentialDialogFromCredentialList(credentialType: string): Promise<void>
async fillCredentialField(fieldName: string, value: string)
async saveCredential()
```
---
## 📝 Code Review Checklist
Before submitting your PR, ensure:
- [ ] All page object methods follow the getter/action/query pattern
- [ ] Complex workflows are in composables, not page objects
- [ ] Tests use composables, not low-level page methods
- [ ] Used `BasePage` methods instead of raw Playwright selectors
- [ ] Added JSDoc comments for non-obvious methods
- [ ] Test names clearly describe the business scenario
- [ ] No `waitForTimeout` - used proper Playwright waiting
- [ ] Constants used for repeated strings
- [ ] Dynamic data includes timestamps to avoid conflicts
- [ ] Methods are small and focused on one responsibility
---
## 🔍 Real Implementation Example
Here's a complete example from our codebase showing all layers:
```typescript
// 1. Page Object (ProjectSettingsPage.ts)
export class ProjectSettingsPage extends BasePage {
// Simple action methods only
async fillProjectName(name: string) {
await this.page.getByTestId('project-settings-name-input').locator('input').fill(name);
}
async clickSaveButton() {
await this.clickButtonByName('Save');
}
}
// 2. Composable (ProjectComposer.ts)
export class ProjectComposer {
async createProject(projectName?: string) {
await this.n8n.page.getByTestId('universal-add').click();
await Promise.all([
this.n8n.page.waitForResponse('**/rest/projects/*'),
this.n8n.page.getByTestId('navigation-menu-item').filter({ hasText: 'Project' }).click(),
]);
await this.n8n.notifications.waitForNotificationAndClose('saved successfully');
await this.n8n.page.waitForLoadState();
const projectNameUnique = projectName ?? `Project ${Date.now()}`;
await this.n8n.projectSettings.fillProjectName(projectNameUnique);
await this.n8n.projectSettings.clickSaveButton();
const projectId = this.extractProjectIdFromPage('projects', 'settings');
return { projectName: projectNameUnique, projectId };
}
}
// 3. Test (39-projects.spec.ts)
test('should filter credentials by project ID', async ({ n8n, api }) => {
const { projectName, projectId } = await n8n.projectComposer.createProject();
await n8n.projectComposer.addCredentialToProject(
projectName,
'Notion API',
'apiKey',
NOTION_API_KEY,
);
const credentials = await getCredentialsForProject(api, projectId);
expect(credentials).toHaveLength(1);
});
```

View File

@@ -1,40 +0,0 @@
import type { n8nPage } from '../pages/n8nPage';
export class CanvasComposer {
constructor(private readonly n8n: n8nPage) {}
/**
* 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();
}
/**
* Execute a node and wait for success toast notification
* @param nodeName - The node to execute
*/
async executeNodeAndWaitForToast(nodeName: string): Promise<void> {
await this.n8n.canvas.executeNode(nodeName);
await this.n8n.notifications.waitForNotificationAndClose('Node executed successfully');
}
/**
* Copy selected nodes and verify success toast
*/
async copySelectedNodesWithToast(): Promise<void> {
await this.n8n.canvas.copyNodes();
await this.n8n.notifications.waitForNotificationAndClose('Copied to clipboard');
}
/**
* Select all nodes and copy them
*/
async selectAllAndCopy(): Promise<void> {
await this.n8n.canvas.selectAll();
await this.copySelectedNodesWithToast();
}
}

View File

@@ -1,55 +0,0 @@
import { nanoid } from 'nanoid';
import type { n8nPage } from '../pages/n8nPage';
export class ProjectComposer {
constructor(private readonly n8n: n8nPage) {}
/**
* Create a project and return the project name and ID. If no project name is provided, a unique name will be generated.
* @param projectName - The name of the project to create.
* @returns The project name and ID.
*/
async createProject(projectName?: string) {
await this.n8n.page.getByTestId('universal-add').click();
await 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 ${nanoid(8)}`;
await this.n8n.projectSettings.fillProjectName(projectNameUnique);
await this.n8n.projectSettings.clickSaveButton();
const projectId = this.extractProjectIdFromPage('projects', 'settings');
return { projectName: projectNameUnique, projectId };
}
/**
* Add a new credential to a project.
* @param projectName - The name of the project to add the credential to.
* @param credentialType - The type of credential to add by visible name e.g 'Notion API'
* @param credentialFieldName - The name of the field to add the credential to. e.g. 'apiKey' which would be data-test-id='parameter-input-apiKey'
* @param credentialValue - The value of the credential to add.
*/
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();
}
extractIdFromUrl(url: string, beforeWord: string, afterWord: string): string {
const path = url.includes('://') ? new URL(url).pathname : url;
const match = path.match(new RegExp(`/${beforeWord}/([^/]+)/${afterWord}`));
return match?.[1] ?? '';
}
extractProjectIdFromPage(beforeWord: string, afterWord: string): string {
return this.extractIdFromUrl(this.n8n.page.url(), beforeWord, afterWord);
}
}

View File

@@ -1,54 +0,0 @@
import type { n8nPage } from '../pages/n8nPage';
/**
* Composer for UI test entry points. All methods in this class navigate to or verify UI state.
* For API-only testing, use the standalone `api` fixture directly instead.
*/
export class TestEntryComposer {
constructor(private readonly n8n: n8nPage) {}
/**
* Start UI test from the home page and navigate to canvas
*/
async fromHome() {
await this.n8n.goHome();
await this.n8n.page.waitForURL('/home/workflows');
}
/**
* Start UI test from a blank canvas (assumes already on canvas)
*/
async fromBlankCanvas() {
await this.n8n.goHome();
await this.n8n.workflows.clickAddWorkflowButton();
// Verify we're on canvas
await this.n8n.canvas.canvasPane().isVisible();
}
/**
* Start UI test from a workflow in a new project
*/
async fromNewProject() {
// Enable features to allow us to create a new project
await this.n8n.api.enableFeature('projectRole:admin');
await this.n8n.api.enableFeature('projectRole:editor');
await this.n8n.api.setMaxTeamProjectsQuota(-1);
// Create a project using the API
const response = await this.n8n.api.projectApi.createProject();
const projectId = response.id;
await this.n8n.page.goto(`workflow/new?projectId=${projectId}`);
await this.n8n.canvas.canvasPane().isVisible();
}
/**
* Start UI test from the canvas of an imported workflow
* Returns the workflow import result for use in the test
*/
async fromImportedWorkflow(workflowFile: string) {
const workflowImportResult = await this.n8n.api.workflowApi.importWorkflow(workflowFile);
await this.n8n.page.goto(`workflow/${workflowImportResult.workflowId}`);
return workflowImportResult;
}
}

View File

@@ -1,65 +0,0 @@
import { nanoid } from 'nanoid';
import type { n8nPage } from '../pages/n8nPage';
/**
* A class for user interactions with workflows that go across multiple pages.
*/
export class WorkflowComposer {
constructor(private readonly n8n: n8nPage) {}
/**
* Executes a successful workflow and waits for the notification to be closed.
* This waits for http calls and also closes the notification.
*/
async executeWorkflowAndWaitForNotification(
notificationMessage: string,
options: { timeout?: number } = {},
) {
const { timeout = 3000 } = options;
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, { 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);
const responsePromise = this.n8n.page.waitForResponse(
(response) =>
response.url().includes('/rest/workflows') && response.request().method() === 'POST',
);
await this.n8n.canvas.saveWorkflow();
await responsePromise;
}
/**
* 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 ${nanoid(8)}`;
await this.n8n.goHome();
await this.n8n.workflows.clickAddWorkflowButton();
await this.n8n.canvas.importWorkflow(fileName, workflowName);
return { workflowName };
}
}

View File

@@ -1,71 +0,0 @@
import { baseConfig } from '@n8n/eslint-config/base';
import playwrightPlugin from 'eslint-plugin-playwright';
export default [
...baseConfig,
playwrightPlugin.configs['flat/recommended'],
{
ignores: ['playwright-report/**/*', 'ms-playwright-cache/**/*'],
},
{
rules: {
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/no-unused-expressions': 'off',
'@typescript-eslint/no-use-before-define': 'off',
'@typescript-eslint/promise-function-async': 'off',
'n8n-local-rules/no-uncaught-json-parse': 'off',
'playwright/expect-expect': 'warn',
'playwright/max-nested-describe': 'warn',
'playwright/no-conditional-in-test': 'error',
'playwright/no-skipped-test': 'warn',
// Allow any naming convention for TestRequirements object properties
// This is specifically for workflow filenames and intercept keys that may not follow camelCase
'@typescript-eslint/naming-convention': [
'error',
{
selector: 'default',
format: ['camelCase'],
leadingUnderscore: 'allow',
trailingUnderscore: 'allow',
},
{
selector: 'variable',
format: ['camelCase', 'UPPER_CASE'],
},
{
selector: 'typeLike',
format: ['PascalCase'],
},
{
selector: 'property',
format: ['camelCase', 'snake_case', 'UPPER_CASE'],
filter: {
// Allow any format for properties in TestRequirements objects (workflow files, intercept keys, etc.)
regex: '^(workflow|intercepts|storage|config)$',
match: false,
},
},
{
selector: 'objectLiteralProperty',
format: null, // Allow any format for object literal properties in TestRequirements
filter: {
// This allows workflow filenames and intercept keys to use any naming convention
regex: '\\.(json|spec\\.ts)$|[a-zA-Z0-9_-]+',
match: true,
},
},
],
'import-x/no-extraneous-dependencies': [
'error',
{
devDependencies: ['**/tests/**', '**/e2e/**', '**/playwright/**'],
optionalDependencies: false,
},
],
},
},
];

View File

@@ -1,106 +0,0 @@
import type { NodeDetailsViewPage } from '../pages/NodeDetailsViewPage';
/**
* Helper class for setting node parameters in the NDV
*/
export class NodeParameterHelper {
constructor(private ndv: NodeDetailsViewPage) {}
/**
* Detects parameter type by checking DOM structure
* Supports dropdown, text, and switch parameters
* @param parameterName - The parameter name to check
* @returns The detected parameter type
*/
async detectParameterType(parameterName: string): Promise<'dropdown' | 'text' | 'switch'> {
const parameterContainer = this.ndv.getParameterInput(parameterName);
const [hasSwitch, hasSelect, hasSelectCaret] = await Promise.all([
parameterContainer
.locator('.el-switch')
.count()
.then((count) => count > 0),
parameterContainer
.locator('.el-select')
.count()
.then((count) => count > 0),
parameterContainer
.locator('.el-select__caret')
.count()
.then((count) => count > 0),
]);
if (hasSwitch) return 'switch';
if (hasSelect && hasSelectCaret) return 'dropdown';
return 'text';
}
/**
* Sets a parameter value with automatic type detection or explicit type
* Supports dropdown, text, and switch parameters
* @param parameterName - Name of the parameter to set
* @param value - Value to set (string or boolean)
* @param type - Optional explicit type to skip detection for better performance
*/
async setParameter(
parameterName: string,
value: string | boolean,
type?: 'dropdown' | 'text' | 'switch',
): Promise<void> {
if (typeof value === 'boolean') {
await this.ndv.setParameterSwitch(parameterName, value);
return;
}
const parameterType = type ?? (await this.detectParameterType(parameterName));
switch (parameterType) {
case 'dropdown':
await this.ndv.setParameterDropdown(parameterName, value);
break;
case 'text':
await this.ndv.setParameterInput(parameterName, value);
break;
case 'switch':
await this.ndv.setParameterSwitch(parameterName, value === 'true');
break;
}
}
async webhook(config: {
httpMethod?: string;
path?: string;
authentication?: string;
responseMode?: string;
}): Promise<void> {
if (config.httpMethod !== undefined)
await this.setParameter('httpMethod', config.httpMethod, 'dropdown');
if (config.path !== undefined) await this.setParameter('path', config.path, 'text');
if (config.authentication !== undefined)
await this.setParameter('authentication', config.authentication, 'dropdown');
if (config.responseMode !== undefined)
await this.setParameter('responseMode', config.responseMode, 'dropdown');
}
/**
* Simplified HTTP Request node parameter configuration
* @param config - Configuration object with parameter values
*/
async httpRequest(config: {
method?: string;
url?: string;
authentication?: string;
sendQuery?: boolean;
sendHeaders?: boolean;
sendBody?: boolean;
}): Promise<void> {
if (config.method !== undefined) await this.setParameter('method', config.method, 'dropdown');
if (config.url !== undefined) await this.setParameter('url', config.url, 'text');
if (config.authentication !== undefined)
await this.setParameter('authentication', config.authentication, 'dropdown');
if (config.sendQuery !== undefined)
await this.setParameter('sendQuery', config.sendQuery, 'switch');
if (config.sendHeaders !== undefined)
await this.setParameter('sendHeaders', config.sendHeaders, 'switch');
if (config.sendBody !== undefined)
await this.setParameter('sendBody', config.sendBody, 'switch');
}
}

View File

@@ -1,66 +0,0 @@
import { BasePage } from './BasePage';
export class AIAssistantPage extends BasePage {
getAskAssistantFloatingButton() {
return this.page.getByTestId('ask-assistant-floating-button');
}
getAskAssistantCanvasActionButton() {
return this.page.getByTestId('ask-assistant-canvas-action-button');
}
getAskAssistantChat() {
return this.page.getByTestId('ask-assistant-chat');
}
getPlaceholderMessage() {
return this.page.getByTestId('placeholder-message');
}
getChatInput() {
return this.page.getByTestId('chat-input');
}
getSendMessageButton() {
return this.page.getByTestId('send-message-button');
}
getCloseChatButton() {
return this.page.getByTestId('close-chat-button');
}
getAskAssistantSidebarResizer() {
return this.page
.getByTestId('ask-assistant-sidebar')
.locator('[class*="_resizer"][data-dir="left"]')
.first();
}
getNodeErrorViewAssistantButton() {
return this.page.getByTestId('node-error-view-ask-assistant-button').locator('button').first();
}
getChatMessagesAll() {
return this.page.locator('[data-test-id^="chat-message"]');
}
getChatMessagesAssistant() {
return this.page.getByTestId('chat-message-assistant');
}
getChatMessagesUser() {
return this.page.getByTestId('chat-message-user');
}
getChatMessagesSystem() {
return this.page.getByTestId('chat-message-system');
}
getQuickReplyButtons() {
return this.page.getByTestId('quick-replies').locator('button');
}
getNewAssistantSessionModal() {
return this.page.getByTestId('new-assistant-session-modal');
}
}

View File

@@ -1,21 +0,0 @@
import type { Page } from '@playwright/test';
export abstract class BasePage {
constructor(protected readonly page: Page) {}
protected async clickByTestId(testId: string) {
await this.page.getByTestId(testId).click();
}
protected async fillByTestId(testId: string, value: string) {
await this.page.getByTestId(testId).fill(value);
}
protected async clickByText(text: string) {
await this.page.getByText(text).click();
}
protected async clickButtonByName(name: string) {
await this.page.getByRole('button', { name }).click();
}
}

View File

@@ -1,15 +0,0 @@
import { BasePage } from './BasePage';
export class BecomeCreatorCTAPage extends BasePage {
getBecomeTemplateCreatorCta() {
return this.page.getByTestId('become-template-creator-cta');
}
getCloseBecomeTemplateCreatorCtaButton() {
return this.page.getByTestId('close-become-template-creator-cta');
}
async closeBecomeTemplateCreatorCta() {
await this.getCloseBecomeTemplateCreatorCtaButton().click();
}
}

View File

@@ -1,449 +0,0 @@
import type { Locator } from '@playwright/test';
import { nanoid } from 'nanoid';
import { BasePage } from './BasePage';
import { resolveFromRoot } from '../utils/path-helper';
export class CanvasPage extends BasePage {
saveWorkflowButton(): Locator {
return this.page.getByRole('button', { name: 'Save' });
}
nodeCreatorItemByName(text: string): Locator {
return this.page.getByTestId('node-creator-item-name').getByText(text, { exact: true });
}
nodeCreatorSubItem(subItemText: string): Locator {
return this.page.getByTestId('node-creator-item-name').getByText(subItemText, { exact: true });
}
nodeByName(nodeName: string): Locator {
return this.page.locator(`[data-test-id="canvas-node"][data-node-name="${nodeName}"]`);
}
nodeToolbar(nodeName: string): Locator {
return this.nodeByName(nodeName).getByTestId('canvas-node-toolbar');
}
nodeDeleteButton(nodeName: string): Locator {
return this.nodeToolbar(nodeName).getByTestId('delete-node-button');
}
nodeDisableButton(nodeName: string): Locator {
return this.nodeToolbar(nodeName).getByTestId('disable-node-button');
}
async clickCanvasPlusButton(): Promise<void> {
await this.clickByTestId('canvas-plus-button');
}
getCanvasNodes() {
return this.page.getByTestId('canvas-node');
}
async clickNodeCreatorPlusButton(): Promise<void> {
await this.clickByTestId('node-creator-plus-button');
}
async clickSaveWorkflowButton(): Promise<void> {
await this.saveWorkflowButton().click();
}
async fillNodeCreatorSearchBar(text: string): Promise<void> {
await this.nodeCreatorSearchBar().fill(text);
}
async clickNodeCreatorItemName(text: string): Promise<void> {
await this.nodeCreatorItemByName(text).click();
}
async addNode(text: string): Promise<void> {
await this.clickNodeCreatorPlusButton();
await this.fillNodeCreatorSearchBar(text);
await this.clickNodeCreatorItemName(text);
}
async addNodeAndCloseNDV(text: string, subItemText?: string): Promise<void> {
if (subItemText) {
await this.addNodeWithSubItem(text, subItemText);
} else {
await this.addNode(text);
}
await this.page.keyboard.press('Escape');
}
async addNodeWithSubItem(searchText: string, subItemText: string): Promise<void> {
await this.addNode(searchText);
await this.nodeCreatorSubItem(subItemText).click();
}
async addActionNode(searchText: string, subItemText: string): Promise<void> {
await this.addNode(searchText);
await this.page.getByText('Actions').click();
await this.nodeCreatorSubItem(subItemText).click();
}
async addTriggerNode(searchText: string, subItemText: string): Promise<void> {
await this.addNode(searchText);
await this.page.getByText('Triggers').click();
await this.nodeCreatorSubItem(subItemText).click();
}
async deleteNodeByName(nodeName: string): Promise<void> {
await this.nodeDeleteButton(nodeName).click();
}
async saveWorkflow(): Promise<void> {
await this.clickSaveWorkflowButton();
}
getExecuteWorkflowButton(): Locator {
return this.page.getByTestId('execute-workflow-button');
}
async clickExecuteWorkflowButton(): Promise<void> {
await this.page.getByTestId('execute-workflow-button').click();
}
async clickDebugInEditorButton(): Promise<void> {
await this.page.getByRole('button', { name: 'Debug in editor' }).click();
}
async pinNode(nodeName: string): Promise<void> {
await this.nodeByName(nodeName).click({ button: 'right' });
await this.page.getByTestId('context-menu').getByText('Pin').click();
}
async unpinNode(nodeName: string): Promise<void> {
await this.nodeByName(nodeName).click({ button: 'right' });
await this.page.getByText('Unpin').click();
}
async openNode(nodeName: string): Promise<void> {
await this.nodeByName(nodeName).dblclick();
}
/**
* Get the names of all pinned nodes on the canvas.
* @returns An array of node names.
*/
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;
}
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.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');
}
async activateWorkflow() {
const responsePromise = this.page.waitForResponse(
(response) =>
response.url().includes('/rest/workflows/') && response.request().method() === 'PATCH',
);
await this.page.getByTestId('workflow-activate-switch').click();
await responsePromise;
await this.page.waitForTimeout(200);
}
async clickZoomToFitButton(): Promise<void> {
await this.clickByTestId('zoom-to-fit');
}
/**
* Get node issues for a specific node
*/
getNodeIssuesByName(nodeName: string) {
return this.nodeByName(nodeName).getByTestId('node-issues');
}
/**
* Add tags to the workflow
* @param count - The number of tags to add
* @returns An array of tag names
*/
async addTags(count: number = 1): Promise<string[]> {
const tags: string[] = [];
for (let i = 0; i < count; i++) {
const tag = `tag-${nanoid(8)}-${i}`;
tags.push(tag);
if (i === 0) {
await this.clickByText('Add tag');
} else {
await this.page
.getByTestId('tags-dropdown')
.getByText(tags[i - 1])
.click();
}
await this.page.getByRole('combobox').first().fill(tag);
await this.page.getByRole('combobox').first().press('Enter');
}
await this.page.click('body');
return tags;
}
getWorkflowSaveButton(): Locator {
return this.page.getByTestId('workflow-save-button');
}
// Production Checklist methods
getProductionChecklistButton(): Locator {
return this.page.getByTestId('suggested-action-count');
}
getProductionChecklistPopover(): Locator {
return this.page.locator('[data-reka-popper-content-wrapper=""]').filter({ hasText: /./ });
}
getProductionChecklistActionItem(text?: string): Locator {
const items = this.page.getByTestId('suggested-action-item');
if (text) {
return items.getByText(text);
}
return items;
}
getProductionChecklistIgnoreAllButton(): Locator {
return this.page.getByTestId('suggested-action-ignore-all');
}
getErrorActionItem(): Locator {
return this.getProductionChecklistActionItem('Set up error notifications');
}
getTimeSavedActionItem(): Locator {
return this.getProductionChecklistActionItem('Track time saved');
}
getEvaluationsActionItem(): Locator {
return this.getProductionChecklistActionItem('Test reliability of AI steps');
}
async clickProductionChecklistButton(): Promise<void> {
await this.getProductionChecklistButton().click();
}
async clickProductionChecklistIgnoreAll(): Promise<void> {
await this.getProductionChecklistIgnoreAllButton().click();
}
async clickProductionChecklistAction(actionText: string): Promise<void> {
await this.getProductionChecklistActionItem(actionText).click();
}
async duplicateNode(nodeName: string): Promise<void> {
await this.nodeByName(nodeName).click({ button: 'right' });
await this.page.getByTestId('context-menu').getByText('Duplicate').click();
}
nodeConnections(): Locator {
return this.page.locator('[data-test-id="edge"]');
}
canvasNodePlusEndpointByName(nodeName: string): Locator {
return this.page
.locator(
`[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"] [data-test-id="canvas-handle-plus"]`,
)
.first();
}
nodeCreatorSearchBar(): Locator {
return this.page.getByTestId('node-creator-search-bar');
}
nodeCreatorNodeItems(): Locator {
return this.page.getByTestId('node-creator-node-item');
}
nodeCreatorActionItems(): Locator {
return this.page.getByTestId('node-creator-action-item');
}
nodeCreatorCategoryItems(): Locator {
return this.page.getByTestId('node-creator-category-item');
}
selectedNodes(): Locator {
return this.page
.locator('[data-test-id="canvas-node"]')
.locator('xpath=..')
.locator('.selected');
}
disabledNodes(): Locator {
return this.page.locator('[data-canvas-node-render-type][class*="disabled"]');
}
nodeExecuteButton(nodeName: string): Locator {
return this.nodeToolbar(nodeName).getByTestId('execute-node-button');
}
canvasPane(): Locator {
return this.page.getByTestId('canvas-wrapper');
}
// Actions
async addInitialNodeToCanvas(nodeName: string): Promise<void> {
await this.clickCanvasPlusButton();
await this.fillNodeCreatorSearchBar(nodeName);
await this.clickNodeCreatorItemName(nodeName);
}
async clickNodePlusEndpoint(nodeName: string): Promise<void> {
await this.canvasNodePlusEndpointByName(nodeName).click();
}
async executeNode(nodeName: string): Promise<void> {
await this.nodeByName(nodeName).hover();
await this.nodeExecuteButton(nodeName).click();
}
async selectAll(): Promise<void> {
await this.page.keyboard.press('ControlOrMeta+a');
}
async copyNodes(): Promise<void> {
await this.page.keyboard.press('ControlOrMeta+c');
}
async deselectAll(): Promise<void> {
await this.canvasPane().click({ position: { x: 10, y: 10 } });
}
getNodeLeftPosition(nodeLocator: Locator): Promise<number> {
return nodeLocator.evaluate((el) => el.getBoundingClientRect().left);
}
// Connection helpers
connectionBetweenNodes(sourceNodeName: string, targetNodeName: string): Locator {
return this.page.locator(
`[data-test-id="edge"][data-source-node-name="${sourceNodeName}"][data-target-node-name="${targetNodeName}"]`,
);
}
connectionToolbarBetweenNodes(sourceNodeName: string, targetNodeName: string): Locator {
return this.page.locator(
`[data-test-id="edge-label"][data-source-node-name="${sourceNodeName}"][data-target-node-name="${targetNodeName}"] [data-test-id="canvas-edge-toolbar"]`,
);
}
// Canvas action helpers
async addNodeBetweenNodes(
sourceNodeName: string,
targetNodeName: string,
newNodeName: string,
): Promise<void> {
const specificConnection = this.connectionBetweenNodes(sourceNodeName, targetNodeName);
// eslint-disable-next-line playwright/no-force-option
await specificConnection.hover({ force: true });
const addNodeButton = this.connectionToolbarBetweenNodes(
sourceNodeName,
targetNodeName,
).getByTestId('add-connection-button');
await addNodeButton.click();
await this.fillNodeCreatorSearchBar(newNodeName);
await this.clickNodeCreatorItemName(newNodeName);
await this.page.keyboard.press('Escape');
}
async deleteConnectionBetweenNodes(
sourceNodeName: string,
targetNodeName: string,
): Promise<void> {
const specificConnection = this.connectionBetweenNodes(sourceNodeName, targetNodeName);
// eslint-disable-next-line playwright/no-force-option
await specificConnection.hover({ force: true });
const deleteButton = this.connectionToolbarBetweenNodes(
sourceNodeName,
targetNodeName,
).getByTestId('delete-connection-button');
await deleteButton.click();
}
async navigateNodesWithArrows(direction: 'left' | 'right' | 'up' | 'down'): Promise<void> {
const keyMap = {
left: 'ArrowLeft',
right: 'ArrowRight',
up: 'ArrowUp',
down: 'ArrowDown',
};
await this.canvasPane().focus();
await this.page.keyboard.press(keyMap[direction]);
}
async extendSelectionWithArrows(direction: 'left' | 'right' | 'up' | 'down'): Promise<void> {
const keyMap = {
left: 'Shift+ArrowLeft',
right: 'Shift+ArrowRight',
up: 'Shift+ArrowUp',
down: 'Shift+ArrowDown',
};
await this.canvasPane().focus();
await this.page.keyboard.press(keyMap[direction]);
}
/**
* Visit the workflow page with a specific timestamp for NPS survey testing.
* Uses Playwright's clock API to set a fixed time.
*/
async visitWithTimestamp(timestamp: number): Promise<void> {
// Set fixed time using Playwright's clock API
await this.page.clock.setFixedTime(timestamp);
await this.page.goto('/workflow/new');
}
}

View File

@@ -1,65 +0,0 @@
import { BasePage } from './BasePage';
export class CredentialsPage extends BasePage {
get emptyListCreateCredentialButton() {
return this.page.getByRole('button', { name: 'Add first credential' });
}
get createCredentialButton() {
return this.page.getByTestId('create-credential-button');
}
get credentialCards() {
return this.page.getByTestId('credential-cards');
}
/**
* Create a new credential of the specified type
* @param credentialType - The type of credential to create (e.g. 'Notion API')
*/
async openNewCredentialDialogFromCredentialList(credentialType: string): Promise<void> {
await this.page.getByRole('combobox', { name: 'Search for app...' }).fill(credentialType);
await this.page
.getByTestId('new-credential-type-select-option')
.filter({ hasText: credentialType })
.click();
await this.page.getByTestId('new-credential-type-button').click();
}
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);
}
async saveCredential() {
await this.clickButtonByName('Save');
}
async closeCredentialDialog() {
await this.clickButtonByName('Close this dialog');
}
async createAndSaveNewCredential(fieldName: string, value: string) {
await this.openCredentialSelector();
await this.createNewCredential();
await this.filLCredentialSaveClose(fieldName, value);
}
async filLCredentialSaveClose(fieldName: string, value: string) {
await this.fillCredentialField(fieldName, value);
await this.saveCredential();
await this.page.getByText('Connection tested successfully').waitFor({ state: 'visible' });
await this.closeCredentialDialog();
}
}

View File

@@ -1,36 +0,0 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class ExecutionsPage extends BasePage {
async clickDebugInEditorButton(): Promise<void> {
await this.clickButtonByName('Debug in editor');
}
async clickCopyToEditorButton(): Promise<void> {
await this.clickButtonByName('Copy to editor');
}
getExecutionItems(): Locator {
return this.page.locator('div.execution-card');
}
getLastExecutionItem(): Locator {
const executionItems = this.getExecutionItems();
return executionItems.nth(0);
}
async clickLastExecutionItem(): Promise<void> {
const executionItem = this.getLastExecutionItem();
await executionItem.click();
}
/**
* Handle the pinned nodes confirmation dialog.
* @param action - The action to take.
*/
async handlePinnedNodesConfirmation(action: 'Unpin' | 'Cancel'): Promise<void> {
const confirmDialog = this.page.locator('.matching-pinned-nodes-confirmation');
await this.page.getByRole('button', { name: action }).click();
}
}

View File

@@ -1,15 +0,0 @@
import { BasePage } from './BasePage';
export class IframePage extends BasePage {
getIframe() {
return this.page.locator('iframe');
}
getIframeBySrc(src: string) {
return this.page.locator(`iframe[src="${src}"]`);
}
async waitForIframeRequest(url: string) {
await this.page.waitForResponse(url);
}
}

View File

@@ -1,460 +0,0 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
import { BasePage } from './BasePage';
import { NodeParameterHelper } from '../helpers/NodeParameterHelper';
import { EditFieldsNode } from './nodes/EditFieldsNode';
export class NodeDetailsViewPage extends BasePage {
readonly setupHelper: NodeParameterHelper;
readonly editFields: EditFieldsNode;
constructor(page: Page) {
super(page);
this.setupHelper = new NodeParameterHelper(this);
this.editFields = new EditFieldsNode(page);
}
async clickBackToCanvasButton() {
await this.clickByTestId('back-to-canvas');
}
getParameterByLabel(labelName: string) {
return this.page.locator('.parameter-item').filter({ hasText: labelName });
}
/**
* Fill a parameter input field
* @param labelName - The label of the parameter e.g URL
* @param value - The value to fill in the input field e.g https://foo.bar
*/
async fillParameterInput(labelName: string, value: string) {
await this.getParameterByLabel(labelName).getByTestId('parameter-input-field').fill(value);
}
async selectWorkflowResource(createItemText: string, searchText: string = '') {
await this.clickByTestId('rlc-input');
if (searchText) {
await this.fillByTestId('rlc-search', searchText);
}
await this.clickByText(createItemText);
}
async togglePinData() {
await this.clickByTestId('ndv-pin-data');
}
async close() {
await this.clickBackToCanvasButton();
}
async execute() {
await this.clickByTestId('node-execute-button');
}
getOutputPanel() {
return this.page.getByTestId('output-panel');
}
getContainer() {
return this.page.getByTestId('ndv');
}
getInputPanel() {
return this.page.getByTestId('ndv-input-panel');
}
getParameterExpressionPreviewValue() {
return this.page.getByTestId('parameter-expression-preview-value');
}
getEditPinnedDataButton() {
return this.page.getByTestId('ndv-edit-pinned-data');
}
getPinDataButton() {
return this.getOutputPanel().getByTestId('ndv-pin-data');
}
getRunDataPaneHeader() {
return this.page.getByTestId('run-data-pane-header');
}
getOutputTable() {
return this.getOutputPanel().getByTestId('ndv-data-container').locator('table');
}
getOutputDataContainer() {
return this.getOutputPanel().getByTestId('ndv-data-container');
}
getOutputTableRows() {
return this.getOutputTable().locator('tr');
}
getOutputTableHeaders() {
return this.getOutputTable().locator('thead th');
}
getOutputTableRow(row: number) {
return this.getOutputTableRows().nth(row);
}
getOutputTableCell(row: number, col: number) {
return this.getOutputTableRow(row).locator('td').nth(col);
}
getOutputTbodyCell(row: number, col: number) {
return this.getOutputTableRow(row).locator('td').nth(col);
}
// Pin data operations
async setPinnedData(data: object | string) {
const pinnedData = typeof data === 'string' ? data : JSON.stringify(data);
await this.getEditPinnedDataButton().click();
// Wait for editor to appear and use broader selector
const editor = this.getOutputPanel().locator('[contenteditable="true"]');
await editor.waitFor();
await editor.click();
await editor.fill(pinnedData);
await this.savePinnedData();
}
async pastePinnedData(data: object) {
await this.getEditPinnedDataButton().click();
const editor = this.getOutputPanel().locator('[contenteditable="true"]');
await editor.waitFor();
await editor.click();
await editor.fill('');
// Set clipboard data and paste
await this.page.evaluate(async (jsonData) => {
await navigator.clipboard.writeText(JSON.stringify(jsonData));
}, data);
await this.page.keyboard.press('ControlOrMeta+V');
await this.savePinnedData();
}
async savePinnedData() {
await this.getRunDataPaneHeader().locator('button:visible').filter({ hasText: 'Save' }).click();
}
// Assignment collection methods for advanced tests
getAssignmentCollectionAdd(paramName: string) {
return this.page
.getByTestId(`assignment-collection-${paramName}`)
.getByTestId('assignment-collection-drop-area');
}
getAssignmentValue(paramName: string) {
return this.page
.getByTestId(`assignment-collection-${paramName}`)
.getByTestId('assignment-value');
}
getInlineExpressionEditorInput() {
return this.page.getByTestId('inline-expression-editor-input');
}
getNodeParameters() {
return this.page.getByTestId('node-parameters');
}
getParameterInputHint() {
return this.page.getByTestId('parameter-input-hint');
}
async makeWebhookRequest(path: string) {
return await this.page.request.get(path);
}
getVisiblePoppers() {
return this.page.locator('.el-popper:visible');
}
async clearExpressionEditor() {
const editor = this.getInlineExpressionEditorInput();
await editor.click();
await this.page.keyboard.press('ControlOrMeta+A');
await this.page.keyboard.press('Delete');
}
async typeInExpressionEditor(text: string) {
const editor = this.getInlineExpressionEditorInput();
await editor.click();
// We have to use type() instead of fill() because the editor is a CodeMirror editor
await editor.type(text);
}
/**
* Get parameter input by name (for Code node and similar)
* @param parameterName - The name of the parameter e.g 'jsCode', 'mode'
*/
getParameterInput(parameterName: string) {
return this.page.getByTestId(`parameter-input-${parameterName}`);
}
/**
* Get parameter input field
* @param parameterName - The name of the parameter
*/
getParameterInputField(parameterName: string) {
return this.getParameterInput(parameterName).getByTestId('parameter-input-field');
}
/**
* Select option in parameter dropdown (improved with Playwright best practices)
* @param parameterName - The parameter name
* @param optionText - The text of the option to select
*/
async selectOptionInParameterDropdown(parameterName: string, optionText: string) {
const dropdown = this.getParameterInput(parameterName);
await dropdown.click();
// Wait for dropdown to be visible and select option - following Playwright best practices
await this.page.getByRole('option', { name: optionText }).click();
}
/**
* Click parameter dropdown by name (test-id based selector)
* @param parameterName - The parameter name e.g 'httpMethod', 'authentication'
*/
async clickParameterDropdown(parameterName: string): Promise<void> {
await this.clickByTestId(`parameter-input-${parameterName}`);
}
/**
* Select option from visible dropdown using Playwright role-based selectors
* This follows the pattern used in working n8n tests
* @param optionText - The text of the option to select
*/
async selectFromVisibleDropdown(optionText: string): Promise<void> {
// Use Playwright's role-based selector - this is more reliable than CSS selectors
await this.page.getByRole('option', { name: optionText }).click();
}
/**
* Fill parameter input field by parameter name
* @param parameterName - The parameter name e.g 'path', 'url'
* @param value - The value to fill
*/
async fillParameterInputByName(parameterName: string, value: string): Promise<void> {
const input = this.getParameterInputField(parameterName);
await input.click();
await input.fill(value);
}
/**
* Click parameter options expansion (e.g. for Response Code)
*/
async clickParameterOptions(): Promise<void> {
await this.page.locator('.param-options').click();
}
/**
* Get visible Element UI popper (dropdown/popover)
* Ported from Cypress pattern with Playwright selectors
*/
getVisiblePopper() {
return this.page
.locator('.el-popper')
.filter({ hasNot: this.page.locator('[aria-hidden="true"]') });
}
/**
* Wait for parameter dropdown to be visible and ready for interaction
* @param parameterName - The parameter name
*/
async waitForParameterDropdown(parameterName: string): Promise<void> {
const dropdown = this.getParameterInput(parameterName);
await dropdown.waitFor({ state: 'visible' });
await expect(dropdown).toBeEnabled();
}
/**
* Click on a floating node in the NDV (for switching between connected nodes)
* @param nodeName - The name of the node to click
*/
async clickFloatingNode(nodeName: string) {
await this.page.locator(`[data-test-id="floating-node"][data-node-name="${nodeName}"]`).click();
}
/**
* Execute the previous node (useful for providing input data)
*/
async executePrevious() {
await this.clickByTestId('execute-previous-node');
}
async clickAskAiTab() {
await this.page.locator('#tab-ask-ai').click();
}
getAskAiTabPanel() {
return this.page.getByTestId('code-node-tab-ai');
}
getAskAiCtaButton() {
return this.page.getByTestId('ask-ai-cta');
}
getAskAiPromptInput() {
return this.page.getByTestId('ask-ai-prompt-input');
}
getAskAiPromptCounter() {
return this.page.getByTestId('ask-ai-prompt-counter');
}
getAskAiCtaTooltipNoInputData() {
return this.page.getByTestId('ask-ai-cta-tooltip-no-input-data');
}
getAskAiCtaTooltipNoPrompt() {
return this.page.getByTestId('ask-ai-cta-tooltip-no-prompt');
}
getAskAiCtaTooltipPromptTooShort() {
return this.page.getByTestId('ask-ai-cta-tooltip-prompt-too-short');
}
getCodeTabPanel() {
return this.page.getByTestId('code-node-tab-code');
}
getCodeTab() {
return this.page.locator('#tab-code');
}
getCodeEditor() {
return this.getParameterInput('jsCode').locator('.cm-content');
}
getLintErrors() {
return this.getParameterInput('jsCode').locator('.cm-lintRange-error');
}
getLintTooltip() {
return this.page.locator('.cm-tooltip-lint');
}
getPlaceholderText(text: string) {
return this.page.getByText(text);
}
getHeyAiText() {
return this.page.locator('text=Hey AI, generate JavaScript');
}
getCodeGenerationCompletedText() {
return this.page.locator('text=Code generation completed');
}
getErrorMessageText(message: string) {
return this.page.locator(`text=${message}`);
}
async setParameterDropdown(parameterName: string, optionText: string): Promise<void> {
await this.getParameterInput(parameterName).click();
await this.page.getByRole('option', { name: optionText }).click();
}
async setParameterInput(parameterName: string, value: string): Promise<void> {
await this.fillParameterInputByName(parameterName, value);
}
async setParameterSwitch(parameterName: string, enabled: boolean): Promise<void> {
const switchElement = this.getParameterInput(parameterName).locator('.el-switch');
const isCurrentlyEnabled = (await switchElement.getAttribute('aria-checked')) === 'true';
if (isCurrentlyEnabled !== enabled) {
await switchElement.click();
}
}
async setMultipleParameters(
parameters: Record<string, string | number | boolean>,
): Promise<void> {
for (const [parameterName, value] of Object.entries(parameters)) {
if (typeof value === 'string') {
const parameterType = await this.setupHelper.detectParameterType(parameterName);
if (parameterType === 'dropdown') {
await this.setParameterDropdown(parameterName, value);
} else {
await this.setParameterInput(parameterName, value);
}
} else if (typeof value === 'boolean') {
await this.setParameterSwitch(parameterName, value);
} else if (typeof value === 'number') {
await this.setParameterInput(parameterName, value.toString());
}
}
}
async getParameterValue(parameterName: string): Promise<string> {
const parameterType = await this.setupHelper.detectParameterType(parameterName);
switch (parameterType) {
case 'text':
return await this.getTextParameterValue(parameterName);
case 'dropdown':
return await this.getDropdownParameterValue(parameterName);
case 'switch':
return await this.getSwitchParameterValue(parameterName);
default:
// Fallback for unknown types
return (await this.getParameterInput(parameterName).textContent()) ?? '';
}
}
/**
* Get value from a text parameter - simplified approach
*/
private async getTextParameterValue(parameterName: string): Promise<string> {
const parameterContainer = this.getParameterInput(parameterName);
const input = parameterContainer.locator('input').first();
return await input.inputValue();
}
/**
* Get value from a dropdown parameter
*/
private async getDropdownParameterValue(parameterName: string): Promise<string> {
const selectedOption = this.getParameterInput(parameterName).locator('.el-select__tags-text');
return (await selectedOption.textContent()) ?? '';
}
/**
* Get value from a switch parameter
*/
private async getSwitchParameterValue(parameterName: string): Promise<string> {
const switchElement = this.getParameterInput(parameterName).locator('.el-switch');
const isEnabled = (await switchElement.getAttribute('aria-checked')) === 'true';
return isEnabled ? 'true' : 'false';
}
async validateParameter(parameterName: string, expectedValue: string): Promise<void> {
const actualValue = await this.getParameterValue(parameterName);
if (actualValue !== expectedValue) {
throw new Error(
`Parameter ${parameterName} has value "${actualValue}", expected "${expectedValue}"`,
);
}
}
getAssignmentCollectionContainer(paramName: string) {
return this.page.getByTestId(`assignment-collection-${paramName}`);
}
getAssignmentName(paramName: string, index = 0) {
return this.getAssignmentCollectionContainer(paramName)
.getByTestId('assignment')
.nth(index)
.getByTestId('assignment-name');
}
}

View File

@@ -1,268 +0,0 @@
import type { Locator, Page } from '@playwright/test';
export class NotificationsPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
/**
* Gets the main container locator for a notification by searching in its title text.
* @param text The text or a regular expression to find within the notification's title.
* @returns A Locator for the notification container element.
*/
getNotificationByTitle(text: string | RegExp): Locator {
return this.page.getByRole('alert').filter({
has: this.page.locator('.el-notification__title').filter({ hasText: text }),
});
}
/**
* Gets the main container locator for a notification by searching in its content/body text.
* This is useful for finding notifications where the detailed message is in the content
* rather than the title (e.g., error messages with detailed descriptions).
* @param text The text or a regular expression to find within the notification's content.
* @returns A Locator for the notification container element.
*/
getNotificationByContent(text: string | RegExp): Locator {
return this.page.getByRole('alert').filter({
has: this.page.locator('.el-notification__content').filter({ hasText: text }),
});
}
/**
* Gets the main container locator for a notification by searching in both title and content.
* This is the most flexible method as it will find notifications regardless of whether
* the text appears in the title or content section.
* @param text The text or a regular expression to find within the notification's title or content.
* @returns A Locator for the notification container element.
*/
getNotificationByTitleOrContent(text: string | RegExp): Locator {
return this.page.getByRole('alert').filter({ hasText: text });
}
/**
* Gets the main container locator for a notification by searching in both title and content,
* filtered to a specific node name. This is useful when multiple notifications might be present
* and you want to ensure you're checking the right one for a specific node.
* @param text The text or a regular expression to find within the notification's title or content.
* @param nodeName The name of the node to filter notifications for.
* @returns A Locator for the notification container element.
*/
getNotificationByTitleOrContentForNode(text: string | RegExp, nodeName: string): Locator {
return this.page.getByRole('alert').filter({ hasText: text }).filter({ hasText: nodeName });
}
/**
* Clicks the close button on the FIRST notification matching the text.
* Fast execution with short timeouts for snappy notifications.
* @param text The text of the notification to close.
* @param options Optional configuration
*/
async closeNotificationByText(
text: string | RegExp,
options: { timeout?: number } = {},
): Promise<boolean> {
const { timeout = 2000 } = options;
try {
const notification = this.getNotificationByTitle(text).first();
await notification.waitFor({ state: 'visible', timeout });
const closeBtn = notification.locator('.el-notification__closeBtn');
await closeBtn.click({ timeout: 500 });
// Quick check that it's gone - don't wait long
await notification.waitFor({ state: 'hidden', timeout: 1000 });
return true;
} catch (error) {
return false;
}
}
/**
* Closes ALL currently visible notifications that match the given text.
* Uses aggressive polling for fast cleanup.
* @param text The text of the notifications to close.
* @param options Optional configuration
*/
async closeAllNotificationsWithText(
text: string | RegExp,
options: { timeout?: number; maxRetries?: number } = {},
): Promise<number> {
const { timeout = 1500, maxRetries = 15 } = options;
let closedCount = 0;
let retries = 0;
while (retries < maxRetries) {
try {
const notifications = this.getNotificationByTitle(text);
const count = await notifications.count();
if (count === 0) {
break;
}
// Close the first visible notification quickly
const firstNotification = notifications.first();
if (await firstNotification.isVisible({ timeout: 200 })) {
const closeBtn = firstNotification.locator('.el-notification__closeBtn');
await closeBtn.click({ timeout: 300 });
// Brief wait for disappearance, then continue
await firstNotification.waitFor({ state: 'hidden', timeout: 500 }).catch(() => {});
closedCount++;
} else {
// If not visible, likely already gone
break;
}
} catch (error) {
// Continue quickly on any error
break;
}
retries++;
}
return closedCount;
}
/**
* Check if a notification is visible based on text.
* Fast check with short timeout.
* @param text The text to search for in notification title.
* @param options Optional configuration
*/
async isNotificationVisible(
text: string | RegExp,
options: { timeout?: number } = {},
): Promise<boolean> {
const { timeout = 500 } = options;
try {
const notification = this.getNotificationByTitle(text).first();
await notification.waitFor({ state: 'visible', timeout });
return true;
} catch {
return false;
}
}
/**
* Wait for a notification to appear with specific text.
* Reasonable timeout for waiting, but still faster than before.
* @param text The text to search for in notification title.
* @param options Optional configuration
*/
async waitForNotification(
text: string | RegExp,
options: { timeout?: number } = {},
): Promise<boolean> {
const { timeout = 5000 } = options;
try {
const notification = this.getNotificationByTitle(text).first();
await notification.waitFor({ state: 'visible', timeout });
return true;
} catch {
return false;
}
}
// Wait for notification and then close it
async waitForNotificationAndClose(
text: string | RegExp,
options: { timeout?: number } = {},
): Promise<boolean> {
const { timeout = 3000 } = options;
await this.waitForNotification(text, { timeout });
await this.closeNotificationByText(text, { timeout });
return true;
}
/**
* Get all visible notification texts.
* @returns Array of notification title texts
*/
async getAllNotificationTexts(): Promise<string[]> {
try {
const titles = this.page.getByRole('alert').locator('.el-notification__title');
return await titles.allTextContents();
} catch {
return [];
}
}
/**
* Wait for all notifications to disappear.
* Fast check with short timeout.
* @param options Optional configuration
*/
async waitForAllNotificationsToDisappear(options: { timeout?: number } = {}): Promise<boolean> {
const { timeout = 2000 } = options;
try {
// Wait for no alerts to be visible
await this.page.getByRole('alert').first().waitFor({
state: 'detached',
timeout,
});
return true;
} catch {
// Check if any are still visible
const count = await this.getNotificationCount();
return count === 0;
}
}
/**
* Get the count of visible notifications.
* @param text Optional text to filter notifications
*/
async getNotificationCount(text?: string | RegExp): Promise<number> {
try {
const notifications = text ? this.getNotificationByTitle(text) : this.page.getByRole('alert');
return await notifications.count();
} catch {
return 0;
}
}
/**
* Quick utility to close any notification and continue.
* Uses the most aggressive timeouts for maximum speed.
* @param text The text of the notification to close.
*/
async quickClose(text: string | RegExp): Promise<void> {
try {
const notification = this.getNotificationByTitle(text).first();
if (await notification.isVisible({ timeout: 100 })) {
await notification.locator('.el-notification__closeBtn').click({ timeout: 200 });
}
} catch {
// Silent fail for speed
}
}
/**
* Nuclear option: Close everything as fast as possible.
* No waiting, no error handling, just close and move on.
*/
async quickCloseAll(): Promise<void> {
try {
const closeButtons = this.page.locator('.el-notification__closeBtn');
const count = await closeButtons.count();
for (let i = 0; i < count; i++) {
try {
await closeButtons.nth(i).click({ timeout: 100 });
} catch {
// Continue silently
}
}
} catch {
// Silent fail
}
}
}

View File

@@ -1,57 +0,0 @@
import type { Locator, Page } from '@playwright/test';
import { BasePage } from './BasePage';
export class NpsSurveyPage extends BasePage {
constructor(page: Page) {
super(page);
}
getNpsSurveyModal(): Locator {
return this.page.getByTestId('nps-survey-modal');
}
getNpsSurveyRatings(): Locator {
return this.page.getByTestId('nps-survey-ratings');
}
getNpsSurveyFeedback(): Locator {
return this.page.getByTestId('nps-survey-feedback');
}
getNpsSurveySubmitButton(): Locator {
return this.page.getByTestId('nps-survey-feedback-button');
}
getNpsSurveyCloseButton(): Locator {
return this.getNpsSurveyModal().locator('button.el-drawer__close-btn');
}
getRatingButton(rating: number): Locator {
return this.getNpsSurveyRatings().locator('button').nth(rating);
}
getFeedbackTextarea(): Locator {
return this.getNpsSurveyFeedback().locator('textarea');
}
async clickRating(rating: number): Promise<void> {
await this.getRatingButton(rating).click();
}
async fillFeedback(feedback: string): Promise<void> {
await this.getFeedbackTextarea().fill(feedback);
}
async clickSubmitButton(): Promise<void> {
await this.getNpsSurveySubmitButton().click();
}
async closeSurvey(): Promise<void> {
await this.getNpsSurveyCloseButton().click();
}
async getRatingButtonCount(): Promise<number> {
return await this.getNpsSurveyRatings().locator('button').count();
}
}

View File

@@ -1,11 +0,0 @@
import { BasePage } from './BasePage';
export class ProjectSettingsPage extends BasePage {
async fillProjectName(name: string) {
await this.page.getByTestId('project-settings-name-input').locator('input').fill(name);
}
async clickSaveButton() {
await this.clickButtonByName('Save');
}
}

View File

@@ -1,15 +0,0 @@
import { BasePage } from './BasePage';
export class SettingsPage extends BasePage {
getMenuItems() {
return this.page.getByTestId('menu-item');
}
getMenuItem(id: string) {
return this.page.getByTestId('menu-item').getByTestId(id);
}
async goToSettings() {
await this.page.goto('/settings');
}
}

View File

@@ -1,46 +0,0 @@
import type { Locator, Page } from '@playwright/test';
export class SidebarPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async clickAddProjectButton() {
await this.page.getByTestId('project-plus-button').click();
}
async universalAdd() {
await this.page.getByTestId('universal-add').click();
}
async addProjectFromUniversalAdd() {
await this.universalAdd();
await this.page.getByTestId('navigation-menu-item').filter({ hasText: 'Project' }).click();
}
async addWorkflowFromUniversalAdd(projectName: string) {
await this.universalAdd();
await this.page.getByTestId('universal-add').getByText('Workflow').click();
await this.page.getByTestId('universal-add').getByRole('link', { name: projectName }).click();
}
async openNewCredentialDialogForProject(projectName: string) {
await this.universalAdd();
await this.page.getByTestId('universal-add').getByText('Credential').click();
await this.page.getByTestId('universal-add').getByRole('link', { name: projectName }).click();
}
getProjectMenuItems(): Locator {
return this.page.getByTestId('project-menu-item');
}
async clickProjectMenuItem(projectName: string) {
await this.getProjectMenuItems().filter({ hasText: projectName }).click();
}
getAddFirstProjectButton(): Locator {
return this.page.getByTestId('add-first-project-button');
}
}

View File

@@ -1,35 +0,0 @@
import { BasePage } from './BasePage';
export class VersionsPage extends BasePage {
getVersionUpdatesPanelOpenButton() {
return this.page.getByTestId('version-update-next-versions-link');
}
getVersionUpdatesPanel() {
return this.page.getByTestId('version-updates-panel');
}
getVersionUpdatesPanelCloseButton() {
return this.getVersionUpdatesPanel().getByRole('button', { name: 'Close' });
}
getVersionCard() {
return this.page.getByTestId('version-card');
}
getWhatsNewMenuItem() {
return this.page.getByTestId('menu-item').getByTestId('whats-new');
}
async openWhatsNewMenu() {
await this.getWhatsNewMenuItem().click();
}
async openVersionUpdatesPanel() {
await this.getVersionUpdatesPanelOpenButton().click();
}
async closeVersionUpdatesPanel() {
await this.getVersionUpdatesPanelCloseButton().click();
}
}

View File

@@ -1,31 +0,0 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class WorkflowActivationModal extends BasePage {
getModal(): Locator {
return this.page.getByTestId('activation-modal');
}
getDontShowAgainCheckbox(): Locator {
return this.getModal().getByText("Don't show again");
}
getGotItButton(): Locator {
return this.getModal().getByRole('button', { name: 'Got it' });
}
async close(): Promise<void> {
await this.getDontShowAgainCheckbox().click();
await this.getGotItButton().click();
}
async clickDontShowAgain(): Promise<void> {
await this.getDontShowAgainCheckbox().click();
}
async clickGotIt(): Promise<void> {
await this.getGotItButton().click();
}
}

View File

@@ -1,39 +0,0 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class WorkflowSettingsModal extends BasePage {
getModal(): Locator {
return this.page.getByTestId('workflow-settings-dialog');
}
getWorkflowMenu(): Locator {
return this.page.getByTestId('workflow-menu');
}
getSettingsMenuItem(): Locator {
return this.page.getByTestId('workflow-menu-item-settings');
}
getErrorWorkflowField(): Locator {
return this.page.getByTestId('workflow-settings-error-workflow');
}
getSaveButton(): Locator {
return this.page.getByRole('button', { name: 'Save' });
}
async open(): Promise<void> {
await this.getWorkflowMenu().click();
await this.getSettingsMenuItem().click();
}
async clickSave(): Promise<void> {
await this.getSaveButton().click();
}
async selectErrorWorkflow(workflowName: string): Promise<void> {
await this.getErrorWorkflowField().click();
await this.page.getByRole('option', { name: workflowName }).first().click();
}
}

View File

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

View File

@@ -1,159 +0,0 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class WorkflowsPage extends BasePage {
async clickAddFirstProjectButton() {
await this.clickByTestId('add-first-project-button');
}
async clickAddProjectButton() {
await this.clickByTestId('project-plus-button');
}
/**
* This is the add workflow button on the workflows page, visible when there are already workflows.
*/
async clickAddWorkflowButton() {
await this.clickByTestId('add-resource-workflow');
}
/**
* This is the new workflow button on the workflows page, visible when there are no workflows.
*/
async clickNewWorkflowCard() {
await this.clickByTestId('new-workflow-card');
}
getNewWorkflowCard() {
return this.page.getByTestId('new-workflow-card');
}
async clearSearch() {
await this.clickByTestId('resources-list-search');
await this.page.getByTestId('resources-list-search').clear();
}
getProjectName() {
return this.page.getByTestId('project-name');
}
getSearchBar() {
return this.page.getByTestId('resources-list-search');
}
getWorkflowFilterButton() {
return this.page.getByTestId('workflow-filter-button');
}
getWorkflowTagsDropdown() {
return this.page.getByTestId('workflow-tags-dropdown');
}
getWorkflowTagItem(tagName: string) {
return this.page.getByTestId('workflow-tag-item').filter({ hasText: tagName });
}
getWorkflowArchivedCheckbox() {
return this.page.getByTestId('workflow-archived-checkbox');
}
async unarchiveWorkflow(workflowItem: Locator) {
await workflowItem.getByTestId('workflow-card-actions').click();
await this.page.getByRole('menuitem', { name: 'Unarchive' }).click();
}
async deleteWorkflow(workflowItem: Locator) {
await workflowItem.getByTestId('workflow-card-actions').click();
await this.page.getByRole('menuitem', { name: 'Delete' }).click();
await this.page.getByRole('button', { name: 'delete' }).click();
}
async searchWorkflows(searchTerm: string) {
await this.clickByTestId('resources-list-search');
await this.fillByTestId('resources-list-search', searchTerm);
}
getWorkflowItems() {
return this.page.getByTestId('resources-list-item-workflow');
}
getWorkflowByName(name: string) {
return this.getWorkflowItems().filter({ hasText: name });
}
async shareWorkflow(workflowName: string) {
const workflow = this.getWorkflowByName(workflowName);
await workflow.getByTestId('workflow-card-actions').click();
await this.page.getByRole('menuitem', { name: 'Share...' }).click();
}
getArchiveMenuItem() {
return this.page.getByRole('menuitem', { name: 'Archive' });
}
async archiveWorkflow(workflowItem: Locator) {
await workflowItem.getByTestId('workflow-card-actions').click();
await this.getArchiveMenuItem().click();
}
getFiltersButton() {
return this.page.getByTestId('resources-list-filters-trigger');
}
async openFilters() {
await this.clickByTestId('resources-list-filters-trigger');
}
async closeFilters() {
await this.clickByTestId('resources-list-filters-trigger');
}
getShowArchivedCheckbox() {
return this.page.getByTestId('show-archived-checkbox');
}
async toggleShowArchived() {
await this.openFilters();
await this.getShowArchivedCheckbox().locator('span').nth(1).click();
await this.closeFilters();
}
getStatusDropdown() {
return this.page.getByTestId('status-dropdown');
}
/**
* Select a status filter (for active/deactivated workflows)
* @param status - 'All', 'Active', or 'Deactivated'
*/
async selectStatusFilter(status: 'All' | 'Active' | 'Deactivated') {
await this.openFilters();
await this.getStatusDropdown().getByRole('combobox', { name: 'Select' }).click();
if (status === 'All') {
await this.page.getByRole('option', { name: 'All' }).click();
} else {
await this.page.getByText(status, { exact: true }).click();
}
await this.closeFilters();
}
getTagsDropdown() {
return this.page.getByTestId('tags-dropdown');
}
async filterByTags(tags: string[]) {
await this.openFilters();
await this.clickByTestId('tags-dropdown');
for (const tag of tags) {
await this.page.getByRole('option', { name: tag }).locator('span').click();
}
await this.closeFilters();
}
async filterByTag(tag: string) {
await this.filterByTags([tag]);
}
}

View File

@@ -1,95 +0,0 @@
import type { Page } from '@playwright/test';
import { AIAssistantPage } from './AIAssistantPage';
import { BecomeCreatorCTAPage } from './BecomeCreatorCTAPage';
import { CanvasPage } from './CanvasPage';
import { CredentialsPage } from './CredentialsPage';
import { ExecutionsPage } from './ExecutionsPage';
import { IframePage } from './IframePage';
import { NodeDetailsViewPage } from './NodeDetailsViewPage';
import { NotificationsPage } from './NotificationsPage';
import { NpsSurveyPage } from './NpsSurveyPage';
import { ProjectSettingsPage } from './ProjectSettingsPage';
import { SettingsPage } from './SettingsPage';
import { SidebarPage } from './SidebarPage';
import { VersionsPage } from './VersionsPage';
import { WorkflowActivationModal } from './WorkflowActivationModal';
import { WorkflowSettingsModal } from './WorkflowSettingsModal';
import { WorkflowSharingModal } from './WorkflowSharingModal';
import { WorkflowsPage } from './WorkflowsPage';
import { CanvasComposer } from '../composables/CanvasComposer';
import { ProjectComposer } from '../composables/ProjectComposer';
import { TestEntryComposer } from '../composables/TestEntryComposer';
import { WorkflowComposer } from '../composables/WorkflowComposer';
import type { ApiHelpers } from '../services/api-helper';
// eslint-disable-next-line @typescript-eslint/naming-convention
export class n8nPage {
readonly page: Page;
readonly api: ApiHelpers;
// Pages
readonly aiAssistant: AIAssistantPage;
readonly becomeCreatorCTA: BecomeCreatorCTAPage;
readonly canvas: CanvasPage;
readonly iframe: IframePage;
readonly ndv: NodeDetailsViewPage;
readonly npsSurvey: NpsSurveyPage;
readonly projectSettings: ProjectSettingsPage;
readonly settings: SettingsPage;
readonly versions: VersionsPage;
readonly workflows: WorkflowsPage;
readonly notifications: NotificationsPage;
readonly credentials: CredentialsPage;
readonly executions: ExecutionsPage;
readonly sideBar: SidebarPage;
// Modals
readonly workflowActivationModal: WorkflowActivationModal;
readonly workflowSettingsModal: WorkflowSettingsModal;
readonly workflowSharingModal: WorkflowSharingModal;
// Composables
readonly workflowComposer: WorkflowComposer;
readonly projectComposer: ProjectComposer;
readonly canvasComposer: CanvasComposer;
readonly start: TestEntryComposer;
constructor(page: Page, api: ApiHelpers) {
this.page = page;
this.api = api;
// Pages
this.aiAssistant = new AIAssistantPage(page);
this.becomeCreatorCTA = new BecomeCreatorCTAPage(page);
this.canvas = new CanvasPage(page);
this.iframe = new IframePage(page);
this.ndv = new NodeDetailsViewPage(page);
this.npsSurvey = new NpsSurveyPage(page);
this.projectSettings = new ProjectSettingsPage(page);
this.settings = new SettingsPage(page);
this.versions = new VersionsPage(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);
// Modals
this.workflowActivationModal = new WorkflowActivationModal(page);
this.workflowSettingsModal = new WorkflowSettingsModal(page);
// Composables
this.workflowComposer = new WorkflowComposer(this);
this.projectComposer = new ProjectComposer(this);
this.canvasComposer = new CanvasComposer(this);
this.start = new TestEntryComposer(this);
}
async goHome() {
await this.page.goto('/');
}
}

View File

@@ -1,74 +0,0 @@
/* eslint-disable import-x/no-default-export */
import { currentsReporter } from '@currents/playwright';
import { defineConfig } from '@playwright/test';
import os from 'os';
import path from 'path';
import currentsConfig from './currents.config';
import { getProjects } from './playwright-projects';
import { getPortFromUrl } from './utils/url-helper';
const IS_CI = !!process.env.CI;
const MACBOOK_WINDOW_SIZE = { width: 1536, height: 960 };
const USER_FOLDER = path.join(os.tmpdir(), `n8n-main-${Date.now()}`);
// Calculate workers based on environment
// The amount of workers to run, limited to 6 as higher causes instability in the local server
// Use half the CPUs in local, full in CI (CI has no other processes so we can use more)
const CPU_COUNT = os.cpus().length;
const LOCAL_WORKERS = Math.min(6, Math.floor(CPU_COUNT / 2));
const CI_WORKERS = CPU_COUNT;
const WORKERS = IS_CI ? CI_WORKERS : LOCAL_WORKERS;
export default defineConfig({
globalSetup: './global-setup.ts',
forbidOnly: IS_CI,
retries: IS_CI ? 2 : 0,
workers: WORKERS,
timeout: 60000,
expect: {
timeout: 10000,
},
projects: getProjects(),
// We use this if an n8n url is passed in. If the server is already running, we reuse it.
webServer: process.env.N8N_BASE_URL
? {
command: 'cd .. && pnpm start',
url: `${process.env.N8N_BASE_URL}/favicon.ico`,
timeout: 20000,
reuseExistingServer: true,
env: {
DB_SQLITE_POOL_SIZE: '40',
E2E_TESTS: 'true',
N8N_PORT: getPortFromUrl(process.env.N8N_BASE_URL),
N8N_USER_FOLDER: USER_FOLDER,
N8N_LOG_LEVEL: 'debug',
N8N_METRICS: 'true',
},
}
: undefined,
use: {
trace: 'on',
video: 'on',
screenshot: 'on',
testIdAttribute: 'data-test-id',
headless: process.env.SHOW_BROWSER !== 'true',
viewport: MACBOOK_WINDOW_SIZE,
actionTimeout: 20000, // TODO: We might need to make this dynamic for container tests if we have low resource containers etc
navigationTimeout: 10000,
},
reporter: IS_CI
? [
['list'],
['github'],
['junit', { outputFile: process.env.PLAYWRIGHT_JUNIT_OUTPUT_NAME ?? 'results.xml' }],
['html', { open: 'never' }],
['json', { outputFile: 'test-results.json' }],
currentsReporter(currentsConfig),
]
: [['html']],
});

View File

@@ -1,12 +0,0 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"sourceMap": false,
"declaration": false,
"lib": ["esnext", "dom"],
"types": ["@playwright/test", "node"]
},
"include": ["**/*.ts"],
"exclude": ["**/dist/**/*", "**/node_modules/**/*"],
"references": [{ "path": "../../workflow/tsconfig.build.esm.json" }]
}