feat(e2e): add playwright for e2e tests

This commit is contained in:
perf3ct 2025-06-17 20:10:04 +00:00
parent ed9d467c9f
commit efbd15774a
19 changed files with 2313 additions and 3 deletions

124
.github/workflows/e2e-tests.yml vendored Normal file
View File

@ -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

279
README-E2E.md Normal file
View File

@ -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

View File

@ -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

112
frontend/e2e/auth.spec.ts Normal file
View File

@ -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();
});
});

View File

@ -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();
}
});
});

View File

@ -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<AuthFixture>({
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 };

235
frontend/e2e/search.spec.ts Normal file
View File

@ -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();
});
});

View File

@ -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();
}
});
});

View File

@ -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();
}
}
}
}
});
});

177
frontend/e2e/upload.spec.ts Normal file
View File

@ -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
});
}
});
});

View File

@ -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
};

View File

@ -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
});
}
}

View File

@ -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",

View File

@ -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",

View File

@ -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,
},
});

12
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
/// <reference types="vite/client" />
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
}

View File

@ -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

View File

@ -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

217
scripts/run-e2e-local.sh Executable file
View File

@ -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 "$@"