chore: 清理macOS同步产生的重复文件
详细说明: - 删除了352个带数字后缀的重复文件 - 更新.gitignore防止未来产生此类文件 - 这些文件是由iCloud或其他同步服务冲突产生的 - 不影响项目功能,仅清理冗余文件
This commit is contained in:
@@ -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);
|
||||
});
|
||||
```
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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('/');
|
||||
}
|
||||
}
|
||||
@@ -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']],
|
||||
});
|
||||
@@ -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" }]
|
||||
}
|
||||
Reference in New Issue
Block a user