diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml new file mode 100644 index 0000000..1f8c25f --- /dev/null +++ b/.github/workflows/ui-tests.yml @@ -0,0 +1,164 @@ +name: UI Tests + +on: + push: + branches: + - develop + pull_request: + workflow_dispatch: + +concurrency: + group: ui-tests-hazelnode-${{ github.event.number || github.ref }} + cancel-in-progress: true + +jobs: + ui-tests: + runs-on: ubuntu-latest + timeout-minutes: 60 + name: Playwright E2E Tests + + services: + redis-cache: + image: redis:alpine + ports: + - 13000:6379 + redis-queue: + image: redis:alpine + ports: + - 11000:6379 + mariadb: + image: mariadb:10.6 + env: + MYSQL_ROOT_PASSWORD: root + ports: + - 3306:3306 + options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3 + + steps: + - name: Clone + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + check-latest: true + + - name: Add to Hosts + run: echo "127.0.0.1 hazelnode.test" | sudo tee -a /etc/hosts + + - name: Cache pip + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py', '**/setup.cfg') }} + restore-keys: | + ${{ runner.os }}-pip- + ${{ runner.os }}- + + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: 'echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT' + + - name: Cache yarn + uses: actions/cache@v4 + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('**/package.json') }} + restore-keys: | + ${{ runner.os }}-playwright- + + - name: Install MariaDB Client + run: | + sudo apt update + sudo apt-get install mariadb-client + + - name: Setup Bench + run: | + pip install frappe-bench + bench init --skip-redis-config-generation --skip-assets --python "$(which python)" ~/frappe-bench + mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'" + mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" + + - name: Install Hazelnode + working-directory: /home/runner/frappe-bench + run: | + bench get-app hazelnode $GITHUB_WORKSPACE + bench setup requirements --dev + bench new-site --db-root-password root --admin-password admin hazelnode.test + bench --site hazelnode.test install-app hazelnode + bench build + env: + CI: "Yes" + + - name: Configure Site for UI Tests + working-directory: /home/runner/frappe-bench + run: | + bench --site hazelnode.test set-config allow_tests true + bench --site hazelnode.test set-config host_name "http://hazelnode.test:8000" + + - name: Start Frappe Server + working-directory: /home/runner/frappe-bench + run: | + # Disable watch and schedule to reduce resource usage + sed -i 's/^watch:/# watch:/g' Procfile + sed -i 's/^schedule:/# schedule:/g' Procfile + + # Start bench in background + bench start &> bench_start.log & + + # Wait for server to be ready + echo "Waiting for Frappe server to start..." + timeout 60 bash -c 'until curl -s http://hazelnode.test:8000 > /dev/null; do sleep 2; done' + echo "Frappe server is ready!" + + - name: Install Playwright + run: | + npm install + npx playwright install --with-deps chromium + + - name: Run Playwright Tests + run: npx playwright test + env: + BASE_URL: http://hazelnode.test:8000 + FRAPPE_USER: Administrator + FRAPPE_PASSWORD: admin + + - name: Upload Playwright Report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 + + - name: Upload Test Results + uses: actions/upload-artifact@v4 + if: failure() + with: + name: test-results + path: test-results/ + retention-days: 7 + + - name: Show Bench Logs on Failure + if: failure() + working-directory: /home/runner/frappe-bench + run: | + echo "=== Bench Start Log ===" + cat bench_start.log || true + echo "" + echo "=== Frappe Logs ===" + cat logs/*.log || true diff --git a/.gitignore b/.gitignore index eb87e23..4507e90 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,8 @@ __pycache__ hazelnode/public/frontend hazelnode/www/frontend.html hazelnode/www/hazelnode.html + +# Playwright +e2e/.auth/ +playwright-report/ +test-results/ diff --git a/E2E_TESTS_PLAN.md b/E2E_TESTS_PLAN.md new file mode 100644 index 0000000..b1e0c73 --- /dev/null +++ b/E2E_TESTS_PLAN.md @@ -0,0 +1,624 @@ +# E2E Testing Implementation Plan for Hazelnode + +## Overview + +This document outlines the plan to implement end-to-end tests for Hazelnode using Playwright, based on analysis of [frappe/lms](https://github.com/frappe/lms) Cypress setup. + +## Findings from frappe/lms + +### CI/CD Setup (`.github/workflows/ui-tests.yml`) + +- **Trigger**: Runs on PRs, push to main, and manual dispatch +- **Services**: MariaDB 10.8 +- **Environment Setup**: + 1. Clone repo + setup Python/Node + 2. Add hostname to `/etc/hosts` (e.g., `lms.test`) + 3. Run helper scripts to install dependencies and setup bench/site + 4. Start bench server in background + 5. Run UI tests with `bench --site run-ui-tests --headless` + +### Site Configuration (`site_config.json`) + +Key settings: +```json +{ + "allow_tests": true, + "enable_ui_tests": true, + "host_name": "http://lms.test:8000" +} +``` + +### Cypress Configuration + +- Custom commands for login (API-based), button clicks, dialog handling +- Test fixtures for file uploads (images, videos) +- Tests interact with Frappe UI components using selectors + +### Test Patterns + +1. **Login via API** - POST to `/api/method/login` (avoids slow UI login) +2. **Wait strategies** - `cy.wait()` after navigation/state changes +3. **Element selection** - Using labels, text content, ARIA attributes +4. **Assertions** - URL checks, element visibility, content verification + +--- + +## Implementation Plan for Hazelnode (Playwright) + +### Phase 1: Infrastructure Setup + +#### 1.1 Directory Structure + +``` +hazelnode/ +├── e2e/ # Playwright test directory +│ ├── fixtures/ # Test data (workflows, images) +│ ├── helpers/ # Test utilities +│ │ ├── auth.ts # Login helpers +│ │ ├── workflow.ts # Workflow creation helpers +│ │ └── frappe.ts # Frappe API utilities +│ ├── pages/ # Page Object Models +│ │ ├── login.page.ts # Login page +│ │ ├── workflow-list.page.ts # Workflow list page +│ │ └── workflow-editor.page.ts # Workflow editor page +│ └── tests/ # Test specifications +│ ├── auth.spec.ts # Authentication tests +│ ├── workflow-crud.spec.ts # Workflow CRUD operations +│ └── workflow-editor.spec.ts # Editor interactions +├── playwright.config.ts # Playwright configuration +└── .github/ + ├── workflows/ + │ └── ui-tests.yml # New UI tests workflow + └── helper/ + ├── install_dependencies.sh # System dependencies + ├── install.sh # Bench/site setup + └── site_config.json # Test site configuration +``` + +#### 1.2 Package Dependencies + +```json +{ + "devDependencies": { + "@playwright/test": "^1.49.0", + "@types/node": "^22.0.0" + }, + "scripts": { + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed" + } +} +``` + +#### 1.3 Playwright Configuration (`playwright.config.ts`) + +```typescript +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e/tests', + fullyParallel: false, // Sequential for Frappe state consistency + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, // Single worker for Frappe + reporter: process.env.CI ? 'github' : 'html', + + use: { + baseURL: process.env.BASE_URL || 'http://hazelnode.test:8000', + trace: 'on-first-retry', + video: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + globalSetup: require.resolve('./e2e/helpers/global-setup'), + globalTeardown: require.resolve('./e2e/helpers/global-teardown'), +}); +``` + +### Phase 2: Core Helpers + +#### 2.1 Authentication Helper (`e2e/helpers/auth.ts`) + +```typescript +import { APIRequestContext, Page } from '@playwright/test'; + +export async function loginViaAPI( + request: APIRequestContext, + email: string = 'Administrator', + password: string = 'admin' +): Promise { + await request.post('/api/method/login', { + form: { usr: email, pwd: password } + }); +} + +export async function loginViaUI( + page: Page, + email: string = 'Administrator', + password: string = 'admin' +): Promise { + await page.goto('/login'); + await page.fill('input[name="email"]', email); + await page.fill('input[name="password"]', password); + await page.click('button[type="submit"]'); + await page.waitForURL('**/app/**'); +} + +export async function logout(page: Page): Promise { + await page.goto('/api/method/logout'); +} +``` + +#### 2.2 Frappe API Helper (`e2e/helpers/frappe.ts`) + +```typescript +import { APIRequestContext } from '@playwright/test'; + +export async function createDoc( + request: APIRequestContext, + doctype: string, + doc: Record +): Promise> { + const response = await request.post('/api/resource/' + doctype, { + data: doc + }); + return response.json(); +} + +export async function deleteDoc( + request: APIRequestContext, + doctype: string, + name: string +): Promise { + await request.delete(`/api/resource/${doctype}/${name}`); +} + +export async function callMethod( + request: APIRequestContext, + method: string, + args: Record = {} +): Promise { + const response = await request.post(`/api/method/${method}`, { + data: args + }); + return response.json(); +} +``` + +#### 2.3 Workflow Helpers (`e2e/helpers/workflow.ts`) + +```typescript +import { APIRequestContext } from '@playwright/test'; +import { createDoc, deleteDoc } from './frappe'; + +export interface WorkflowNode { + node_id: string; + node_type: string; + position_x: number; + position_y: number; + params: Record; +} + +export async function createTestWorkflow( + request: APIRequestContext, + name: string, + nodes: WorkflowNode[] = [] +): Promise { + const doc = await createDoc(request, 'Hazel Workflow', { + title: name, + nodes: nodes + }); + return doc.name as string; +} + +export async function deleteTestWorkflow( + request: APIRequestContext, + name: string +): Promise { + await deleteDoc(request, 'Hazel Workflow', name); +} +``` + +### Phase 3: Page Objects + +#### 3.1 Workflow Editor Page (`e2e/pages/workflow-editor.page.ts`) + +```typescript +import { Page, Locator } from '@playwright/test'; + +export class WorkflowEditorPage { + readonly page: Page; + readonly canvas: Locator; + readonly nodePanel: Locator; + readonly saveButton: Locator; + readonly runButton: Locator; + + constructor(page: Page) { + this.page = page; + this.canvas = page.locator('.react-flow'); + this.nodePanel = page.locator('[data-testid="node-panel"]'); + this.saveButton = page.locator('button:has-text("Save")'); + this.runButton = page.locator('button:has-text("Run")'); + } + + async goto(workflowId: string) { + await this.page.goto(`/hazelnode/workflow/${workflowId}`); + await this.canvas.waitFor({ state: 'visible' }); + } + + async dragNodeToCanvas(nodeType: string, x: number, y: number) { + const node = this.nodePanel.locator(`[data-node-type="${nodeType}"]`); + await node.dragTo(this.canvas, { targetPosition: { x, y } }); + } + + async selectNode(nodeId: string) { + await this.canvas.locator(`[data-id="${nodeId}"]`).click(); + } + + async connectNodes(sourceId: string, targetId: string) { + const sourceHandle = this.canvas.locator( + `[data-id="${sourceId}"] [data-handleid="source"]` + ); + const targetHandle = this.canvas.locator( + `[data-id="${targetId}"] [data-handleid="target"]` + ); + await sourceHandle.dragTo(targetHandle); + } + + async save() { + await this.saveButton.click(); + await this.page.waitForResponse( + (resp) => resp.url().includes('/api/') && resp.status() === 200 + ); + } + + async getNodeCount(): Promise { + return this.canvas.locator('.react-flow__node').count(); + } +} +``` + +### Phase 4: Test Specifications + +#### 4.1 Authentication Tests (`e2e/tests/auth.spec.ts`) + +```typescript +import { test, expect } from '@playwright/test'; +import { loginViaAPI, loginViaUI, logout } from '../helpers/auth'; + +test.describe('Authentication', () => { + test('should login via API', async ({ request, page }) => { + await loginViaAPI(request); + await page.goto('/hazelnode'); + await expect(page).toHaveURL(/.*hazelnode.*/); + }); + + test('should login via UI', async ({ page }) => { + await loginViaUI(page); + await expect(page.locator('[data-testid="user-menu"]')).toBeVisible(); + }); + + test('should redirect to login when not authenticated', async ({ page }) => { + await page.goto('/hazelnode'); + await expect(page).toHaveURL(/.*login.*/); + }); +}); +``` + +#### 4.2 Workflow CRUD Tests (`e2e/tests/workflow-crud.spec.ts`) + +```typescript +import { test, expect } from '@playwright/test'; +import { loginViaAPI } from '../helpers/auth'; +import { createTestWorkflow, deleteTestWorkflow } from '../helpers/workflow'; + +test.describe('Workflow CRUD', () => { + test.beforeEach(async ({ request }) => { + await loginViaAPI(request); + }); + + test('should create a new workflow', async ({ page }) => { + await page.goto('/hazelnode'); + await page.click('button:has-text("New Workflow")'); + + await page.fill('input[name="title"]', 'Test Workflow'); + await page.click('button:has-text("Create")'); + + await expect(page).toHaveURL(/.*workflow\/.*/); + await expect(page.locator('h1')).toContainText('Test Workflow'); + }); + + test('should list existing workflows', async ({ request, page }) => { + // Create test workflow via API + const workflowName = await createTestWorkflow(request, 'List Test Workflow'); + + await page.goto('/hazelnode'); + await expect(page.locator(`text=List Test Workflow`)).toBeVisible(); + + // Cleanup + await deleteTestWorkflow(request, workflowName); + }); + + test('should delete a workflow', async ({ request, page }) => { + const workflowName = await createTestWorkflow(request, 'Delete Test Workflow'); + + await page.goto('/hazelnode'); + await page.locator(`text=Delete Test Workflow`).click(); + await page.click('button:has-text("Delete")'); + await page.click('button:has-text("Confirm")'); + + await expect(page.locator(`text=Delete Test Workflow`)).not.toBeVisible(); + }); +}); +``` + +#### 4.3 Workflow Editor Tests (`e2e/tests/workflow-editor.spec.ts`) + +```typescript +import { test, expect } from '@playwright/test'; +import { loginViaAPI } from '../helpers/auth'; +import { createTestWorkflow, deleteTestWorkflow } from '../helpers/workflow'; +import { WorkflowEditorPage } from '../pages/workflow-editor.page'; + +test.describe('Workflow Editor', () => { + let workflowName: string; + let editorPage: WorkflowEditorPage; + + test.beforeAll(async ({ request }) => { + await loginViaAPI(request); + workflowName = await createTestWorkflow(request, 'Editor Test Workflow'); + }); + + test.afterAll(async ({ request }) => { + await deleteTestWorkflow(request, workflowName); + }); + + test.beforeEach(async ({ page }) => { + editorPage = new WorkflowEditorPage(page); + await editorPage.goto(workflowName); + }); + + test('should display workflow editor canvas', async ({ page }) => { + await expect(editorPage.canvas).toBeVisible(); + }); + + test('should add a trigger node', async ({ page }) => { + await editorPage.dragNodeToCanvas('webhook', 200, 200); + await expect(await editorPage.getNodeCount()).toBe(1); + }); + + test('should add and connect nodes', async ({ page }) => { + await editorPage.dragNodeToCanvas('webhook', 100, 200); + await editorPage.dragNodeToCanvas('log', 300, 200); + + // Get node IDs and connect them + const nodes = await page.locator('.react-flow__node').all(); + const sourceId = await nodes[0].getAttribute('data-id'); + const targetId = await nodes[1].getAttribute('data-id'); + + await editorPage.connectNodes(sourceId!, targetId!); + + // Verify connection exists + await expect(page.locator('.react-flow__edge')).toHaveCount(1); + }); + + test('should save workflow changes', async ({ page }) => { + await editorPage.dragNodeToCanvas('log', 200, 200); + await editorPage.save(); + + // Refresh and verify persistence + await page.reload(); + await expect(await editorPage.getNodeCount()).toBeGreaterThan(0); + }); + + test('should configure node parameters', async ({ page }) => { + await editorPage.dragNodeToCanvas('log', 200, 200); + + const node = page.locator('.react-flow__node').first(); + await node.dblclick(); + + // Fill node configuration + await page.fill('input[name="message"]', 'Test log message'); + await page.click('button:has-text("Apply")'); + + await editorPage.save(); + }); +}); +``` + +### Phase 5: CI/CD Integration + +#### 5.1 GitHub Workflow (`.github/workflows/ui-tests.yml`) + +```yaml +name: UI Tests + +on: + pull_request: + push: + branches: [develop] + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 60 + + services: + redis-cache: + image: redis:alpine + ports: + - 13000:6379 + redis-queue: + image: redis:alpine + ports: + - 11000:6379 + mariadb: + image: mariadb:10.6 + env: + MYSQL_ROOT_PASSWORD: root + ports: + - 3306:3306 + options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3 + + steps: + - name: Clone + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.14' + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Add to Hosts + run: echo "127.0.0.1 hazelnode.test" | sudo tee -a /etc/hosts + + - name: Cache pip + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }} + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('**/package-lock.json') }} + + - name: Install MariaDB Client + run: | + sudo apt update + sudo apt-get install mariadb-client + + - name: Setup Bench + run: | + pip install frappe-bench + bench init --skip-redis-config-generation --skip-assets --python "$(which python)" ~/frappe-bench + mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'" + mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" + + - name: Install Hazelnode + working-directory: /home/runner/frappe-bench + run: | + bench get-app hazelnode $GITHUB_WORKSPACE + bench setup requirements --dev + bench new-site --db-root-password root --admin-password admin hazelnode.test + bench --site hazelnode.test install-app hazelnode + bench build + env: + CI: "Yes" + + - name: Configure Site + working-directory: /home/runner/frappe-bench + run: | + bench --site hazelnode.test set-config allow_tests true + bench --site hazelnode.test set-config enable_ui_tests true + bench --site hazelnode.test set-config host_name "http://hazelnode.test:8000" + bench --site hazelnode.test execute frappe.utils.install.complete_setup_wizard + bench --site hazelnode.test execute frappe.tests.ui_test_helpers.create_test_user + + - name: Start Server + working-directory: /home/runner/frappe-bench + run: | + sed -i 's/^watch:/# watch:/g' Procfile + sed -i 's/^schedule:/# schedule:/g' Procfile + bench start &> bench_start.log & + sleep 10 + + - name: Install Playwright + run: | + cd $GITHUB_WORKSPACE + npm ci + npx playwright install --with-deps chromium + + - name: Run E2E Tests + run: | + cd $GITHUB_WORKSPACE + npx playwright test + env: + BASE_URL: http://hazelnode.test:8000 + + - name: Upload Test Results + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 + + - name: Show Bench Logs + if: always() + run: cat ~/frappe-bench/bench_start.log || true +``` + +### Phase 6: Implementation Checklist + +#### 6.1 Initial Setup +- [ ] Add Playwright dependencies to `package.json` +- [ ] Create `playwright.config.ts` +- [ ] Create directory structure (`e2e/tests`, `e2e/helpers`, `e2e/pages`) +- [ ] Create `.github/workflows/ui-tests.yml` + +#### 6.2 Core Helpers +- [ ] Implement `auth.ts` - login/logout helpers +- [ ] Implement `frappe.ts` - Frappe API utilities +- [ ] Implement `workflow.ts` - Workflow creation/deletion +- [ ] Implement global setup/teardown + +#### 6.3 Page Objects +- [ ] Create `workflow-list.page.ts` +- [ ] Create `workflow-editor.page.ts` +- [ ] Create `login.page.ts` + +#### 6.4 Tests +- [ ] Authentication tests +- [ ] Workflow list/CRUD tests +- [ ] Workflow editor interaction tests +- [ ] Node configuration tests +- [ ] Workflow execution tests + +#### 6.5 Data Test Attributes +- [ ] Add `data-testid` attributes to key frontend components +- [ ] Document selector strategy in test utilities + +--- + +## Key Differences: Cypress vs Playwright + +| Aspect | Cypress (frappe/lms) | Playwright (Hazelnode) | +|--------|---------------------|------------------------| +| Syntax | `cy.get().click()` | `page.locator().click()` | +| Async | Implicit chaining | Explicit async/await | +| API Testing | `cy.request()` | `request.post()` | +| Parallel | Harder to configure | Built-in support | +| Browser support | Chrome-focused | Multi-browser | +| Test isolation | Cookie-based | Context-based | + +## Playwright Advantages for Hazelnode + +1. **TypeScript-first** - Better IDE support, matches frontend stack +2. **Better async handling** - Cleaner code with async/await +3. **Multi-browser testing** - Chromium, Firefox, WebKit +4. **Auto-waiting** - Built-in smart waiting for elements +5. **Trace viewer** - Superior debugging with traces +6. **API testing built-in** - First-class support for API calls + +## Notes + +- Tests should be idempotent (create own data, clean up after) +- Use API calls for setup/teardown, UI for actual testing +- Avoid hard-coded waits; use Playwright's auto-waiting +- Add `data-testid` attributes to critical UI elements for stable selectors diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts new file mode 100644 index 0000000..2cfe248 --- /dev/null +++ b/e2e/helpers/auth.ts @@ -0,0 +1,86 @@ +import { APIRequestContext, Page, BrowserContext } from '@playwright/test'; + +const STORAGE_STATE_PATH = 'e2e/.auth/user.json'; + +/** + * Login via Frappe API (faster than UI login). + * Sets cookies on the request context for subsequent API calls. + */ +export async function loginViaAPI( + request: APIRequestContext, + email: string = 'Administrator', + password: string = 'admin' +): Promise { + const response = await request.post('/api/method/login', { + form: { + usr: email, + pwd: password, + }, + }); + + if (!response.ok()) { + throw new Error( + `Login failed: ${response.status()} ${await response.text()}` + ); + } +} + +/** + * Login via UI (for testing the login flow itself). + */ +export async function loginViaUI( + page: Page, + email: string = 'Administrator', + password: string = 'admin' +): Promise { + await page.goto('/login'); + await page.waitForLoadState('networkidle'); + + await page.fill('input[data-fieldname="email"]', email); + await page.fill('input[data-fieldname="password"]', password); + await page.click('button[type="submit"]'); + + // Wait for redirect to desk/app + await page.waitForURL(/\/(app|desk)/, { timeout: 30000 }); +} + +/** + * Logout the current user. + */ +export async function logout(page: Page): Promise { + await page.goto('/api/method/logout'); + await page.waitForLoadState('networkidle'); +} + +/** + * Save authentication state for reuse across tests. + */ +export async function saveAuthState(context: BrowserContext): Promise { + await context.storageState({ path: STORAGE_STATE_PATH }); +} + +/** + * Get the storage state path for authenticated sessions. + */ +export function getStorageStatePath(): string { + return STORAGE_STATE_PATH; +} + +/** + * Check if user is logged in by verifying session. + */ +export async function isLoggedIn( + request: APIRequestContext +): Promise { + try { + const response = await request.get( + '/api/method/frappe.auth.get_logged_user' + ); + if (!response.ok()) return false; + + const data = await response.json(); + return data.message && data.message !== 'Guest'; + } catch { + return false; + } +} diff --git a/e2e/helpers/frappe.ts b/e2e/helpers/frappe.ts new file mode 100644 index 0000000..36f7893 --- /dev/null +++ b/e2e/helpers/frappe.ts @@ -0,0 +1,231 @@ +import { APIRequestContext } from '@playwright/test'; +import * as fs from 'fs'; + +/** + * Frappe API response wrapper. + */ +export interface FrappeResponse { + message?: T; + exc?: string; + exc_type?: string; + _server_messages?: string; +} + +// Path to CSRF token file saved by auth.setup.ts +const CSRF_FILE = 'e2e/.auth/csrf.json'; + +// Cache for CSRF token (read from file once) +let csrfTokenCache: string | null = null; + +/** + * Get CSRF token from the file saved during auth setup. + * The token is extracted from window.frappe.csrf_token after login. + */ +function getCsrfToken(): string { + // Return cached token if available + if (csrfTokenCache !== null) { + return csrfTokenCache; + } + + // Read token from file + try { + if (fs.existsSync(CSRF_FILE)) { + const data = JSON.parse(fs.readFileSync(CSRF_FILE, 'utf-8')); + csrfTokenCache = data.csrf_token || ''; + return csrfTokenCache; + } + } catch (error) { + console.warn('Failed to read CSRF token file:', error); + } + + csrfTokenCache = ''; + return ''; +} + +/** + * Create a new document via Frappe REST API. + */ +export async function createDoc>( + request: APIRequestContext, + doctype: string, + doc: Record +): Promise { + const csrfToken = getCsrfToken(); + + const response = await request.post(`/api/resource/${doctype}`, { + data: doc, + headers: { + 'Content-Type': 'application/json', + ...(csrfToken ? { 'X-Frappe-CSRF-Token': csrfToken } : {}), + }, + }); + + if (!response.ok()) { + const error = await response.text(); + throw new Error(`Failed to create ${doctype}: ${error}`); + } + + const result = await response.json(); + return result.data as T; +} + +/** + * Get a document by name via Frappe REST API. + */ +export async function getDoc>( + request: APIRequestContext, + doctype: string, + name: string +): Promise { + const response = await request.get( + `/api/resource/${doctype}/${encodeURIComponent(name)}` + ); + + if (!response.ok()) { + const error = await response.text(); + throw new Error(`Failed to get ${doctype}/${name}: ${error}`); + } + + const result = await response.json(); + return result.data as T; +} + +/** + * Update a document via Frappe REST API. + */ +export async function updateDoc>( + request: APIRequestContext, + doctype: string, + name: string, + updates: Record +): Promise { + const csrfToken = getCsrfToken(); + + const response = await request.put( + `/api/resource/${doctype}/${encodeURIComponent(name)}`, + { + data: updates, + headers: { + 'Content-Type': 'application/json', + ...(csrfToken ? { 'X-Frappe-CSRF-Token': csrfToken } : {}), + }, + } + ); + + if (!response.ok()) { + const error = await response.text(); + throw new Error(`Failed to update ${doctype}/${name}: ${error}`); + } + + const result = await response.json(); + return result.data as T; +} + +/** + * Delete a document via Frappe REST API. + */ +export async function deleteDoc( + request: APIRequestContext, + doctype: string, + name: string +): Promise { + const csrfToken = getCsrfToken(); + + const response = await request.delete( + `/api/resource/${doctype}/${encodeURIComponent(name)}`, + { + headers: { + ...(csrfToken ? { 'X-Frappe-CSRF-Token': csrfToken } : {}), + }, + } + ); + + if (!response.ok()) { + const error = await response.text(); + throw new Error(`Failed to delete ${doctype}/${name}: ${error}`); + } +} + +/** + * Call a Frappe whitelisted method. + */ +export async function callMethod( + request: APIRequestContext, + method: string, + args: Record = {} +): Promise { + const csrfToken = getCsrfToken(); + + const response = await request.post(`/api/method/${method}`, { + data: args, + headers: { + 'Content-Type': 'application/json', + ...(csrfToken ? { 'X-Frappe-CSRF-Token': csrfToken } : {}), + }, + }); + + if (!response.ok()) { + const error = await response.text(); + throw new Error(`Failed to call ${method}: ${error}`); + } + + const result: FrappeResponse = await response.json(); + return result.message as T; +} + +/** + * Get a list of documents via Frappe REST API. + */ +export async function getList>( + request: APIRequestContext, + doctype: string, + options: { + fields?: string[]; + filters?: Record; + limit?: number; + orderBy?: string; + } = {} +): Promise { + const params = new URLSearchParams(); + + if (options.fields) { + params.set('fields', JSON.stringify(options.fields)); + } + if (options.filters) { + params.set('filters', JSON.stringify(options.filters)); + } + if (options.limit) { + params.set('limit_page_length', options.limit.toString()); + } + if (options.orderBy) { + params.set('order_by', options.orderBy); + } + + const response = await request.get( + `/api/resource/${doctype}?${params.toString()}` + ); + + if (!response.ok()) { + const error = await response.text(); + throw new Error(`Failed to get list of ${doctype}: ${error}`); + } + + const result = await response.json(); + return result.data as T[]; +} + +/** + * Check if a document exists. + */ +export async function docExists( + request: APIRequestContext, + doctype: string, + name: string +): Promise { + try { + await getDoc(request, doctype, name); + return true; + } catch { + return false; + } +} diff --git a/e2e/helpers/index.ts b/e2e/helpers/index.ts new file mode 100644 index 0000000..6d7f4d9 --- /dev/null +++ b/e2e/helpers/index.ts @@ -0,0 +1,3 @@ +export * from './auth'; +export * from './frappe'; +export * from './workflow'; diff --git a/e2e/helpers/workflow.ts b/e2e/helpers/workflow.ts new file mode 100644 index 0000000..e34f8dd --- /dev/null +++ b/e2e/helpers/workflow.ts @@ -0,0 +1,182 @@ +import { APIRequestContext } from '@playwright/test'; +import { createDoc, deleteDoc, getDoc, docExists } from './frappe'; + +/** + * Hazel Workflow document interface. + * Based on hazelnode/doctype/hazel_workflow/hazel_workflow.json + */ +export interface HazelWorkflow { + name: string; + title: string; + description?: string; + enabled?: number; // Frappe Check field: 0 or 1 + trigger_type?: string; // Link to Hazel Node Type + trigger_config?: string; // JSON + nodes?: HazelNode[]; + connections?: HazelConnection[]; +} + +/** + * Hazel Node interface. + * Based on hazelnode/doctype/hazel_node/hazel_node.json + */ +export interface HazelNode { + node_id: string; + type: string; // Link to Hazel Node Type + position_x: number; + position_y: number; + parameters?: string; // JSON string +} + +/** + * Hazel Connection interface. + * Based on hazelnode/doctype/hazel_node_connection/hazel_node_connection.json + */ +export interface HazelConnection { + source_node_id: string; + source_handle?: string; + target_node_id: string; + target_handle?: string; +} + +/** + * Create a test workflow via API. + * Returns the workflow name (docname). + * + * Note: If nodes are provided, trigger_type must also be set + * per hazel_workflow.py validation. + */ +export async function createTestWorkflow( + request: APIRequestContext, + title: string, + options: { + description?: string; + trigger_type?: string; + trigger_config?: Record; + nodes?: HazelNode[]; + connections?: HazelConnection[]; + } = {} +): Promise { + const doc = await createDoc(request, 'Hazel Workflow', { + title, + description: options.description || `Test workflow: ${title}`, + trigger_type: options.trigger_type, + trigger_config: options.trigger_config + ? JSON.stringify(options.trigger_config) + : undefined, + nodes: options.nodes || [], + connections: options.connections || [], + }); + + return doc.name; +} + +/** + * Delete a test workflow via API. + */ +export async function deleteTestWorkflow( + request: APIRequestContext, + name: string +): Promise { + if (await docExists(request, 'Hazel Workflow', name)) { + await deleteDoc(request, 'Hazel Workflow', name); + } +} + +/** + * Get a workflow by name. + */ +export async function getWorkflow( + request: APIRequestContext, + name: string +): Promise { + return getDoc(request, 'Hazel Workflow', name); +} + +/** + * Create a workflow with Schedule Event trigger (no action nodes). + * This is the simplest valid workflow for testing. + */ +export async function createScheduleWorkflow( + request: APIRequestContext, + title: string +): Promise { + return createTestWorkflow(request, title, { + trigger_type: 'Schedule Event', + trigger_config: { cron: '0 0 * * *' }, + }); +} + +/** + * Create a workflow with Schedule Event trigger and Log action. + */ +export async function createScheduleLogWorkflow( + request: APIRequestContext, + title: string +): Promise { + return createTestWorkflow(request, title, { + trigger_type: 'Schedule Event', + trigger_config: { cron: '0 0 * * *' }, + nodes: [ + { + node_id: 'node_1_log', + type: 'Log', + position_x: 350, + position_y: 200, + parameters: JSON.stringify({ message: 'Test log message' }), + }, + ], + connections: [ + { + source_node_id: 'trigger', + source_handle: 'default', + target_node_id: 'node_1_log', + target_handle: 'default', + }, + ], + }); +} + +/** + * Create a workflow with Document Event trigger. + */ +export async function createDocumentEventWorkflow( + request: APIRequestContext, + title: string +): Promise { + return createTestWorkflow(request, title, { + trigger_type: 'Document Event', + trigger_config: { doctype: 'User', event: 'on_update' }, + }); +} + +// Legacy aliases for backward compatibility +export const createWebhookWorkflow = createScheduleWorkflow; +export const createWebhookLogWorkflow = createScheduleLogWorkflow; + +/** + * Generate a unique workflow title for tests. + */ +export function generateWorkflowTitle(prefix: string = 'Test'): string { + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 8); + return `${prefix} Workflow ${timestamp}-${random}`; +} + +/** + * Clean up all test workflows (use with caution). + */ +export async function cleanupTestWorkflows( + request: APIRequestContext, + titlePrefix: string = 'Test' +): Promise { + const { getList } = await import('./frappe'); + const workflows = await getList(request, 'Hazel Workflow', { + filters: { title: ['like', `${titlePrefix}%`] }, + fields: ['name'], + }); + + for (const workflow of workflows) { + await deleteTestWorkflow(request, workflow.name); + } +} diff --git a/e2e/pages/index.ts b/e2e/pages/index.ts new file mode 100644 index 0000000..f4a7d9c --- /dev/null +++ b/e2e/pages/index.ts @@ -0,0 +1,3 @@ +export { LoginPage } from './login.page'; +export { WorkflowListPage } from './workflow-list.page'; +export { WorkflowEditorPage } from './workflow-editor.page'; diff --git a/e2e/pages/login.page.ts b/e2e/pages/login.page.ts new file mode 100644 index 0000000..7e78cd8 --- /dev/null +++ b/e2e/pages/login.page.ts @@ -0,0 +1,77 @@ +import { Page, Locator, expect } from '@playwright/test'; + +/** + * Page Object for the Frappe login page. + * + * Frappe uses specific HTML structure for login: + * - Email input: input[type="text"] with autocomplete="username" + * - Password input: input[type="password"] + * - Submit button: .btn-login or button with "Login" text + */ +export class LoginPage { + readonly page: Page; + readonly emailInput: Locator; + readonly passwordInput: Locator; + readonly submitButton: Locator; + readonly errorMessage: Locator; + + constructor(page: Page) { + this.page = page; + // Frappe login page selectors - use exact IDs from frappe/www/login.html + // See: https://github.com/frappe/frappe/blob/develop/frappe/www/login.html + this.emailInput = page.locator('#login_email'); + this.passwordInput = page.locator('#login_password'); + this.submitButton = page.locator('button.btn-login'); + this.errorMessage = page.locator('.msgprint, .alert-danger').first(); + } + + /** + * Navigate to the login page. + */ + async goto(): Promise { + await this.page.goto('/login'); + await this.page.waitForLoadState('networkidle'); + } + + /** + * Fill in the login form with credentials. + */ + async fillCredentials(email: string, password: string): Promise { + await this.emailInput.fill(email); + await this.passwordInput.fill(password); + } + + /** + * Submit the login form. + */ + async submit(): Promise { + await this.submitButton.click(); + } + + /** + * Perform a complete login. + */ + async login( + email: string = 'Administrator', + password: string = 'admin' + ): Promise { + await this.goto(); + await this.fillCredentials(email, password); + await this.submit(); + await this.page.waitForURL(/\/(app|desk|hazelnode)/, { timeout: 30000 }); + } + + /** + * Assert that login failed with an error. + */ + async expectLoginError(): Promise { + await expect(this.errorMessage).toBeVisible(); + } + + /** + * Assert that we're on the login page. + */ + async expectToBeOnLoginPage(): Promise { + await expect(this.page).toHaveURL(/.*login.*/); + } +} diff --git a/e2e/pages/workflow-editor.page.ts b/e2e/pages/workflow-editor.page.ts new file mode 100644 index 0000000..389cf29 --- /dev/null +++ b/e2e/pages/workflow-editor.page.ts @@ -0,0 +1,296 @@ +import { Page, Locator, expect } from '@playwright/test'; + +/** + * Page Object for the Hazelnode workflow editor page. + */ +export class WorkflowEditorPage { + readonly page: Page; + + // Main layout elements + readonly canvas: Locator; + readonly nodePalette: Locator; + readonly configPanel: Locator; + + // ReactFlow elements + readonly reactFlowContainer: Locator; + readonly nodes: Locator; + readonly edges: Locator; + readonly controls: Locator; + + // Palette elements + readonly paletteNodes: Locator; + readonly actionNodes: Locator; + readonly logicNodes: Locator; + + // Config panel elements + readonly configPanelTitle: Locator; + readonly saveButton: Locator; + + constructor(page: Page) { + this.page = page; + + // Main layout + this.canvas = page.locator('.react-flow'); + this.nodePalette = page.locator('[class*="scroll-area"], .p-3:has(h3)'); + this.configPanel = page.locator( + '[data-testid="config-panel"], .config-panel' + ); + + // ReactFlow + this.reactFlowContainer = page.locator('.react-flow__renderer'); + this.nodes = page.locator('.react-flow__node'); + this.edges = page.locator('.react-flow__edge'); + this.controls = page.locator('.react-flow__controls'); + + // Palette + this.paletteNodes = page.locator('[draggable="true"]'); + this.actionNodes = page.locator( + 'h4:has-text("Actions") + div [draggable="true"]' + ); + this.logicNodes = page.locator( + 'h4:has-text("Logic") + div [draggable="true"]' + ); + + // Config panel + this.configPanelTitle = page.locator('[data-testid="config-panel-title"]'); + this.saveButton = page.locator('button:has-text("Save")'); + } + + /** + * Navigate to a workflow editor by ID. + */ + async goto(workflowId: string): Promise { + await this.page.goto(`/hazelnode/workflow/${workflowId}`); + await this.waitForEditorReady(); + } + + /** + * Wait for the editor to be ready. + */ + async waitForEditorReady(): Promise { + await this.page.waitForLoadState('domcontentloaded'); + await this.canvas.waitFor({ state: 'visible', timeout: 30000 }); + await this.page.waitForLoadState('networkidle'); + // Wait for ReactFlow to initialize controls + await this.controls.waitFor({ state: 'visible', timeout: 15000 }); + } + + /** + * Get the count of nodes on the canvas. + */ + async getNodeCount(): Promise { + return this.nodes.count(); + } + + /** + * Get the count of edges (connections) on the canvas. + */ + async getEdgeCount(): Promise { + return this.edges.count(); + } + + /** + * Get a specific node by its data-id attribute. + */ + getNodeById(nodeId: string): Locator { + return this.page.locator(`.react-flow__node[data-id="${nodeId}"]`); + } + + /** + * Get a draggable node from the palette by exact name. + * Uses the span containing the node name to be precise. + */ + getPaletteNode(nodeName: string): Locator { + // Match Card with draggable that contains a span with exact node name + return this.page.locator( + `[draggable="true"]:has(span.font-medium:text-is("${nodeName}"))` + ); + } + + /** + * Drag a node from the palette to the canvas. + */ + async dragNodeToCanvas( + nodeName: string, + targetX: number, + targetY: number + ): Promise { + const paletteNode = this.getPaletteNode(nodeName); + + // Ensure palette node is visible + await paletteNode.waitFor({ state: 'visible', timeout: 10000 }); + + const canvasBounds = await this.canvas.boundingBox(); + if (!canvasBounds) { + throw new Error('Canvas not found'); + } + + // Drag to canvas at specified position + await paletteNode.dragTo(this.canvas, { + targetPosition: { x: targetX, y: targetY }, + }); + } + + /** + * Click on a node to select it. + */ + async selectNode(nodeId: string): Promise { + const node = this.getNodeById(nodeId); + await node.click(); + } + + /** + * Double-click on a node to open its config. + */ + async openNodeConfig(nodeId: string): Promise { + const node = this.getNodeById(nodeId); + await node.dblclick(); + } + + /** + * Connect two nodes by dragging from source handle to target handle. + */ + async connectNodes( + sourceNodeId: string, + targetNodeId: string, + sourceHandle: string = 'source', + targetHandle: string = 'target' + ): Promise { + const sourceConnector = this.page.locator( + `.react-flow__node[data-id="${sourceNodeId}"] .react-flow__handle[data-handleid="${sourceHandle}"]` + ); + const targetConnector = this.page.locator( + `.react-flow__node[data-id="${targetNodeId}"] .react-flow__handle[data-handleid="${targetHandle}"]` + ); + + await sourceConnector.dragTo(targetConnector); + } + + /** + * Delete the currently selected node. + * Tries both Delete and Backspace keys as ReactFlow may respond to either. + */ + async deleteSelectedNode(): Promise { + // Try Delete key first, then Backspace + await this.page.keyboard.press('Delete'); + await this.page.keyboard.press('Backspace'); + } + + /** + * Delete a node by its ID. + */ + async deleteNode(nodeId: string): Promise { + const node = this.getNodeById(nodeId); + // Click on the node to select it and ensure focus + await node.click(); + // Verify node is selected + await expect(node).toHaveClass(/selected/); + // Small delay to ensure selection is registered + await this.page.waitForTimeout(100); + // Press delete keys + await this.deleteSelectedNode(); + } + + /** + * Save the workflow. + */ + async save(): Promise { + await this.saveButton.click(); + await this.page.waitForResponse( + (resp) => + resp.url().includes('/api/resource/Hazel%20Workflow') && + resp.request().method() === 'PUT' && + resp.status() === 200 + ); + } + + /** + * Wait for ReactFlow controls to be ready. + */ + async waitForControlsReady(): Promise { + await this.controls.waitFor({ state: 'visible', timeout: 10000 }); + } + + /** + * Get a control button by its aria-label or title. + * ReactFlow may use different attributes in different versions. + */ + private getControlButton(name: string): Locator { + return this.controls.locator( + `button[aria-label="${name}"], button[title="${name}"]` + ); + } + + /** + * Zoom in on the canvas. + * Uses button click with force to bypass any overlays. + */ + async zoomIn(): Promise { + await this.waitForControlsReady(); + const zoomInButton = this.getControlButton('zoom in'); + // Use force click to bypass any intercepting elements + await zoomInButton.click({ force: true }); + } + + /** + * Zoom out on the canvas. + * Uses button click with force to bypass any overlays. + */ + async zoomOut(): Promise { + await this.waitForControlsReady(); + const zoomOutButton = this.getControlButton('zoom out'); + // Use force click to bypass any intercepting elements + await zoomOutButton.click({ force: true }); + } + + /** + * Fit the view to show all nodes. + */ + async fitView(): Promise { + await this.waitForControlsReady(); + const fitButton = this.getControlButton('fit view'); + // Use force click to bypass any intercepting elements + await fitButton.click({ force: true }); + } + + /** + * Assert that the editor is displayed. + */ + async expectEditorVisible(): Promise { + await expect(this.canvas).toBeVisible(); + } + + /** + * Assert a specific number of nodes on canvas. + */ + async expectNodeCount(count: number): Promise { + await expect(this.nodes).toHaveCount(count); + } + + /** + * Assert a specific number of edges on canvas. + */ + async expectEdgeCount(count: number): Promise { + await expect(this.edges).toHaveCount(count); + } + + /** + * Assert that a node with given ID exists. + */ + async expectNodeExists(nodeId: string): Promise { + await expect(this.getNodeById(nodeId)).toBeVisible(); + } + + /** + * Get all node IDs currently on the canvas. + */ + async getAllNodeIds(): Promise { + const nodes = await this.nodes.all(); + const ids: string[] = []; + for (const node of nodes) { + const id = await node.getAttribute('data-id'); + if (id) ids.push(id); + } + return ids; + } +} diff --git a/e2e/pages/workflow-list.page.ts b/e2e/pages/workflow-list.page.ts new file mode 100644 index 0000000..de9554c --- /dev/null +++ b/e2e/pages/workflow-list.page.ts @@ -0,0 +1,155 @@ +import { Page, Locator, expect } from '@playwright/test'; + +/** + * Page Object for the Hazelnode workflow list page. + */ +export class WorkflowListPage { + readonly page: Page; + readonly pageTitle: Locator; + readonly newWorkflowButton: Locator; + readonly workflowTable: Locator; + readonly workflowRows: Locator; + readonly loadingSkeleton: Locator; + readonly errorMessage: Locator; + + // Create dialog elements + readonly createDialog: Locator; + readonly titleInput: Locator; + readonly createButton: Locator; + readonly cancelButton: Locator; + + constructor(page: Page) { + this.page = page; + + // List page elements + this.pageTitle = page.locator('text=Your Workflows'); + this.newWorkflowButton = page.locator('button:has-text("New Workflow")'); + this.workflowTable = page.locator('table'); + this.workflowRows = page.locator('table tbody tr'); + this.loadingSkeleton = page.locator('[class*="skeleton"]'); + this.errorMessage = page.locator('text=Error loading workflows'); + + // Create dialog elements - HeadlessUI dialog panel + // Use content-based selectors as HeadlessUI transitions can leave role="dialog" in DOM + this.createDialog = page.locator('text=Create new workflow'); + // Input has id="title" + this.titleInput = page.locator('input#title'); + this.createButton = page.locator('button:has-text("Create"):not(:has-text("Creating"))'); + this.cancelButton = page.locator('button:has-text("Cancel")'); + } + + /** + * Navigate to the workflow list page. + */ + async goto(): Promise { + await this.page.goto('/hazelnode'); + await this.waitForLoaded(); + } + + /** + * Wait for the page to finish loading. + */ + async waitForLoaded(): Promise { + // Wait for DOM to settle + await this.page.waitForLoadState('domcontentloaded'); + // Wait for page title and button (appear even during loading) + await this.pageTitle.waitFor({ state: 'visible', timeout: 30000 }); + await this.newWorkflowButton.waitFor({ state: 'visible', timeout: 10000 }); + // Wait for network to settle + await this.page.waitForLoadState('networkidle'); + // Wait for either the table or error message to confirm data loaded + await Promise.race([ + this.workflowTable.waitFor({ state: 'visible', timeout: 30000 }), + this.errorMessage.waitFor({ state: 'visible', timeout: 30000 }), + ]).catch(() => { + // May already be visible + }); + } + + /** + * Open the create workflow dialog. + */ + async openCreateDialog(): Promise { + // Ensure button is visible and enabled + await this.newWorkflowButton.waitFor({ state: 'visible', timeout: 10000 }); + await expect(this.newWorkflowButton).toBeEnabled(); + await this.newWorkflowButton.click(); + // Wait for dialog title to appear (HeadlessUI transitions) + await this.createDialog.waitFor({ state: 'visible', timeout: 15000 }); + // Wait for input to be visible and interactable + await this.titleInput.waitFor({ state: 'visible', timeout: 5000 }); + } + + /** + * Create a new workflow via the dialog. + */ + async createWorkflow(title: string): Promise { + await this.openCreateDialog(); + await this.titleInput.fill(title); + await this.createButton.click(); + // Wait for navigation to editor page + await this.page.waitForURL(/.*workflow\/.*/, { timeout: 30000 }); + } + + /** + * Close the create dialog without creating. + */ + async closeCreateDialog(): Promise { + await this.cancelButton.click(); + // Wait for dialog title to disappear + await this.createDialog.waitFor({ state: 'hidden', timeout: 5000 }); + } + + /** + * Get the number of workflows displayed. + */ + async getWorkflowCount(): Promise { + return this.workflowRows.count(); + } + + /** + * Click on a workflow by its title. + */ + async clickWorkflow(title: string): Promise { + await this.page.locator(`table tr:has-text("${title}")`).click(); + await this.page.waitForURL(/.*workflow\/.*/, { timeout: 30000 }); + } + + /** + * Toggle the enabled state of a workflow. + */ + async toggleWorkflowEnabled(title: string): Promise { + const row = this.page.locator(`table tr:has-text("${title}")`); + const toggle = row.locator('button[role="switch"]'); + await toggle.click(); + // Wait for API response + await this.page.waitForResponse( + (resp) => + resp.url().includes('/api/resource/Hazel%20Workflow') && + resp.request().method() === 'PUT' + ); + } + + /** + * Check if a workflow exists in the list. + */ + async workflowExists(title: string): Promise { + const row = this.page.locator(`table tr:has-text("${title}")`); + return (await row.count()) > 0; + } + + /** + * Assert that the page loaded successfully. + */ + async expectPageLoaded(): Promise { + await expect(this.pageTitle).toBeVisible(); + await expect(this.newWorkflowButton).toBeVisible(); + } + + /** + * Assert that a workflow is visible in the list. + */ + async expectWorkflowVisible(title: string): Promise { + await expect(this.page.locator(`table tr:has-text("${title}")`)).toBeVisible(); + } +} diff --git a/e2e/tests/auth.setup.ts b/e2e/tests/auth.setup.ts new file mode 100644 index 0000000..869cf5e --- /dev/null +++ b/e2e/tests/auth.setup.ts @@ -0,0 +1,69 @@ +import { test as setup, expect } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; + +const authFile = 'e2e/.auth/user.json'; +const csrfFile = 'e2e/.auth/csrf.json'; + +/** + * Authentication setup - runs once before all tests. + * + * This follows Playwright's recommended "setup project" pattern: + * - Authenticates via API using page.request (shares cookies with browser) + * - Saves browser state (cookies) to file + * - Extracts and saves CSRF token for API calls + * - Other tests reuse this state via storageState config + * + * @see https://playwright.dev/docs/auth + */ +setup('authenticate', async ({ page }) => { + // Ensure auth directory exists + const authDir = path.dirname(authFile); + if (!fs.existsSync(authDir)) { + fs.mkdirSync(authDir, { recursive: true }); + } + + // Login via Frappe API using page.request (shares context with page) + const loginResponse = await page.request.post('/api/method/login', { + form: { + usr: process.env.FRAPPE_USER || 'Administrator', + pwd: process.env.FRAPPE_PASSWORD || 'admin', + }, + }); + + expect(loginResponse.ok()).toBeTruthy(); + + // Verify login succeeded by checking current user + const userResponse = await page.request.get( + '/api/method/frappe.auth.get_logged_user' + ); + expect(userResponse.ok()).toBeTruthy(); + + const userData = await userResponse.json(); + expect(userData.message).not.toBe('Guest'); + + console.log(`✅ Authenticated as: ${userData.message}`); + + // Navigate to app to load frappe context and get CSRF token + await page.goto('/app'); + await page.waitForLoadState('networkidle'); + + // Wait for frappe to initialize and extract CSRF token + const csrfToken = await page.evaluate(() => { + // Wait for frappe to be defined + return (window as unknown as { frappe?: { csrf_token?: string } }).frappe + ?.csrf_token; + }); + + if (csrfToken) { + // Save CSRF token to file for API helpers to use + fs.writeFileSync(csrfFile, JSON.stringify({ csrf_token: csrfToken })); + console.log(`🔐 Saved CSRF token to ${csrfFile}`); + } else { + console.warn('⚠️ Could not extract CSRF token from page'); + } + + // Save authentication state + await page.context().storageState({ path: authFile }); + console.log(`💾 Saved auth state to ${authFile}`); +}); diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts new file mode 100644 index 0000000..ae9397c --- /dev/null +++ b/e2e/tests/auth.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from '@playwright/test'; +import { LoginPage } from '../pages'; +import { isLoggedIn } from '../helpers'; + +/** + * Tests for authentication that already have auth state from setup. + */ +test.describe('Authentication - Pre-authenticated', () => { + test('should access hazelnode when authenticated', async ({ page }) => { + // Already authenticated via setup project + await page.goto('/hazelnode'); + await page.waitForLoadState('networkidle'); + + // Should not be redirected to login + await expect(page).not.toHaveURL(/.*login.*/); + }); + + test('should verify session via API', async ({ request }) => { + // Already authenticated via setup project + const loggedIn = await isLoggedIn(request); + expect(loggedIn).toBe(true); + }); +}); + +/** + * Tests for authentication that need fresh (unauthenticated) state. + * Uses storageState reset to clear any auth cookies. + */ +test.describe('Authentication - Fresh state', () => { + // Reset storage state to test without authentication + test.use({ storageState: { cookies: [], origins: [] } }); + + test('should login via UI', async ({ page }) => { + const loginPage = new LoginPage(page); + + await loginPage.login('Administrator', 'admin'); + + // Should be redirected away from login + await expect(page).not.toHaveURL(/.*login.*/); + }); + + test('should show error for invalid credentials', async ({ page }) => { + const loginPage = new LoginPage(page); + + await loginPage.goto(); + await loginPage.fillCredentials('invalid@example.com', 'wrongpassword'); + await loginPage.submit(); + + // Should stay on login page + await loginPage.expectToBeOnLoginPage(); + }); + + test('should redirect to login when not authenticated', async ({ page }) => { + // Try to access protected page without auth + await page.goto('/hazelnode'); + await page.waitForLoadState('networkidle'); + + // Should be redirected to login + await expect(page).toHaveURL(/.*login.*/); + }); +}); diff --git a/e2e/tests/workflow-crud.spec.ts b/e2e/tests/workflow-crud.spec.ts new file mode 100644 index 0000000..ab6c068 --- /dev/null +++ b/e2e/tests/workflow-crud.spec.ts @@ -0,0 +1,153 @@ +import { test, expect } from '@playwright/test'; +import { WorkflowListPage } from '../pages'; +import { + createTestWorkflow, + deleteTestWorkflow, + generateWorkflowTitle, + getWorkflow, +} from '../helpers'; + +/** + * Workflow CRUD tests. + * Authentication is handled by the setup project - tests run pre-authenticated. + */ +test.describe('Workflow CRUD Operations', () => { + + test('should display workflow list page', async ({ page }) => { + const listPage = new WorkflowListPage(page); + + await listPage.goto(); + await listPage.expectPageLoaded(); + }); + + test('should create a new workflow via UI', async ({ page, request }) => { + const listPage = new WorkflowListPage(page); + const workflowTitle = generateWorkflowTitle('UI Create'); + + await listPage.goto(); + await listPage.createWorkflow(workflowTitle); + + // Should navigate to editor + await expect(page).toHaveURL(/.*workflow\/.*/); + + // Verify workflow was created via API + const urlParts = page.url().split('/'); + const workflowId = urlParts[urlParts.length - 1]; + + const workflow = await getWorkflow(request, workflowId); + expect(workflow.title).toBe(workflowTitle); + + // Cleanup + await deleteTestWorkflow(request, workflowId); + }); + + test('should list existing workflows', async ({ request, page }) => { + const listPage = new WorkflowListPage(page); + const workflowTitle = generateWorkflowTitle('List Test'); + + // Create workflow via API + const workflowName = await createTestWorkflow(request, workflowTitle); + + try { + await listPage.goto(); + await listPage.expectWorkflowVisible(workflowTitle); + } finally { + // Cleanup + await deleteTestWorkflow(request, workflowName); + } + }); + + test('should navigate to workflow editor when clicking a workflow', async ({ + request, + page, + }) => { + const listPage = new WorkflowListPage(page); + const workflowTitle = generateWorkflowTitle('Click Test'); + + // Create workflow via API + const workflowName = await createTestWorkflow(request, workflowTitle); + + try { + await listPage.goto(); + await listPage.clickWorkflow(workflowTitle); + + // Should navigate to editor + await expect(page).toHaveURL(new RegExp(`.*workflow/${workflowName}.*`)); + } finally { + // Cleanup + await deleteTestWorkflow(request, workflowName); + } + }); + + test('should toggle workflow enabled state', async ({ request, page }) => { + const listPage = new WorkflowListPage(page); + const workflowTitle = generateWorkflowTitle('Toggle Test'); + + // Create workflow via API + const workflowName = await createTestWorkflow(request, workflowTitle); + + try { + await listPage.goto(); + + // Toggle enabled state + await listPage.toggleWorkflowEnabled(workflowTitle); + + // Verify state changed via API + const workflow = await getWorkflow(request, workflowName); + // Initial state should be 0, after toggle should be 1 + expect(workflow.enabled).toBe(1); + + // Toggle back + await listPage.toggleWorkflowEnabled(workflowTitle); + + const updatedWorkflow = await getWorkflow(request, workflowName); + expect(updatedWorkflow.enabled).toBe(0); + } finally { + // Cleanup + await deleteTestWorkflow(request, workflowName); + } + }); + + test('should cancel workflow creation dialog', async ({ page }) => { + const listPage = new WorkflowListPage(page); + + await listPage.goto(); + await listPage.openCreateDialog(); + + // Dialog should be visible + await expect(listPage.createDialog).toBeVisible(); + + // Cancel + await listPage.closeCreateDialog(); + + // Dialog should be hidden + await expect(listPage.createDialog).toBeHidden(); + + // Should still be on list page + await listPage.expectPageLoaded(); + }); + + test('should create workflow via API and verify in list', async ({ + request, + page, + }) => { + const listPage = new WorkflowListPage(page); + const workflowTitle = generateWorkflowTitle('API Create'); + + // Create via API + const workflowName = await createTestWorkflow(request, workflowTitle, { + description: 'Created via API for testing', + }); + + try { + await listPage.goto(); + + // Should appear in list + const exists = await listPage.workflowExists(workflowTitle); + expect(exists).toBe(true); + } finally { + // Cleanup + await deleteTestWorkflow(request, workflowName); + } + }); +}); diff --git a/e2e/tests/workflow-editor.spec.ts b/e2e/tests/workflow-editor.spec.ts new file mode 100644 index 0000000..19492c4 --- /dev/null +++ b/e2e/tests/workflow-editor.spec.ts @@ -0,0 +1,260 @@ +import { test, expect } from '@playwright/test'; +import { WorkflowEditorPage } from '../pages'; +import { + createTestWorkflow, + deleteTestWorkflow, + generateWorkflowTitle, + createWebhookWorkflow, + createWebhookLogWorkflow, +} from '../helpers'; + +/** + * Workflow Editor tests. + * Authentication is handled by the setup project - tests run pre-authenticated. + */ +test.describe('Workflow Editor', () => { + + test('should display workflow editor canvas', async ({ request, page }) => { + const workflowTitle = generateWorkflowTitle('Editor Canvas'); + const workflowName = await createTestWorkflow(request, workflowTitle); + + try { + const editorPage = new WorkflowEditorPage(page); + await editorPage.goto(workflowName); + + await editorPage.expectEditorVisible(); + } finally { + await deleteTestWorkflow(request, workflowName); + } + }); + + test('should display existing nodes from workflow', async ({ + request, + page, + }) => { + const workflowTitle = generateWorkflowTitle('Existing Nodes'); + const workflowName = await createWebhookWorkflow(request, workflowTitle); + + try { + const editorPage = new WorkflowEditorPage(page); + await editorPage.goto(workflowName); + + // Wait for nodes to render + await page.waitForTimeout(1000); + + // Should have at least one node (the webhook trigger) + const nodeCount = await editorPage.getNodeCount(); + expect(nodeCount).toBeGreaterThanOrEqual(1); + } finally { + await deleteTestWorkflow(request, workflowName); + } + }); + + test('should display existing edges from workflow', async ({ + request, + page, + }) => { + const workflowTitle = generateWorkflowTitle('Existing Edges'); + const workflowName = await createWebhookLogWorkflow( + request, + workflowTitle + ); + + try { + const editorPage = new WorkflowEditorPage(page); + await editorPage.goto(workflowName); + + // Wait for edges to render + await page.waitForTimeout(1000); + + // Should have at least one edge connecting the nodes + const edgeCount = await editorPage.getEdgeCount(); + expect(edgeCount).toBeGreaterThanOrEqual(1); + } finally { + await deleteTestWorkflow(request, workflowName); + } + }); + + test('should show node palette with draggable nodes', async ({ + request, + page, + }) => { + const workflowTitle = generateWorkflowTitle('Node Palette'); + const workflowName = await createTestWorkflow(request, workflowTitle); + + try { + const editorPage = new WorkflowEditorPage(page); + await editorPage.goto(workflowName); + + // Check for draggable nodes in palette + const paletteNodeCount = await editorPage.paletteNodes.count(); + expect(paletteNodeCount).toBeGreaterThan(0); + } finally { + await deleteTestWorkflow(request, workflowName); + } + }); + + test('should show ReactFlow controls', async ({ request, page }) => { + const workflowTitle = generateWorkflowTitle('Controls'); + const workflowName = await createTestWorkflow(request, workflowTitle); + + try { + const editorPage = new WorkflowEditorPage(page); + await editorPage.goto(workflowName); + + // Controls should be visible + await expect(editorPage.controls).toBeVisible(); + } finally { + await deleteTestWorkflow(request, workflowName); + } + }); + + test('should select a node when clicked', async ({ request, page }) => { + const workflowTitle = generateWorkflowTitle('Select Node'); + const workflowName = await createWebhookWorkflow(request, workflowTitle); + + try { + const editorPage = new WorkflowEditorPage(page); + await editorPage.goto(workflowName); + + // Wait for nodes to render + await page.waitForTimeout(1000); + + // Get first node and click it + const nodeIds = await editorPage.getAllNodeIds(); + expect(nodeIds.length).toBeGreaterThan(0); + + await editorPage.selectNode(nodeIds[0]); + + // Node should have selected class + const selectedNode = editorPage.getNodeById(nodeIds[0]); + await expect(selectedNode).toHaveClass(/selected/); + } finally { + await deleteTestWorkflow(request, workflowName); + } + }); + + test('should use zoom controls', async ({ request, page }) => { + const workflowTitle = generateWorkflowTitle('Zoom Controls'); + const workflowName = await createTestWorkflow(request, workflowTitle); + + try { + const editorPage = new WorkflowEditorPage(page); + await editorPage.goto(workflowName); + + // Test zoom in + await editorPage.zoomIn(); + + // Test zoom out + await editorPage.zoomOut(); + + // Test fit view + await editorPage.fitView(); + + // Editor should still be visible + await editorPage.expectEditorVisible(); + } finally { + await deleteTestWorkflow(request, workflowName); + } + }); + + test('should persist node count after page reload', async ({ + request, + page, + }) => { + const workflowTitle = generateWorkflowTitle('Persist Reload'); + const workflowName = await createWebhookLogWorkflow( + request, + workflowTitle + ); + + try { + const editorPage = new WorkflowEditorPage(page); + await editorPage.goto(workflowName); + + // Wait for nodes to render + await page.waitForTimeout(1000); + + const initialNodeCount = await editorPage.getNodeCount(); + + // Reload the page + await page.reload(); + await editorPage.waitForEditorReady(); + + // Wait for nodes to render again + await page.waitForTimeout(1000); + + const reloadedNodeCount = await editorPage.getNodeCount(); + + // Node count should be the same + expect(reloadedNodeCount).toBe(initialNodeCount); + } finally { + await deleteTestWorkflow(request, workflowName); + } + }); +}); + +/** + * Workflow Editor - Node Interaction tests. + * Authentication is handled by the setup project - tests run pre-authenticated. + */ +test.describe('Workflow Editor - Node Interactions', () => { + test('should drag and drop node from palette to canvas', async ({ + request, + page, + }) => { + const workflowTitle = generateWorkflowTitle('Drag Drop'); + const workflowName = await createTestWorkflow(request, workflowTitle); + + try { + const editorPage = new WorkflowEditorPage(page); + await editorPage.goto(workflowName); + + const initialNodeCount = await editorPage.getNodeCount(); + + // Try to drag a Log node to the canvas + await editorPage.dragNodeToCanvas('Log', 300, 200); + + // Wait for node to be added + await page.waitForTimeout(500); + + const newNodeCount = await editorPage.getNodeCount(); + + // Should have one more node + expect(newNodeCount).toBe(initialNodeCount + 1); + } finally { + await deleteTestWorkflow(request, workflowName); + } + }); + + test('should delete node with keyboard', async ({ request, page }) => { + const workflowTitle = generateWorkflowTitle('Delete Node'); + const workflowName = await createWebhookWorkflow(request, workflowTitle); + + try { + const editorPage = new WorkflowEditorPage(page); + await editorPage.goto(workflowName); + + // Wait for nodes to render + await page.waitForTimeout(1000); + + const initialNodeCount = await editorPage.getNodeCount(); + const nodeIds = await editorPage.getAllNodeIds(); + + if (nodeIds.length > 0) { + // Select and delete the first node + await editorPage.deleteNode(nodeIds[0]); + + // Wait for deletion + await page.waitForTimeout(500); + + const newNodeCount = await editorPage.getNodeCount(); + + // Should have one less node + expect(newNodeCount).toBe(initialNodeCount - 1); + } + } finally { + await deleteTestWorkflow(request, workflowName); + } + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..e9b45a5 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "noEmit": true, + "baseUrl": ".", + "paths": { + "@helpers/*": ["helpers/*"], + "@pages/*": ["pages/*"] + } + }, + "include": [ + "**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/package.json b/package.json index ff763c5..2f9e43f 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,17 @@ "test": "echo \"Error: no test specified\" && exit 1", "postinstall": "cd frontend && yarn install", "dev": "cd frontend && yarn dev", - "build": "cd frontend && yarn build" + "build": "cd frontend && yarn build", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed", + "test:e2e:debug": "playwright test --debug" }, "keywords": [], "author": "", - "license": "ISC" + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.49.0", + "@types/node": "^22.0.0" + } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..b47e75f --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,57 @@ +import { defineConfig, devices } from '@playwright/test'; + +// Auth state file path (added to .gitignore) +const authFile = 'e2e/.auth/user.json'; + +/** + * Playwright configuration for Hazelnode E2E tests. + * + * Uses the recommended "setup project" pattern for authentication: + * 1. Setup project runs first and saves auth state to file + * 2. Other projects depend on setup and reuse the stored auth state + * + * @see https://playwright.dev/docs/auth + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + testDir: './e2e/tests', + fullyParallel: false, // Sequential for Frappe state consistency + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, // Single worker for Frappe session management + // Use multiple reporters in CI for both inline annotations and HTML artifacts + reporter: process.env.CI + ? [['github'], ['html', { open: 'never' }]] + : 'html', + timeout: 60000, // 60s per test + + expect: { + timeout: 10000, // 10s for assertions + }, + + use: { + baseURL: process.env.BASE_URL || 'http://hazelnode.test:8000', + trace: 'on-first-retry', + video: 'retain-on-failure', + screenshot: 'only-on-failure', + actionTimeout: 15000, + navigationTimeout: 30000, + }, + + projects: [ + // Setup project - authenticates and saves state + { + name: 'setup', + testMatch: /auth\.setup\.ts/, + }, + // Main test project - uses stored auth state + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + storageState: authFile, + }, + dependencies: ['setup'], + }, + ], +});