From efbd15774a4acc17db91be0749822f9e0b4c2798 Mon Sep 17 00:00:00 2001 From: perf3ct Date: Tue, 17 Jun 2025 20:10:04 +0000 Subject: [PATCH] feat(e2e): add playwright for e2e tests --- .github/workflows/e2e-tests.yml | 124 ++++++++ README-E2E.md | 279 ++++++++++++++++++ docker-compose.test.yml | 24 ++ frontend/e2e/auth.spec.ts | 112 +++++++ frontend/e2e/document-management.spec.ts | 175 +++++++++++ frontend/e2e/fixtures/auth.ts | 29 ++ frontend/e2e/search.spec.ts | 235 +++++++++++++++ frontend/e2e/settings.spec.ts | 283 ++++++++++++++++++ frontend/e2e/sources.spec.ts | 353 +++++++++++++++++++++++ frontend/e2e/upload.spec.ts | 177 ++++++++++++ frontend/e2e/utils/test-data.ts | 46 +++ frontend/e2e/utils/test-helpers.ts | 54 ++++ frontend/package-lock.json | 64 ++++ frontend/package.json | 5 + frontend/playwright.config.ts | 45 +++ frontend/src/vite-env.d.ts | 12 + frontend/test_data/sample.txt | 11 + run-tests.sh | 71 ++++- scripts/run-e2e-local.sh | 217 ++++++++++++++ 19 files changed, 2313 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/e2e-tests.yml create mode 100644 README-E2E.md create mode 100644 frontend/e2e/auth.spec.ts create mode 100644 frontend/e2e/document-management.spec.ts create mode 100644 frontend/e2e/fixtures/auth.ts create mode 100644 frontend/e2e/search.spec.ts create mode 100644 frontend/e2e/settings.spec.ts create mode 100644 frontend/e2e/sources.spec.ts create mode 100644 frontend/e2e/upload.spec.ts create mode 100644 frontend/e2e/utils/test-data.ts create mode 100644 frontend/e2e/utils/test-helpers.ts create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/test_data/sample.txt create mode 100755 scripts/run-e2e-local.sh diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 0000000..dbc329c --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,124 @@ +name: E2E Tests + +on: + push: + branches: [ master, main ] + pull_request: + branches: [ master, main ] + +jobs: + test-e2e: + timeout-minutes: 60 + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:13 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: readur_test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Setup Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + override: true + + - name: Install frontend dependencies + working-directory: ./frontend + run: npm ci + + - name: Install Playwright Browsers + working-directory: ./frontend + run: npx playwright install --with-deps + + - name: Build frontend + working-directory: ./frontend + run: npm run build + + - name: Setup test database + run: | + PGPASSWORD=postgres psql -h localhost -U postgres -d readur_test -c "CREATE EXTENSION IF NOT EXISTS vector;" + + - name: Run database migrations + run: | + # Set environment variables for test database + export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/readur_test" + export TEST_MODE=true + + # Run migrations + cargo run --bin migrate + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/readur_test + TEST_MODE: true + + - name: Start backend server + run: | + # Build and start the backend in test mode + cargo build --release + + # Start server in background + DATABASE_URL="postgresql://postgres:postgres@localhost:5432/readur_test" \ + TEST_MODE=true \ + ROCKET_PORT=8000 \ + ./target/release/readur & + + # Wait for server to be ready + timeout 60 bash -c 'until curl -f http://localhost:8000/health; do sleep 2; done' + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/readur_test + TEST_MODE: true + ROCKET_PORT: 8000 + + - name: Start frontend dev server + working-directory: ./frontend + run: | + # Start Vite dev server in background + npm run dev & + + # Wait for frontend to be ready + timeout 60 bash -c 'until curl -f http://localhost:5173; do sleep 2; done' + env: + VITE_API_BASE_URL: http://localhost:8000 + + - name: Run Playwright tests + working-directory: ./frontend + run: npm run test:e2e + env: + CI: true + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: frontend/test-results/ + retention-days: 30 + + - name: Upload test artifacts + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-artifacts + path: | + frontend/test-results/e2e-artifacts/ + frontend/test-results/screenshots/ + retention-days: 30 \ No newline at end of file diff --git a/README-E2E.md b/README-E2E.md new file mode 100644 index 0000000..b9174f1 --- /dev/null +++ b/README-E2E.md @@ -0,0 +1,279 @@ +# Readur E2E Testing Guide + +This guide covers the end-to-end (E2E) testing setup for Readur using Playwright. + +## Overview + +The E2E test suite covers: +- User authentication flows +- Document upload and processing +- Search functionality +- Document management +- Complete user workflows + +## Setup + +### Prerequisites + +- Node.js 18+ and npm +- Rust and Cargo +- PostgreSQL +- Git + +### Installation + +1. **Install Playwright dependencies**: + ```bash + cd frontend + npm install + npx playwright install + ``` + +2. **Set up test database**: + ```bash + # Create test database + createdb readur_e2e_test + + # Add vector extension (if available) + psql -d readur_e2e_test -c "CREATE EXTENSION IF NOT EXISTS vector;" + ``` + +## Running Tests + +### Local Development + +#### Quick Start +Use the provided script for automated setup: +```bash +./scripts/run-e2e-local.sh +``` + +#### Manual Setup +If you prefer manual control: + +1. **Start backend server**: + ```bash + DATABASE_URL="postgresql://postgres:postgres@localhost:5432/readur_e2e_test" \ + TEST_MODE=true \ + ROCKET_PORT=8001 \ + cargo run --release + ``` + +2. **Start frontend dev server**: + ```bash + cd frontend + VITE_API_BASE_URL="http://localhost:8001" \ + npm run dev -- --port 5174 + ``` + +3. **Run tests**: + ```bash + cd frontend + npm run test:e2e + ``` + +### Test Options + +- **Headless mode** (default): `npm run test:e2e` +- **Headed mode** (show browser): `npm run test:e2e:headed` +- **Debug mode**: `npm run test:e2e:debug` +- **UI mode**: `npm run test:e2e:ui` + +### Using the Local Script + +The `run-e2e-local.sh` script provides additional options: + +```bash +# Run tests normally +./scripts/run-e2e-local.sh + +# Run in headed mode +./scripts/run-e2e-local.sh --headed + +# Run in debug mode +./scripts/run-e2e-local.sh --debug + +# Run with Playwright UI +./scripts/run-e2e-local.sh --ui + +# Show help +./scripts/run-e2e-local.sh --help +``` + +## GitHub Actions + +The E2E tests automatically run in GitHub Actions on: +- Push to `master`/`main` branch +- Pull requests to `master`/`main` branch + +The workflow: +1. Sets up PostgreSQL database +2. Builds and starts the backend server +3. Starts the frontend dev server +4. Runs all E2E tests +5. Uploads test reports and artifacts + +## Test Structure + +### Test Files + +- `e2e/auth.spec.ts` - Authentication flows +- `e2e/upload.spec.ts` - Document upload functionality +- `e2e/search.spec.ts` - Search workflows +- `e2e/document-management.spec.ts` - Document management + +### Utilities + +- `e2e/fixtures/auth.ts` - Authentication fixture for logged-in tests +- `e2e/utils/test-helpers.ts` - Common helper functions +- `e2e/utils/test-data.ts` - Test data and configuration + +### Configuration + +- `playwright.config.ts` - Playwright configuration +- `.github/workflows/e2e-tests.yml` - GitHub Actions workflow + +## Test Data + +Tests use sample files from: +- `frontend/test_data/hello_ocr.png` - Sample image for OCR +- `frontend/test_data/multiline.png` - Multi-line text image +- `frontend/test_data/numbers.png` - Numbers image + +Add additional test files to `frontend/test_data/` as needed. + +## Writing Tests + +### Basic Test Structure + +```typescript +import { test, expect } from '@playwright/test'; +import { TestHelpers } from './utils/test-helpers'; + +test.describe('Feature Name', () => { + let helpers: TestHelpers; + + test.beforeEach(async ({ page }) => { + helpers = new TestHelpers(page); + await helpers.navigateToPage('/your-page'); + }); + + test('should do something', async ({ page }) => { + // Your test logic here + await expect(page.locator('[data-testid="element"]')).toBeVisible(); + }); +}); +``` + +### Using Authentication Fixture + +For tests requiring authentication: + +```typescript +import { test, expect } from './fixtures/auth'; + +test.describe('Authenticated Feature', () => { + test('should work when logged in', async ({ authenticatedPage }) => { + // Page is already authenticated + await authenticatedPage.goto('/protected-page'); + }); +}); +``` + +### Best Practices + +1. **Use data-testid attributes** for reliable element selection +2. **Wait for API calls** using `helpers.waitForApiCall()` +3. **Handle loading states** with `helpers.waitForLoadingToComplete()` +4. **Use meaningful test descriptions** that describe user actions +5. **Clean up test data** when necessary +6. **Use timeouts appropriately** from `TIMEOUTS` constants + +## Debugging + +### Local Debugging + +1. **Run with --debug flag**: + ```bash + npm run test:e2e:debug + ``` + +2. **Use Playwright UI**: + ```bash + npm run test:e2e:ui + ``` + +3. **Add debugging code**: + ```typescript + await page.pause(); // Pauses execution + await page.screenshot({ path: 'debug.png' }); // Take screenshot + ``` + +### CI Debugging + +- Check uploaded test artifacts in GitHub Actions +- Review test reports in the workflow summary +- Examine screenshots and videos from failed tests + +## Configuration + +### Environment Variables + +- `PLAYWRIGHT_BASE_URL` - Base URL for tests (default: http://localhost:5173) +- `CI` - Set to true in CI environment +- `TEST_MODE` - Set to true for backend test mode + +### Timeouts + +Configure timeouts in `utils/test-data.ts`: +- `TIMEOUTS.short` (5s) - Quick operations +- `TIMEOUTS.medium` (10s) - Normal operations +- `TIMEOUTS.long` (30s) - Slow operations +- `TIMEOUTS.upload` (60s) - File uploads +- `TIMEOUTS.ocr` (120s) - OCR processing + +## Troubleshooting + +### Common Issues + +1. **Tests timing out**: + - Increase timeouts in configuration + - Check if services are running properly + - Verify database connectivity + +2. **Authentication failures**: + - Ensure test user exists in database + - Check authentication fixture implementation + - Verify API endpoints are correct + +3. **File upload failures**: + - Ensure test files exist in `test_data/` + - Check file permissions + - Verify upload API is working + +4. **Database issues**: + - Ensure PostgreSQL is running + - Check database migrations + - Verify test database exists + +### Getting Help + +1. Check logs in `backend.log` and `frontend.log` +2. Review Playwright documentation +3. Examine existing test implementations +4. Use browser dev tools in headed mode + +## Contributing + +When adding new features: + +1. **Add E2E tests** for new user workflows +2. **Update test data** if needed +3. **Add data-testid attributes** to new UI elements +4. **Update this documentation** if test setup changes + +Ensure tests: +- Are reliable and not flaky +- Test realistic user scenarios +- Have good error messages +- Clean up after themselves \ No newline at end of file diff --git a/docker-compose.test.yml b/docker-compose.test.yml index d388e6e..78b26df 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -104,6 +104,30 @@ services: profiles: - frontend-tests + # Frontend development server for E2E tests + frontend_dev: + image: node:18-alpine + container_name: readur_frontend_dev + working_dir: /app + environment: + NODE_ENV: development + VITE_API_BASE_URL: http://readur_test:8001 + VITE_HOST: 0.0.0.0 + VITE_PORT: 5174 + volumes: + - ./frontend:/app + - /app/node_modules + command: ["sh", "-c", "npm ci && npm run dev -- --host 0.0.0.0 --port 5174"] + ports: + - "5174:5174" + networks: + - readur_test_network + depends_on: + readur_test: + condition: service_healthy + profiles: + - e2e-tests + networks: readur_test_network: name: readur_test_network diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts new file mode 100644 index 0000000..3c429f7 --- /dev/null +++ b/frontend/e2e/auth.spec.ts @@ -0,0 +1,112 @@ +import { test, expect } from '@playwright/test'; +import { TEST_USERS, TIMEOUTS } from './utils/test-data'; +import { TestHelpers } from './utils/test-helpers'; + +test.describe('Authentication', () => { + let helpers: TestHelpers; + + test.beforeEach(async ({ page }) => { + helpers = new TestHelpers(page); + }); + + test('should display login form on initial visit', async ({ page }) => { + await page.goto('/'); + + // Check for login form elements using Material-UI structure + await expect(page.locator('input[name="username"]')).toBeVisible(); + await expect(page.locator('input[name="password"]')).toBeVisible(); + await expect(page.locator('button[type="submit"]')).toBeVisible(); + }); + + test('should login with valid credentials', async ({ page }) => { + await page.goto('/'); + + // Fill login form with demo credentials + await page.fill('input[name="username"]', 'admin'); + await page.fill('input[name="password"]', 'readur2024'); + + // Wait for login API call + const loginResponse = helpers.waitForApiCall('/auth/login'); + + await page.click('button[type="submit"]'); + + // Verify login was successful + await loginResponse; + + // Should redirect to dashboard or main page + await page.waitForURL(/\/dashboard|\//, { timeout: TIMEOUTS.medium }); + + // Verify we're no longer on login page + await expect(page.locator('input[name="username"]')).not.toBeVisible(); + }); + + test('should show error with invalid credentials', async ({ page }) => { + await page.goto('/'); + + await page.fill('input[name="username"]', 'invaliduser'); + await page.fill('input[name="password"]', 'wrongpassword'); + + await page.click('button[type="submit"]'); + + // Should show error message (Material-UI Alert) + await expect(page.locator('.MuiAlert-root, [role="alert"]')).toBeVisible({ timeout: TIMEOUTS.short }); + + // Should remain on login page + await expect(page.locator('input[name="username"]')).toBeVisible(); + }); + + test('should logout successfully', async ({ page }) => { + // First login + await page.goto('/'); + await page.fill('input[name="username"]', 'admin'); + await page.fill('input[name="password"]', 'readur2024'); + await page.click('button[type="submit"]'); + + await page.waitForURL(/\/dashboard|\//, { timeout: TIMEOUTS.medium }); + + // Find and click logout button + const logoutButton = page.locator('button:has-text("Logout"), [data-testid="logout"]'); + if (await logoutButton.isVisible()) { + await logoutButton.click(); + } else { + // Try menu-based logout + await page.click('[data-testid="user-menu"], .user-menu, button:has([data-testid="user-avatar"])'); + await page.click('button:has-text("Logout"), [data-testid="logout"]'); + } + + // Should redirect back to login + await page.waitForURL(/\/login|\//, { timeout: TIMEOUTS.medium }); + await expect(page.locator('input[name="username"]')).toBeVisible(); + }); + + test('should persist session on page reload', async ({ page }) => { + // Login first + await page.goto('/'); + await page.fill('input[name="username"]', 'admin'); + await page.fill('input[name="password"]', 'readur2024'); + await page.click('button[type="submit"]'); + + await page.waitForURL(/\/dashboard|\//, { timeout: TIMEOUTS.medium }); + + // Reload the page + await page.reload(); + + // Should still be logged in + await expect(page.locator('input[name="username"]')).not.toBeVisible(); + }); + + test('should validate required fields', async ({ page }) => { + await page.goto('/'); + + // Try to submit without filling fields + await page.click('button[type="submit"]'); + + // Should show validation errors or prevent submission + const usernameInput = page.locator('input[name="username"]'); + const passwordInput = page.locator('input[name="password"]'); + + // Check for HTML5 validation or custom validation messages + await expect(usernameInput).toBeVisible(); + await expect(passwordInput).toBeVisible(); + }); +}); \ No newline at end of file diff --git a/frontend/e2e/document-management.spec.ts b/frontend/e2e/document-management.spec.ts new file mode 100644 index 0000000..1aa432e --- /dev/null +++ b/frontend/e2e/document-management.spec.ts @@ -0,0 +1,175 @@ +import { test, expect } from './fixtures/auth'; +import { TIMEOUTS } from './utils/test-data'; +import { TestHelpers } from './utils/test-helpers'; + +test.describe('Document Management', () => { + let helpers: TestHelpers; + + test.beforeEach(async ({ authenticatedPage }) => { + helpers = new TestHelpers(authenticatedPage); + await helpers.navigateToPage('/documents'); + }); + + test('should display document list', async ({ authenticatedPage: page }) => { + // Check for document list components + await expect(page.locator('[data-testid="document-list"], .document-list, .documents-grid')).toBeVisible(); + }); + + test('should navigate to document details', async ({ authenticatedPage: page }) => { + // Click on first document if available + const firstDocument = page.locator('[data-testid="document-item"], .document-item, .document-card').first(); + + if (await firstDocument.isVisible()) { + await firstDocument.click(); + + // Should navigate to document details page + await page.waitForURL(/\/documents\/[^\/]+/, { timeout: TIMEOUTS.medium }); + + // Should show document details + await expect(page.locator('[data-testid="document-details"], .document-details')).toBeVisible(); + } + }); + + test('should display document metadata', async ({ authenticatedPage: page }) => { + const firstDocument = page.locator('[data-testid="document-item"], .document-item, .document-card').first(); + + if (await firstDocument.isVisible()) { + await firstDocument.click(); + await page.waitForURL(/\/documents\/[^\/]+/, { timeout: TIMEOUTS.medium }); + + // Should show various metadata fields + await expect(page.locator(':has-text("File size"), :has-text("Upload date"), :has-text("Modified")')).toBeVisible(); + } + }); + + test('should allow document download', async ({ authenticatedPage: page }) => { + const firstDocument = page.locator('[data-testid="document-item"], .document-item, .document-card').first(); + + if (await firstDocument.isVisible()) { + await firstDocument.click(); + await page.waitForURL(/\/documents\/[^\/]+/, { timeout: TIMEOUTS.medium }); + + // Look for download button + const downloadButton = page.locator('[data-testid="download"], button:has-text("Download"), .download-button'); + if (await downloadButton.isVisible()) { + // Set up download listener + const downloadPromise = page.waitForEvent('download'); + + await downloadButton.click(); + + // Verify download started + const download = await downloadPromise; + expect(download.suggestedFilename()).toBeTruthy(); + } + } + }); + + test('should allow document deletion', async ({ authenticatedPage: page }) => { + const firstDocument = page.locator('[data-testid="document-item"], .document-item, .document-card').first(); + + if (await firstDocument.isVisible()) { + await firstDocument.click(); + await page.waitForURL(/\/documents\/[^\/]+/, { timeout: TIMEOUTS.medium }); + + // Look for delete button + const deleteButton = page.locator('[data-testid="delete"], button:has-text("Delete"), .delete-button'); + if (await deleteButton.isVisible()) { + await deleteButton.click(); + + // Should show confirmation dialog + const confirmButton = page.locator('button:has-text("Confirm"), button:has-text("Yes"), [data-testid="confirm-delete"]'); + if (await confirmButton.isVisible()) { + await confirmButton.click(); + + // Should redirect back to documents list + await page.waitForURL(/\/documents$/, { timeout: TIMEOUTS.medium }); + } + } + } + }); + + test('should filter documents by type', async ({ authenticatedPage: page }) => { + // Look for filter controls + const filterDropdown = page.locator('[data-testid="type-filter"], select[name="type"], .type-filter'); + if (await filterDropdown.isVisible()) { + await filterDropdown.selectOption('pdf'); + + await helpers.waitForLoadingToComplete(); + + // Should show only PDF documents + const documentItems = page.locator('[data-testid="document-item"], .document-item'); + if (await documentItems.count() > 0) { + // Check that visible documents are PDFs + await expect(documentItems.first().locator(':has-text(".pdf"), .pdf-icon')).toBeVisible(); + } + } + }); + + test('should sort documents', async ({ authenticatedPage: page }) => { + const sortDropdown = page.locator('[data-testid="sort"], select[name="sort"], .sort-dropdown'); + if (await sortDropdown.isVisible()) { + await sortDropdown.selectOption('date-desc'); + + await helpers.waitForLoadingToComplete(); + + // Documents should be reordered + await expect(page.locator('[data-testid="document-list"], .document-list')).toBeVisible(); + } + }); + + test('should display OCR status', async ({ authenticatedPage: page }) => { + const firstDocument = page.locator('[data-testid="document-item"], .document-item, .document-card').first(); + + if (await firstDocument.isVisible()) { + await firstDocument.click(); + await page.waitForURL(/\/documents\/[^\/]+/, { timeout: TIMEOUTS.medium }); + + // Should show OCR status information + await expect(page.locator(':has-text("OCR"), [data-testid="ocr-status"], .ocr-status')).toBeVisible(); + } + }); + + test('should search within document content', async ({ authenticatedPage: page }) => { + const firstDocument = page.locator('[data-testid="document-item"], .document-item, .document-card').first(); + + if (await firstDocument.isVisible()) { + await firstDocument.click(); + await page.waitForURL(/\/documents\/[^\/]+/, { timeout: TIMEOUTS.medium }); + + // Look for in-document search + const searchInput = page.locator('[data-testid="document-search"], input[placeholder*="search" i]'); + if (await searchInput.isVisible()) { + await searchInput.fill('test'); + + // Should highlight matches in document content + await expect(page.locator('.highlight, mark, .search-highlight')).toBeVisible({ + timeout: TIMEOUTS.short + }); + } + } + }); + + test('should paginate document list', async ({ authenticatedPage: page }) => { + // Look for pagination controls + const nextPageButton = page.locator('[data-testid="next-page"], button:has-text("Next"), .pagination-next'); + if (await nextPageButton.isVisible()) { + const initialDocuments = await page.locator('[data-testid="document-item"], .document-item').count(); + + await nextPageButton.click(); + + await helpers.waitForLoadingToComplete(); + + // Should load different documents + const newDocuments = await page.locator('[data-testid="document-item"], .document-item').count(); + expect(newDocuments).toBeGreaterThan(0); + } + }); + + test('should show document thumbnails', async ({ authenticatedPage: page }) => { + // Check for document thumbnails in list view + const documentThumbnails = page.locator('[data-testid="document-thumbnail"], .thumbnail, .document-preview'); + if (await documentThumbnails.first().isVisible()) { + await expect(documentThumbnails.first()).toBeVisible(); + } + }); +}); \ No newline at end of file diff --git a/frontend/e2e/fixtures/auth.ts b/frontend/e2e/fixtures/auth.ts new file mode 100644 index 0000000..2059106 --- /dev/null +++ b/frontend/e2e/fixtures/auth.ts @@ -0,0 +1,29 @@ +import { test as base, expect } from '@playwright/test'; +import type { Page } from '@playwright/test'; + +export interface AuthFixture { + authenticatedPage: Page; +} + +export const test = base.extend({ + authenticatedPage: async ({ page }, use) => { + await page.goto('/'); + + // Check if already logged in by looking for username input (login page) + const usernameInput = await page.locator('input[name="username"]').isVisible().catch(() => false); + + if (usernameInput) { + // Fill login form with demo credentials + await page.fill('input[name="username"]', 'admin'); + await page.fill('input[name="password"]', 'readur2024'); + await page.click('button[type="submit"]'); + + // Wait for navigation away from login page + await page.waitForURL(/\/dashboard|\//, { timeout: 10000 }); + } + + await use(page); + }, +}); + +export { expect }; \ No newline at end of file diff --git a/frontend/e2e/search.spec.ts b/frontend/e2e/search.spec.ts new file mode 100644 index 0000000..a308e81 --- /dev/null +++ b/frontend/e2e/search.spec.ts @@ -0,0 +1,235 @@ +import { test, expect } from './fixtures/auth'; +import { SEARCH_QUERIES, TIMEOUTS, API_ENDPOINTS } from './utils/test-data'; +import { TestHelpers } from './utils/test-helpers'; + +test.describe('Search Functionality', () => { + let helpers: TestHelpers; + + test.beforeEach(async ({ authenticatedPage }) => { + helpers = new TestHelpers(authenticatedPage); + await helpers.navigateToPage('/search'); + }); + + test('should display search interface', async ({ authenticatedPage: page }) => { + // Check for search components + await expect(page.locator('input[type="search"], input[placeholder*="search" i], [data-testid="search-input"]')).toBeVisible(); + await expect(page.locator('button:has-text("Search"), [data-testid="search-button"]')).toBeVisible(); + }); + + test('should perform basic search', async ({ authenticatedPage: page }) => { + const searchInput = page.locator('input[type="search"], input[placeholder*="search" i], [data-testid="search-input"]').first(); + + // Enter search query + await searchInput.fill(SEARCH_QUERIES.simple); + + // Wait for search API call + const searchResponse = helpers.waitForApiCall(API_ENDPOINTS.search); + + // Press Enter or click search button + await searchInput.press('Enter'); + + // Verify search was performed + await searchResponse; + + // Should show search results + await expect(page.locator('[data-testid="search-results"], .search-results')).toBeVisible({ + timeout: TIMEOUTS.medium + }); + }); + + test('should show search suggestions', async ({ authenticatedPage: page }) => { + const searchInput = page.locator('input[type="search"], input[placeholder*="search" i], [data-testid="search-input"]').first(); + + // Start typing to trigger suggestions + await searchInput.type('test', { delay: 100 }); + + // Should show suggestion dropdown + await expect(page.locator('[data-testid="search-suggestions"], .suggestions, .autocomplete')).toBeVisible({ + timeout: TIMEOUTS.short + }); + }); + + test('should filter search results', async ({ authenticatedPage: page }) => { + const searchInput = page.locator('input[type="search"], input[placeholder*="search" i], [data-testid="search-input"]').first(); + + // Perform initial search + await searchInput.fill(SEARCH_QUERIES.simple); + await searchInput.press('Enter'); + + await helpers.waitForLoadingToComplete(); + + // Apply filters + const filterButton = page.locator('[data-testid="filters"], button:has-text("Filter"), .filter-toggle'); + if (await filterButton.isVisible()) { + await filterButton.click(); + + // Select document type filter + const pdfFilter = page.locator('input[type="checkbox"][value="pdf"], label:has-text("PDF")'); + if (await pdfFilter.isVisible()) { + await pdfFilter.check(); + + // Should update search results + await helpers.waitForApiCall(API_ENDPOINTS.search); + } + } + }); + + test('should perform advanced search', async ({ authenticatedPage: page }) => { + // Look for advanced search toggle + const advancedToggle = page.locator('[data-testid="advanced-search"], button:has-text("Advanced"), .advanced-toggle'); + + if (await advancedToggle.isVisible()) { + await advancedToggle.click(); + + // Fill advanced search fields + await page.fill('[data-testid="title-search"], input[name="title"]', SEARCH_QUERIES.advanced.title); + await page.fill('[data-testid="content-search"], input[name="content"]', SEARCH_QUERIES.advanced.content); + + // Set date filters if available + const dateFromInput = page.locator('[data-testid="date-from"], input[name="dateFrom"], input[type="date"]').first(); + if (await dateFromInput.isVisible()) { + await dateFromInput.fill(SEARCH_QUERIES.advanced.dateFrom); + } + + // Perform advanced search + await page.click('button:has-text("Search"), [data-testid="search-button"]'); + + // Verify search results + await expect(page.locator('[data-testid="search-results"], .search-results')).toBeVisible({ + timeout: TIMEOUTS.medium + }); + } + }); + + test('should handle empty search results', async ({ authenticatedPage: page }) => { + const searchInput = page.locator('input[type="search"], input[placeholder*="search" i], [data-testid="search-input"]').first(); + + // Search for something that doesn't exist + await searchInput.fill(SEARCH_QUERIES.noResults); + await searchInput.press('Enter'); + + await helpers.waitForLoadingToComplete(); + + // Should show no results message + await expect(page.locator(':has-text("No results"), :has-text("not found"), [data-testid="no-results"]')).toBeVisible({ + timeout: TIMEOUTS.medium + }); + }); + + test('should navigate to document from search results', async ({ authenticatedPage: page }) => { + const searchInput = page.locator('input[type="search"], input[placeholder*="search" i], [data-testid="search-input"]').first(); + + // Perform search + await searchInput.fill(SEARCH_QUERIES.simple); + await searchInput.press('Enter'); + + await helpers.waitForLoadingToComplete(); + + // Click on first search result + const firstResult = page.locator('[data-testid="search-results"] > *, .search-result').first(); + if (await firstResult.isVisible()) { + await firstResult.click(); + + // Should navigate to document details + await page.waitForURL(/\/documents\/[^\/]+/, { timeout: TIMEOUTS.medium }); + } + }); + + test('should preserve search state on page reload', async ({ authenticatedPage: page }) => { + const searchInput = page.locator('input[type="search"], input[placeholder*="search" i], [data-testid="search-input"]').first(); + + // Perform search + await searchInput.fill(SEARCH_QUERIES.simple); + await searchInput.press('Enter'); + + await helpers.waitForLoadingToComplete(); + + // Reload page + await page.reload(); + + // Should preserve search query and results + await expect(searchInput).toHaveValue(SEARCH_QUERIES.simple); + await expect(page.locator('[data-testid="search-results"], .search-results')).toBeVisible({ + timeout: TIMEOUTS.medium + }); + }); + + test('should sort search results', async ({ authenticatedPage: page }) => { + const searchInput = page.locator('input[type="search"], input[placeholder*="search" i], [data-testid="search-input"]').first(); + + // Perform search + await searchInput.fill(SEARCH_QUERIES.simple); + await searchInput.press('Enter'); + + await helpers.waitForLoadingToComplete(); + + // Look for sort options + const sortDropdown = page.locator('[data-testid="sort"], select[name="sort"], .sort-selector'); + if (await sortDropdown.isVisible()) { + await sortDropdown.selectOption('date-desc'); + + // Should update search results order + await helpers.waitForApiCall(API_ENDPOINTS.search); + } + }); + + test('should paginate search results', async ({ authenticatedPage: page }) => { + const searchInput = page.locator('input[type="search"], input[placeholder*="search" i], [data-testid="search-input"]').first(); + + // Perform search + await searchInput.fill(SEARCH_QUERIES.simple); + await searchInput.press('Enter'); + + await helpers.waitForLoadingToComplete(); + + // Look for pagination + const nextPageButton = page.locator('[data-testid="next-page"], button:has-text("Next"), .pagination button:last-child'); + if (await nextPageButton.isVisible()) { + await nextPageButton.click(); + + // Should load next page of results + await helpers.waitForApiCall(API_ENDPOINTS.search); + await expect(page.locator('[data-testid="search-results"], .search-results')).toBeVisible({ + timeout: TIMEOUTS.medium + }); + } + }); + + test('should highlight search terms in results', async ({ authenticatedPage: page }) => { + const searchInput = page.locator('input[type="search"], input[placeholder*="search" i], [data-testid="search-input"]').first(); + + // Perform search with specific term + await searchInput.fill('test'); + await searchInput.press('Enter'); + + await helpers.waitForLoadingToComplete(); + + // Should highlight search terms in results + await expect(page.locator('.highlight, mark, .search-highlight')).toBeVisible({ + timeout: TIMEOUTS.medium + }); + }); + + test('should clear search results', async ({ authenticatedPage: page }) => { + const searchInput = page.locator('input[type="search"], input[placeholder*="search" i], [data-testid="search-input"]').first(); + + // Perform search + await searchInput.fill(SEARCH_QUERIES.simple); + await searchInput.press('Enter'); + + await helpers.waitForLoadingToComplete(); + + // Clear search + const clearButton = page.locator('[data-testid="clear-search"], button:has-text("Clear"), .clear-button'); + if (await clearButton.isVisible()) { + await clearButton.click(); + } else { + // Clear by emptying input + await searchInput.clear(); + await searchInput.press('Enter'); + } + + // Should clear results + await expect(page.locator('[data-testid="search-results"], .search-results')).not.toBeVisible(); + }); +}); \ No newline at end of file diff --git a/frontend/e2e/settings.spec.ts b/frontend/e2e/settings.spec.ts new file mode 100644 index 0000000..45df490 --- /dev/null +++ b/frontend/e2e/settings.spec.ts @@ -0,0 +1,283 @@ +import { test, expect } from './fixtures/auth'; +import { TIMEOUTS } from './utils/test-data'; +import { TestHelpers } from './utils/test-helpers'; + +test.describe('Settings Management', () => { + let helpers: TestHelpers; + + test.beforeEach(async ({ authenticatedPage }) => { + helpers = new TestHelpers(authenticatedPage); + await helpers.navigateToPage('/settings'); + }); + + test('should display settings interface', async ({ authenticatedPage: page }) => { + // Check for settings page components + await expect(page.locator('[data-testid="settings-container"], .settings-page, .settings-form')).toBeVisible(); + }); + + test('should update OCR settings', async ({ authenticatedPage: page }) => { + // Look for OCR settings section + const ocrSection = page.locator('[data-testid="ocr-settings"], .ocr-section, .settings-section:has-text("OCR")'); + if (await ocrSection.isVisible()) { + // Change OCR language + const languageSelect = page.locator('select[name="ocrLanguage"], [data-testid="ocr-language"]'); + if (await languageSelect.isVisible()) { + await languageSelect.selectOption('spa'); // Spanish + + const saveResponse = helpers.waitForApiCall('/api/settings'); + + // Save settings + await page.click('button[type="submit"], button:has-text("Save"), [data-testid="save-settings"]'); + + await saveResponse; + await helpers.waitForToast(); + } + } + }); + + test('should update watch folder settings', async ({ authenticatedPage: page }) => { + // Navigate to watch folder section if it's a separate page + const watchFolderNav = page.locator('a[href="/watch-folder"], [data-testid="watch-folder-nav"]'); + if (await watchFolderNav.isVisible()) { + await watchFolderNav.click(); + await helpers.waitForLoadingToComplete(); + } + + // Look for watch folder settings + const watchSection = page.locator('[data-testid="watch-settings"], .watch-folder-section, .settings-section:has-text("Watch")'); + if (await watchSection.isVisible()) { + // Enable watch folder + const enableWatch = page.locator('input[type="checkbox"][name="enableWatch"], [data-testid="enable-watch"]'); + if (await enableWatch.isVisible()) { + await enableWatch.check(); + + // Set watch folder path + const pathInput = page.locator('input[name="watchPath"], [data-testid="watch-path"]'); + if (await pathInput.isVisible()) { + await pathInput.fill('/tmp/watch-folder'); + } + + const saveResponse = helpers.waitForApiCall('/api/settings'); + + await page.click('button[type="submit"], button:has-text("Save")'); + + await saveResponse; + await helpers.waitForToast(); + } + } + }); + + test('should update notification settings', async ({ authenticatedPage: page }) => { + const notificationSection = page.locator('[data-testid="notification-settings"], .notification-section, .settings-section:has-text("Notification")'); + if (await notificationSection.isVisible()) { + // Enable notifications + const enableNotifications = page.locator('input[type="checkbox"][name="enableNotifications"], [data-testid="enable-notifications"]'); + if (await enableNotifications.isVisible()) { + await enableNotifications.check(); + + // Configure notification types + const ocrNotifications = page.locator('input[type="checkbox"][name="ocrNotifications"], [data-testid="ocr-notifications"]'); + if (await ocrNotifications.isVisible()) { + await ocrNotifications.check(); + } + + const syncNotifications = page.locator('input[type="checkbox"][name="syncNotifications"], [data-testid="sync-notifications"]'); + if (await syncNotifications.isVisible()) { + await syncNotifications.check(); + } + + const saveResponse = helpers.waitForApiCall('/api/settings'); + + await page.click('button[type="submit"], button:has-text("Save")'); + + await saveResponse; + await helpers.waitForToast(); + } + } + }); + + test('should update search settings', async ({ authenticatedPage: page }) => { + const searchSection = page.locator('[data-testid="search-settings"], .search-section, .settings-section:has-text("Search")'); + if (await searchSection.isVisible()) { + // Configure search results per page + const resultsPerPage = page.locator('select[name="resultsPerPage"], [data-testid="results-per-page"]'); + if (await resultsPerPage.isVisible()) { + await resultsPerPage.selectOption('25'); + } + + // Enable/disable features + const enhancedSearch = page.locator('input[type="checkbox"][name="enhancedSearch"], [data-testid="enhanced-search"]'); + if (await enhancedSearch.isVisible()) { + await enhancedSearch.check(); + } + + const saveResponse = helpers.waitForApiCall('/api/settings'); + + await page.click('button[type="submit"], button:has-text("Save")'); + + await saveResponse; + await helpers.waitForToast(); + } + }); + + test('should reset settings to defaults', async ({ authenticatedPage: page }) => { + // Look for reset button + const resetButton = page.locator('button:has-text("Reset"), button:has-text("Default"), [data-testid="reset-settings"]'); + if (await resetButton.isVisible()) { + await resetButton.click(); + + // Should show confirmation + const confirmButton = page.locator('button:has-text("Confirm"), button:has-text("Yes"), [data-testid="confirm-reset"]'); + if (await confirmButton.isVisible()) { + const resetResponse = helpers.waitForApiCall('/api/settings/reset'); + + await confirmButton.click(); + + await resetResponse; + await helpers.waitForToast(); + + // Page should reload with default values + await helpers.waitForLoadingToComplete(); + } + } + }); + + test('should validate settings before saving', async ({ authenticatedPage: page }) => { + // Try to set invalid values + const pathInput = page.locator('input[name="watchPath"], [data-testid="watch-path"]'); + if (await pathInput.isVisible()) { + // Enter invalid path + await pathInput.fill('invalid/path/with/spaces and special chars!'); + + await page.click('button[type="submit"], button:has-text("Save")'); + + // Should show validation error + await helpers.waitForToast(); + + // Should not save invalid settings + expect(await pathInput.inputValue()).toBe('invalid/path/with/spaces and special chars!'); + } + }); + + test('should export settings', async ({ authenticatedPage: page }) => { + const exportButton = page.locator('button:has-text("Export"), [data-testid="export-settings"]'); + if (await exportButton.isVisible()) { + // Set up download listener + const downloadPromise = page.waitForEvent('download'); + + await exportButton.click(); + + // Verify download started + const download = await downloadPromise; + expect(download.suggestedFilename()).toContain('settings'); + } + }); + + test('should import settings', async ({ authenticatedPage: page }) => { + const importButton = page.locator('button:has-text("Import"), [data-testid="import-settings"]'); + if (await importButton.isVisible()) { + // Look for file input + const fileInput = page.locator('input[type="file"], [data-testid="settings-file"]'); + if (await fileInput.isVisible()) { + // Create a mock settings file + const settingsContent = JSON.stringify({ + ocrLanguage: 'eng', + enableNotifications: true, + resultsPerPage: 20 + }); + + await fileInput.setInputFiles({ + name: 'settings.json', + mimeType: 'application/json', + buffer: Buffer.from(settingsContent) + }); + + const importResponse = helpers.waitForApiCall('/api/settings/import'); + + await importButton.click(); + + await importResponse; + await helpers.waitForToast(); + } + } + }); + + test('should display current system status', async ({ authenticatedPage: page }) => { + // Look for system status section + const statusSection = page.locator('[data-testid="system-status"], .status-section, .settings-section:has-text("Status")'); + if (await statusSection.isVisible()) { + // Should show various system metrics + await expect(statusSection.locator(':has-text("Database"), :has-text("Storage"), :has-text("OCR")')).toBeVisible(); + } + }); + + test('should test OCR functionality', async ({ authenticatedPage: page }) => { + const ocrSection = page.locator('[data-testid="ocr-settings"], .ocr-section'); + if (await ocrSection.isVisible()) { + const testButton = page.locator('button:has-text("Test OCR"), [data-testid="test-ocr"]'); + if (await testButton.isVisible()) { + const testResponse = helpers.waitForApiCall('/api/ocr/test'); + + await testButton.click(); + + await testResponse; + + // Should show test result + await helpers.waitForToast(); + } + } + }); + + test('should clear cache', async ({ authenticatedPage: page }) => { + const clearCacheButton = page.locator('button:has-text("Clear Cache"), [data-testid="clear-cache"]'); + if (await clearCacheButton.isVisible()) { + const clearResponse = helpers.waitForApiCall('/api/cache/clear'); + + await clearCacheButton.click(); + + await clearResponse; + await helpers.waitForToast(); + } + }); + + test('should update user profile', async ({ authenticatedPage: page }) => { + // Look for user profile section + const profileSection = page.locator('[data-testid="profile-settings"], .profile-section, .settings-section:has-text("Profile")'); + if (await profileSection.isVisible()) { + // Update email + const emailInput = page.locator('input[name="email"], [data-testid="user-email"]'); + if (await emailInput.isVisible()) { + await emailInput.fill('newemail@example.com'); + } + + // Update name + const nameInput = page.locator('input[name="name"], [data-testid="user-name"]'); + if (await nameInput.isVisible()) { + await nameInput.fill('Updated Name'); + } + + const saveResponse = helpers.waitForApiCall('/api/users/profile'); + + await page.click('button[type="submit"], button:has-text("Save")'); + + await saveResponse; + await helpers.waitForToast(); + } + }); + + test('should change password', async ({ authenticatedPage: page }) => { + const passwordSection = page.locator('[data-testid="password-settings"], .password-section, .settings-section:has-text("Password")'); + if (await passwordSection.isVisible()) { + await page.fill('input[name="currentPassword"], [data-testid="current-password"]', 'currentpass'); + await page.fill('input[name="newPassword"], [data-testid="new-password"]', 'newpassword123'); + await page.fill('input[name="confirmPassword"], [data-testid="confirm-password"]', 'newpassword123'); + + const changeResponse = helpers.waitForApiCall('/api/users/password'); + + await page.click('button[type="submit"], button:has-text("Change Password")'); + + await changeResponse; + await helpers.waitForToast(); + } + }); +}); \ No newline at end of file diff --git a/frontend/e2e/sources.spec.ts b/frontend/e2e/sources.spec.ts new file mode 100644 index 0000000..e384437 --- /dev/null +++ b/frontend/e2e/sources.spec.ts @@ -0,0 +1,353 @@ +import { test, expect } from './fixtures/auth'; +import { TIMEOUTS, API_ENDPOINTS } from './utils/test-data'; +import { TestHelpers } from './utils/test-helpers'; + +test.describe('Source Management', () => { + let helpers: TestHelpers; + + test.beforeEach(async ({ authenticatedPage }) => { + helpers = new TestHelpers(authenticatedPage); + await helpers.navigateToPage('/sources'); + }); + + test('should display sources interface', async ({ authenticatedPage: page }) => { + // Check for sources page components + await expect(page.locator('[data-testid="sources-list"], .sources-list, .sources-container')).toBeVisible(); + await expect(page.locator('button:has-text("Add Source"), [data-testid="add-source"]')).toBeVisible(); + }); + + test('should create a new local folder source', async ({ authenticatedPage: page }) => { + // Click add source button + await page.click('button:has-text("Add Source"), [data-testid="add-source"]'); + + // Should show add source form/modal + await expect(page.locator('[data-testid="add-source-form"], .add-source-modal, .source-form')).toBeVisible(); + + // Fill in source details + await page.fill('input[name="name"], [data-testid="source-name"]', 'Test Local Folder'); + + // Select source type + const typeSelector = page.locator('select[name="type"], [data-testid="source-type"]'); + if (await typeSelector.isVisible()) { + await typeSelector.selectOption('local_folder'); + } + + // Fill in folder path + await page.fill('input[name="path"], [data-testid="folder-path"]', '/tmp/test-folder'); + + // Wait for source creation API call + const createResponse = helpers.waitForApiCall('/api/sources', TIMEOUTS.medium); + + // Submit form + await page.click('button[type="submit"], button:has-text("Create"), [data-testid="create-source"]'); + + // Verify source was created + await createResponse; + + // Should show success message + await helpers.waitForToast(); + + // Should appear in sources list + await expect(page.locator(':has-text("Test Local Folder")')).toBeVisible({ timeout: TIMEOUTS.medium }); + }); + + test('should create a new WebDAV source', async ({ authenticatedPage: page }) => { + await page.click('button:has-text("Add Source"), [data-testid="add-source"]'); + + await expect(page.locator('[data-testid="add-source-form"], .add-source-modal, .source-form')).toBeVisible(); + + // Fill in WebDAV source details + await page.fill('input[name="name"], [data-testid="source-name"]', 'Test WebDAV'); + + const typeSelector = page.locator('select[name="type"], [data-testid="source-type"]'); + if (await typeSelector.isVisible()) { + await typeSelector.selectOption('webdav'); + } + + // Fill WebDAV specific fields + await page.fill('input[name="url"], [data-testid="webdav-url"]', 'https://example.com/webdav'); + await page.fill('input[name="username"], [data-testid="webdav-username"]', 'testuser'); + await page.fill('input[name="password"], [data-testid="webdav-password"]', 'testpass'); + + const createResponse = helpers.waitForApiCall('/api/sources'); + + await page.click('button[type="submit"], button:has-text("Create"), [data-testid="create-source"]'); + + await createResponse; + await helpers.waitForToast(); + + await expect(page.locator(':has-text("Test WebDAV")')).toBeVisible({ timeout: TIMEOUTS.medium }); + }); + + test('should create a new S3 source', async ({ authenticatedPage: page }) => { + await page.click('button:has-text("Add Source"), [data-testid="add-source"]'); + + await expect(page.locator('[data-testid="add-source-form"], .add-source-modal, .source-form')).toBeVisible(); + + // Fill in S3 source details + await page.fill('input[name="name"], [data-testid="source-name"]', 'Test S3 Bucket'); + + const typeSelector = page.locator('select[name="type"], [data-testid="source-type"]'); + if (await typeSelector.isVisible()) { + await typeSelector.selectOption('s3'); + } + + // Fill S3 specific fields + await page.fill('input[name="bucket"], [data-testid="s3-bucket"]', 'test-bucket'); + await page.fill('input[name="region"], [data-testid="s3-region"]', 'us-east-1'); + await page.fill('input[name="accessKey"], [data-testid="s3-access-key"]', 'AKIATEST'); + await page.fill('input[name="secretKey"], [data-testid="s3-secret-key"]', 'secretkey123'); + + const createResponse = helpers.waitForApiCall('/api/sources'); + + await page.click('button[type="submit"], button:has-text("Create"), [data-testid="create-source"]'); + + await createResponse; + await helpers.waitForToast(); + + await expect(page.locator(':has-text("Test S3 Bucket")')).toBeVisible({ timeout: TIMEOUTS.medium }); + }); + + test('should edit existing source', async ({ authenticatedPage: page }) => { + // Look for existing source to edit + const firstSource = page.locator('[data-testid="source-item"], .source-item, .source-card').first(); + + if (await firstSource.isVisible()) { + // Click edit button + const editButton = firstSource.locator('button:has-text("Edit"), [data-testid="edit-source"], .edit-button'); + if (await editButton.isVisible()) { + await editButton.click(); + + // Should show edit form + await expect(page.locator('[data-testid="edit-source-form"], .edit-source-modal, .source-form')).toBeVisible(); + + // Modify source name + const nameInput = page.locator('input[name="name"], [data-testid="source-name"]'); + await nameInput.fill('Updated Source Name'); + + const updateResponse = helpers.waitForApiCall('/api/sources'); + + await page.click('button[type="submit"], button:has-text("Save"), [data-testid="save-source"]'); + + await updateResponse; + await helpers.waitForToast(); + + // Should show updated name + await expect(page.locator(':has-text("Updated Source Name")')).toBeVisible({ timeout: TIMEOUTS.medium }); + } + } + }); + + test('should delete source', async ({ authenticatedPage: page }) => { + const firstSource = page.locator('[data-testid="source-item"], .source-item, .source-card').first(); + + if (await firstSource.isVisible()) { + const sourceName = await firstSource.locator('[data-testid="source-name"], .source-name, h3, h4').textContent(); + + // Click delete button + const deleteButton = firstSource.locator('button:has-text("Delete"), [data-testid="delete-source"], .delete-button'); + if (await deleteButton.isVisible()) { + await deleteButton.click(); + + // Should show confirmation dialog + const confirmButton = page.locator('button:has-text("Confirm"), button:has-text("Yes"), [data-testid="confirm-delete"]'); + if (await confirmButton.isVisible()) { + const deleteResponse = helpers.waitForApiCall('/api/sources'); + + await confirmButton.click(); + + await deleteResponse; + await helpers.waitForToast(); + + // Source should be removed from list + if (sourceName) { + await expect(page.locator(`:has-text("${sourceName}")`)).not.toBeVisible(); + } + } + } + } + }); + + test('should start source sync', async ({ authenticatedPage: page }) => { + const firstSource = page.locator('[data-testid="source-item"], .source-item, .source-card').first(); + + if (await firstSource.isVisible()) { + // Look for sync button + const syncButton = firstSource.locator('button:has-text("Sync"), [data-testid="sync-source"], .sync-button'); + if (await syncButton.isVisible()) { + const syncResponse = helpers.waitForApiCall('/api/sources/*/sync'); + + await syncButton.click(); + + await syncResponse; + + // Should show sync status + await expect(firstSource.locator(':has-text("Syncing"), [data-testid="sync-status"], .sync-status')).toBeVisible({ + timeout: TIMEOUTS.medium + }); + } + } + }); + + test('should stop source sync', async ({ authenticatedPage: page }) => { + const firstSource = page.locator('[data-testid="source-item"], .source-item, .source-card').first(); + + if (await firstSource.isVisible()) { + // First start sync if not running + const syncButton = firstSource.locator('button:has-text("Sync"), [data-testid="sync-source"]'); + if (await syncButton.isVisible()) { + await syncButton.click(); + await helpers.waitForLoadingToComplete(); + } + + // Look for stop button + const stopButton = firstSource.locator('button:has-text("Stop"), [data-testid="stop-sync"], .stop-button'); + if (await stopButton.isVisible()) { + const stopResponse = helpers.waitForApiCall('/api/sources/*/stop'); + + await stopButton.click(); + + await stopResponse; + + // Should show stopped status + await expect(firstSource.locator(':has-text("Stopped"), :has-text("Idle")')).toBeVisible({ + timeout: TIMEOUTS.medium + }); + } + } + }); + + test('should display source status and statistics', async ({ authenticatedPage: page }) => { + const firstSource = page.locator('[data-testid="source-item"], .source-item, .source-card').first(); + + if (await firstSource.isVisible()) { + // Should show source status information + await expect(firstSource.locator('[data-testid="source-status"], .source-status, .status')).toBeVisible(); + + // Click to view details + await firstSource.click(); + + // Should show detailed statistics if available + const statsSection = page.locator('[data-testid="source-stats"], .source-statistics, .stats-section'); + if (await statsSection.isVisible()) { + await expect(statsSection.locator(':has-text("Documents"), :has-text("Files"), :has-text("Size")')).toBeVisible(); + } + } + }); + + test('should test source connection', async ({ authenticatedPage: page }) => { + await page.click('button:has-text("Add Source"), [data-testid="add-source"]'); + + await expect(page.locator('[data-testid="add-source-form"], .add-source-modal')).toBeVisible(); + + // Fill in source details + await page.fill('input[name="name"], [data-testid="source-name"]', 'Test Connection'); + + const typeSelector = page.locator('select[name="type"], [data-testid="source-type"]'); + if (await typeSelector.isVisible()) { + await typeSelector.selectOption('webdav'); + } + + await page.fill('input[name="url"], [data-testid="webdav-url"]', 'https://example.com/webdav'); + await page.fill('input[name="username"], [data-testid="webdav-username"]', 'testuser'); + await page.fill('input[name="password"], [data-testid="webdav-password"]', 'testpass'); + + // Look for test connection button + const testButton = page.locator('button:has-text("Test"), [data-testid="test-connection"], .test-button'); + if (await testButton.isVisible()) { + const testResponse = helpers.waitForApiCall('/api/sources/test'); + + await testButton.click(); + + await testResponse; + + // Should show test result + await helpers.waitForToast(); + } + }); + + test('should filter sources by type', async ({ authenticatedPage: page }) => { + // Look for filter dropdown + const filterDropdown = page.locator('[data-testid="source-filter"], select[name="filter"], .source-filter'); + if (await filterDropdown.isVisible()) { + await filterDropdown.selectOption('webdav'); + + await helpers.waitForLoadingToComplete(); + + // Should show only WebDAV sources + const sourceItems = page.locator('[data-testid="source-item"], .source-item'); + if (await sourceItems.count() > 0) { + await expect(sourceItems.first().locator(':has-text("WebDAV"), .webdav-icon')).toBeVisible(); + } + } + }); + + test('should display sync history', async ({ authenticatedPage: page }) => { + const firstSource = page.locator('[data-testid="source-item"], .source-item, .source-card').first(); + + if (await firstSource.isVisible()) { + await firstSource.click(); + + // Look for sync history section + const historySection = page.locator('[data-testid="sync-history"], .sync-history, .history-section'); + if (await historySection.isVisible()) { + // Should show sync runs + await expect(historySection.locator('[data-testid="sync-run"], .sync-run, .history-item')).toBeVisible(); + } + } + }); + + test('should validate required fields in source creation', async ({ authenticatedPage: page }) => { + await page.click('button:has-text("Add Source"), [data-testid="add-source"]'); + + await expect(page.locator('[data-testid="add-source-form"], .add-source-modal')).toBeVisible(); + + // Try to submit without filling required fields + await page.click('button[type="submit"], button:has-text("Create"), [data-testid="create-source"]'); + + // Should show validation errors + const nameInput = page.locator('input[name="name"], [data-testid="source-name"]'); + await expect(nameInput).toBeVisible(); + + // Check for validation messages + const validationMessages = page.locator('.error, .validation-error, [data-testid="validation-error"]'); + if (await validationMessages.count() > 0) { + await expect(validationMessages.first()).toBeVisible(); + } + }); + + test('should schedule automatic sync', async ({ authenticatedPage: page }) => { + const firstSource = page.locator('[data-testid="source-item"], .source-item, .source-card').first(); + + if (await firstSource.isVisible()) { + // Click settings or edit button + const settingsButton = firstSource.locator('button:has-text("Settings"), button:has-text("Edit"), [data-testid="source-settings"]'); + if (await settingsButton.isVisible()) { + await settingsButton.click(); + + // Look for scheduling options + const scheduleSection = page.locator('[data-testid="schedule-section"], .schedule-options'); + if (await scheduleSection.isVisible()) { + // Enable automatic sync + const enableSchedule = page.locator('input[type="checkbox"][name="enableSchedule"], [data-testid="enable-schedule"]'); + if (await enableSchedule.isVisible()) { + await enableSchedule.check(); + + // Set sync interval + const intervalSelect = page.locator('select[name="interval"], [data-testid="sync-interval"]'); + if (await intervalSelect.isVisible()) { + await intervalSelect.selectOption('daily'); + } + + const saveResponse = helpers.waitForApiCall('/api/sources'); + + await page.click('button[type="submit"], button:has-text("Save")'); + + await saveResponse; + await helpers.waitForToast(); + } + } + } + } + }); +}); \ No newline at end of file diff --git a/frontend/e2e/upload.spec.ts b/frontend/e2e/upload.spec.ts new file mode 100644 index 0000000..e035efb --- /dev/null +++ b/frontend/e2e/upload.spec.ts @@ -0,0 +1,177 @@ +import { test, expect } from './fixtures/auth'; +import { TEST_FILES, TIMEOUTS, API_ENDPOINTS } from './utils/test-data'; +import { TestHelpers } from './utils/test-helpers'; + +test.describe('Document Upload', () => { + let helpers: TestHelpers; + + test.beforeEach(async ({ authenticatedPage }) => { + helpers = new TestHelpers(authenticatedPage); + await helpers.navigateToPage('/upload'); + }); + + test('should display upload interface', async ({ authenticatedPage: page }) => { + // Check for upload components + await expect(page.locator('input[type="file"], [data-testid="file-upload"]')).toBeVisible(); + await expect(page.locator('button:has-text("Upload"), [data-testid="upload-button"]')).toBeVisible(); + }); + + test('should upload single document successfully', async ({ authenticatedPage: page }) => { + // Find file input - try multiple selectors + const fileInput = page.locator('input[type="file"]').first(); + + // Upload a test file + await fileInput.setInputFiles(TEST_FILES.image); + + // Wait for upload API call + const uploadResponse = helpers.waitForApiCall(API_ENDPOINTS.upload, TIMEOUTS.upload); + + // Click upload button if present + const uploadButton = page.locator('button:has-text("Upload"), [data-testid="upload-button"]'); + if (await uploadButton.isVisible()) { + await uploadButton.click(); + } + + // Verify upload was successful + await uploadResponse; + + // Check for success message + await helpers.waitForToast(); + + // Should show uploaded document in list + await expect(page.locator('[data-testid="uploaded-files"], .uploaded-file')).toBeVisible({ timeout: TIMEOUTS.medium }); + }); + + test('should upload multiple documents', async ({ authenticatedPage: page }) => { + const fileInput = page.locator('input[type="file"]').first(); + + // Upload multiple files + await fileInput.setInputFiles([TEST_FILES.image, TEST_FILES.multiline]); + + const uploadButton = page.locator('button:has-text("Upload"), [data-testid="upload-button"]'); + if (await uploadButton.isVisible()) { + await uploadButton.click(); + } + + // Wait for both uploads to complete + await helpers.waitForLoadingToComplete(); + + // Should show multiple uploaded documents + const uploadedFiles = page.locator('[data-testid="uploaded-files"] > *, .uploaded-file'); + await expect(uploadedFiles).toHaveCount(2, { timeout: TIMEOUTS.medium }); + }); + + test('should show upload progress', async ({ authenticatedPage: page }) => { + const fileInput = page.locator('input[type="file"]').first(); + await fileInput.setInputFiles(TEST_FILES.image); + + const uploadButton = page.locator('button:has-text("Upload"), [data-testid="upload-button"]'); + if (await uploadButton.isVisible()) { + await uploadButton.click(); + } + + // Should show progress indicator + await expect(page.locator('[data-testid="upload-progress"], .progress, [role="progressbar"]')).toBeVisible({ timeout: TIMEOUTS.short }); + }); + + test('should handle upload errors gracefully', async ({ authenticatedPage: page }) => { + // Mock a failed upload by using a non-existent file type or intercepting the request + await page.route('**/api/documents/upload', route => { + route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: 'Upload failed' }) + }); + }); + + const fileInput = page.locator('input[type="file"]').first(); + await fileInput.setInputFiles(TEST_FILES.image); + + const uploadButton = page.locator('button:has-text("Upload"), [data-testid="upload-button"]'); + if (await uploadButton.isVisible()) { + await uploadButton.click(); + } + + // Should show error message + await helpers.waitForToast(); + }); + + test('should validate file types', async ({ authenticatedPage: page }) => { + // Try to upload an unsupported file type + const fileInput = page.locator('input[type="file"]').first(); + + // Create a mock file with unsupported extension + const buffer = Buffer.from('fake content'); + await fileInput.setInputFiles({ + name: 'test.xyz', + mimeType: 'application/octet-stream', + buffer + }); + + const uploadButton = page.locator('button:has-text("Upload"), [data-testid="upload-button"]'); + if (await uploadButton.isVisible()) { + await uploadButton.click(); + } + + // Should show validation error + await helpers.waitForToast(); + }); + + test('should navigate to uploaded document after successful upload', async ({ authenticatedPage: page }) => { + const fileInput = page.locator('input[type="file"]').first(); + await fileInput.setInputFiles(TEST_FILES.image); + + const uploadButton = page.locator('button:has-text("Upload"), [data-testid="upload-button"]'); + if (await uploadButton.isVisible()) { + await uploadButton.click(); + } + + await helpers.waitForLoadingToComplete(); + + // Click on uploaded document to view details + const uploadedDocument = page.locator('[data-testid="uploaded-files"] > *, .uploaded-file').first(); + if (await uploadedDocument.isVisible()) { + await uploadedDocument.click(); + + // Should navigate to document details page + await page.waitForURL(/\/documents\/[^\/]+/, { timeout: TIMEOUTS.medium }); + } + }); + + test('should show OCR processing status', async ({ authenticatedPage: page }) => { + const fileInput = page.locator('input[type="file"]').first(); + await fileInput.setInputFiles(TEST_FILES.image); + + const uploadButton = page.locator('button:has-text("Upload"), [data-testid="upload-button"]'); + if (await uploadButton.isVisible()) { + await uploadButton.click(); + } + + await helpers.waitForLoadingToComplete(); + + // Should show OCR processing status + await expect(page.locator(':has-text("OCR"), :has-text("Processing"), [data-testid="ocr-status"]')).toBeVisible({ + timeout: TIMEOUTS.medium + }); + }); + + test('should allow drag and drop upload', async ({ authenticatedPage: page }) => { + // Look for dropzone + const dropzone = page.locator('[data-testid="dropzone"], .dropzone, .upload-area'); + + if (await dropzone.isVisible()) { + // Simulate drag and drop + await dropzone.dispatchEvent('dragover', { dataTransfer: { files: [] } }); + await dropzone.dispatchEvent('drop', { + dataTransfer: { + files: [{ name: TEST_FILES.image, type: 'image/png' }] + } + }); + + // Should show uploaded file + await expect(page.locator('[data-testid="uploaded-files"], .uploaded-file')).toBeVisible({ + timeout: TIMEOUTS.medium + }); + } + }); +}); \ No newline at end of file diff --git a/frontend/e2e/utils/test-data.ts b/frontend/e2e/utils/test-data.ts new file mode 100644 index 0000000..f1dd6f8 --- /dev/null +++ b/frontend/e2e/utils/test-data.ts @@ -0,0 +1,46 @@ +export const TEST_USERS = { + valid: { + username: 'admin', + password: 'readur2024' + }, + invalid: { + username: 'invaliduser', + password: 'wrongpassword' + } +}; + +export const TEST_FILES = { + pdf: 'test_data/sample.pdf', + image: 'test_data/hello_ocr.png', + text: 'test_data/sample.txt', + multiline: 'test_data/multiline.png', + numbers: 'test_data/numbers.png' +}; + +export const SEARCH_QUERIES = { + simple: 'test document', + advanced: { + title: 'important', + content: 'contract', + dateFrom: '2024-01-01', + dateTo: '2024-12-31' + }, + empty: '', + noResults: 'xyzabc123nonexistent' +}; + +export const API_ENDPOINTS = { + login: '/api/auth/login', + upload: '/api/documents/upload', + search: '/api/search', + documents: '/api/documents', + settings: '/api/settings' +}; + +export const TIMEOUTS = { + short: 5000, + medium: 10000, + long: 30000, + upload: 60000, + ocr: 120000 +}; \ No newline at end of file diff --git a/frontend/e2e/utils/test-helpers.ts b/frontend/e2e/utils/test-helpers.ts new file mode 100644 index 0000000..6fc8c30 --- /dev/null +++ b/frontend/e2e/utils/test-helpers.ts @@ -0,0 +1,54 @@ +import { Page, expect } from '@playwright/test'; + +export class TestHelpers { + constructor(private page: Page) {} + + async waitForApiCall(urlPattern: string | RegExp, timeout = 10000) { + return this.page.waitForResponse(resp => + typeof urlPattern === 'string' + ? resp.url().includes(urlPattern) + : urlPattern.test(resp.url()), + { timeout } + ); + } + + async uploadFile(inputSelector: string, filePath: string) { + const fileInput = this.page.locator(inputSelector); + await fileInput.setInputFiles(filePath); + } + + async clearAndType(selector: string, text: string) { + await this.page.fill(selector, ''); + await this.page.type(selector, text); + } + + async waitForToast(message?: string) { + const toast = this.page.locator('[data-testid="toast"], .toast, [role="alert"]'); + await expect(toast).toBeVisible({ timeout: 5000 }); + + if (message) { + await expect(toast).toContainText(message); + } + + return toast; + } + + async waitForLoadingToComplete() { + // Wait for any loading spinners to disappear + await this.page.waitForFunction(() => + !document.querySelector('[data-testid="loading"], .loading, [aria-label*="loading" i]') + ); + } + + async navigateToPage(path: string) { + await this.page.goto(path); + await this.waitForLoadingToComplete(); + } + + async takeScreenshotOnFailure(testName: string) { + await this.page.screenshot({ + path: `test-results/screenshots/${testName}-${Date.now()}.png`, + fullPage: true + }); + } +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a55ae09..64ee3a8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -26,6 +26,7 @@ "uuid": "^11.1.0" }, "devDependencies": { + "@playwright/test": "^1.53.0", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.6.1", @@ -1591,6 +1592,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0.tgz", + "integrity": "sha512-15hjKreZDcp7t6TL/7jkAo6Df5STZN09jGiv5dbP9A6vMVncXRqE7/B2SncsyOwrkZRBH2i6/TPOL8BVmm3c7w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.53.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -4763,6 +4780,53 @@ "dev": true, "license": "MIT" }, + "node_modules/playwright": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0.tgz", + "integrity": "sha512-ghGNnIEYZC4E+YtclRn4/p6oYbdPiASELBIYkBXfaTVKreQUYbMUYQDwS12a8F0/HtIjr/CkGjtwABeFPGcS4Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.53.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0.tgz", + "integrity": "sha512-mGLg8m0pm4+mmtB7M89Xw/GSqoNC+twivl8ITteqvAndachozYe2ZA7srU6uleV1vEdAHYqjq+SV8SNxRRFYBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index bc58ff4..d670ab6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,10 @@ "build": "vite build", "preview": "vite preview", "test": "vitest", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed", + "test:e2e:debug": "playwright test --debug", "type-check": "tsc --noEmit" }, "dependencies": { @@ -29,6 +33,7 @@ "uuid": "^11.1.0" }, "devDependencies": { + "@playwright/test": "^1.53.0", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.6.1", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..edd11d3 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,45 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + timeout: 30 * 1000, + expect: { + timeout: 5000, + }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [ + ['html', { outputFolder: 'test-results/e2e-report' }], + ['json', { outputFile: 'test-results/e2e-results.json' }], + ['list'] + ], + outputDir: 'test-results/e2e-artifacts', + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], + webServer: { + command: 'npm run dev', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, +}); \ No newline at end of file diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..9669352 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,12 @@ +/// + +interface ImportMetaEnv { + readonly VITE_WATCH_FOLDER: string + // Add more env variables as needed + readonly VITE_API_URL?: string + readonly VITE_APP_TITLE?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} \ No newline at end of file diff --git a/frontend/test_data/sample.txt b/frontend/test_data/sample.txt new file mode 100644 index 0000000..739e6a9 --- /dev/null +++ b/frontend/test_data/sample.txt @@ -0,0 +1,11 @@ +This is a sample text document for testing. +It contains multiple lines of text. + +This document is used for E2E testing of the upload functionality. +The OCR system should be able to process this text correctly. + +Key words for search testing: +- contract +- important +- test document +- readur \ No newline at end of file diff --git a/run-tests.sh b/run-tests.sh index 8e2afeb..f0bc482 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -154,6 +154,7 @@ main() { run_unit_tests run_integration_tests run_frontend_tests + run_e2e_tests ;; *) print_error "Invalid test type: $TEST_TYPE" @@ -252,10 +253,74 @@ run_frontend_tests() { fi } -# Function to run E2E tests (placeholder for future implementation) +# Function to run E2E tests run_e2e_tests() { - print_warning "E2E tests not yet implemented" - # TODO: Add E2E test implementation using Playwright or Cypress + print_status "Running E2E tests..." + + # Start frontend container for E2E tests + print_status "Starting frontend development server for E2E tests..." + docker compose -f $COMPOSE_FILE -p $COMPOSE_PROJECT_NAME --profile e2e-tests up -d frontend_dev + + # Wait for frontend to be ready + local max_attempts=30 + local attempt=0 + + print_status "Waiting for frontend to be ready..." + while [ $attempt -lt $max_attempts ]; do + if curl -f http://localhost:5174 >/dev/null 2>&1; then + print_success "Frontend is ready" + break + fi + + attempt=$((attempt + 1)) + sleep 2 + done + + if [ $attempt -eq $max_attempts ]; then + print_error "Frontend failed to start within expected time" + exit 1 + fi + + # Run E2E tests + local output + local exit_code + + # Run tests in frontend directory with proper environment variables + cd frontend + + # Install dependencies if not already installed + if [ ! -d "node_modules" ]; then + print_status "Installing frontend dependencies..." + npm ci + fi + + # Install Playwright browsers if not already installed + if [ ! -d "node_modules/@playwright" ]; then + print_status "Installing Playwright browsers..." + npx playwright install --with-deps + fi + + # Set environment variables for E2E tests + export PLAYWRIGHT_BASE_URL="http://localhost:5174" + export API_BASE_URL="http://localhost:8001" + + output=$(npm run test:e2e 2>&1) + exit_code=$? + + # Return to root directory + cd .. + + # Display output in terminal + echo "$output" + + if [ $exit_code -eq 0 ]; then + print_success "E2E tests passed" + save_output "e2e" "$output" "passed" + else + print_error "E2E tests failed" + save_output "e2e" "$output" "failed" + exit 1 + fi } # Function to generate detailed test report diff --git a/scripts/run-e2e-local.sh b/scripts/run-e2e-local.sh new file mode 100755 index 0000000..34b5ab7 --- /dev/null +++ b/scripts/run-e2e-local.sh @@ -0,0 +1,217 @@ +#!/bin/bash + +# Local E2E Test Runner for Readur +# This script sets up and runs E2E tests locally + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Configuration +DB_NAME="readur_e2e_test" +DB_USER="postgres" +DB_PASSWORD="postgres" +DB_HOST="localhost" +DB_PORT="5432" +BACKEND_PORT="8001" +FRONTEND_PORT="5174" + +# Function to print colored output +print_status() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Function to check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Function to check if port is in use +port_in_use() { + lsof -i :"$1" >/dev/null 2>&1 +} + +# Function to wait for service +wait_for_service() { + local url="$1" + local timeout="${2:-60}" + local counter=0 + + print_status "Waiting for service at $url..." + + while [ $counter -lt $timeout ]; do + if curl -f "$url" >/dev/null 2>&1; then + print_status "Service is ready!" + return 0 + fi + sleep 2 + ((counter += 2)) + done + + print_error "Service at $url did not become ready within $timeout seconds" + return 1 +} + +# Function to cleanup on exit +cleanup() { + print_status "Cleaning up..." + + # Kill background processes + if [ ! -z "$BACKEND_PID" ]; then + kill $BACKEND_PID 2>/dev/null || true + fi + + if [ ! -z "$FRONTEND_PID" ]; then + kill $FRONTEND_PID 2>/dev/null || true + fi + + # Drop test database + PGPASSWORD=$DB_PASSWORD dropdb -h $DB_HOST -U $DB_USER $DB_NAME 2>/dev/null || true + + print_status "Cleanup complete" +} + +# Set up trap to cleanup on exit +trap cleanup EXIT + +# Main execution +main() { + print_status "Starting Readur E2E Test Setup" + + # Check prerequisites + print_status "Checking prerequisites..." + + if ! command_exists cargo; then + print_error "Rust/Cargo not found. Please install Rust." + exit 1 + fi + + if ! command_exists npm; then + print_error "npm not found. Please install Node.js and npm." + exit 1 + fi + + if ! command_exists psql; then + print_error "PostgreSQL client not found. Please install PostgreSQL." + exit 1 + fi + + # Check if ports are available + if port_in_use $BACKEND_PORT; then + print_error "Port $BACKEND_PORT is already in use. Please free it or change BACKEND_PORT in this script." + exit 1 + fi + + if port_in_use $FRONTEND_PORT; then + print_error "Port $FRONTEND_PORT is already in use. Please free it or change FRONTEND_PORT in this script." + exit 1 + fi + + # Set up test database + print_status "Setting up test database..." + + # Drop existing test database if it exists + PGPASSWORD=$DB_PASSWORD dropdb -h $DB_HOST -U $DB_USER $DB_NAME 2>/dev/null || true + + # Create test database + PGPASSWORD=$DB_PASSWORD createdb -h $DB_HOST -U $DB_USER $DB_NAME + + # Add vector extension if available + PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -U $DB_USER -d $DB_NAME -c "CREATE EXTENSION IF NOT EXISTS vector;" 2>/dev/null || print_warning "Vector extension not available" + + # Run migrations + print_status "Running database migrations..." + export DATABASE_URL="postgresql://$DB_USER:$DB_PASSWORD@$DB_HOST:$DB_PORT/$DB_NAME" + export TEST_MODE=true + + cargo run --bin migrate || { + print_error "Failed to run migrations" + exit 1 + } + + # Build backend + print_status "Building backend..." + cargo build --release + + # Start backend server + print_status "Starting backend server on port $BACKEND_PORT..." + DATABASE_URL="postgresql://$DB_USER:$DB_PASSWORD@$DB_HOST:$DB_PORT/$DB_NAME" \ + TEST_MODE=true \ + ROCKET_PORT=$BACKEND_PORT \ + ./target/release/readur > backend.log 2>&1 & + BACKEND_PID=$! + + # Wait for backend to be ready + wait_for_service "http://localhost:$BACKEND_PORT/health" || { + print_error "Backend failed to start. Check backend.log for details." + exit 1 + } + + # Install frontend dependencies + print_status "Installing frontend dependencies..." + cd frontend + npm ci + + # Install Playwright browsers + print_status "Installing Playwright browsers..." + npx playwright install + + # Start frontend dev server + print_status "Starting frontend dev server on port $FRONTEND_PORT..." + VITE_API_BASE_URL="http://localhost:$BACKEND_PORT" \ + npm run dev -- --port $FRONTEND_PORT > ../frontend.log 2>&1 & + FRONTEND_PID=$! + + # Wait for frontend to be ready + wait_for_service "http://localhost:$FRONTEND_PORT" || { + print_error "Frontend failed to start. Check frontend.log for details." + exit 1 + } + + # Run E2E tests + print_status "Running E2E tests..." + + # Update Playwright config for local testing + export PLAYWRIGHT_BASE_URL="http://localhost:$FRONTEND_PORT" + + if [ "$1" = "--headed" ]; then + npm run test:e2e:headed + elif [ "$1" = "--debug" ]; then + npm run test:e2e:debug + elif [ "$1" = "--ui" ]; then + npm run test:e2e:ui + else + npm run test:e2e + fi + + print_status "E2E tests completed!" +} + +# Parse command line arguments +case "$1" in + --help|-h) + echo "Usage: $0 [--headed|--debug|--ui|--help]" + echo "" + echo "Options:" + echo " --headed Run tests in headed mode (show browser)" + echo " --debug Run tests in debug mode" + echo " --ui Run tests with Playwright UI" + echo " --help Show this help message" + exit 0 + ;; +esac + +# Run main function +main "$@" \ No newline at end of file