pdf-tei-editor / docs /code-assistant /testing-guide.md
cmboulanger's picture
disaster-recovery deploy of pdf-tei-editor
6a49f21 verified
|
Raw
History Blame Contribute Delete
42.5 kB
# Testing Guide
Essential testing information for code assistants. For comprehensive testing documentation, see [../development/testing.md](../development/testing.md).
## Test Structure
```
tests/
├── unit/{js,fastapi}/ # Unit tests
├── api/v1/ # API integration tests (*.test.js)
├── e2e/tests/ # E2E Playwright tests (*.spec.js)
├── backend-test-runner.js # API test runner
├── e2e-runner.js # E2E test runner
└── smart-test-runner.js # Intelligent test selection
```
## Quick Commands
```bash
# Run tests for changed files only (use this most often)
npm run test:changed
# Check which tests would run without executing them
npm run test:changed -- --dry-run
npm run test:changed -- --names-only # Output only test file names
# Unit tests
npm run test:unit:js # JavaScript units
npm run test:unit:fastapi # Python units
# API tests (local FastAPI server, no containers)
npm run test:api # All API tests
npm run test:api -- --grep "save" # Filter by file path pattern
npm run test:api -- --grep "files_serve_caching" # Run specific test file
# E2E tests (Playwright)
npm run test:e2e # All E2E tests with local server
npm run test:e2e:headed # Show browser
npm run test:e2e:debug # Step-through debugging
npm run test:e2e:debug-failure # Capture debug artifacts on failure (headless)
npm run test:e2e -- --grep "should upload" # Filter by test name pattern
# Cross-browser E2E tests (real browser engines, no login required)
npm run test:e2e:xmleditor-browsers # xmlTagSync tests in chromium, firefox, webkit
# Container tests (runs all tests inside container)
npm run test:container # Run with cache
npm run test:container -- --no-cache # Rebuild all layers
npm run test:container -- path/to/file.js # Test specific files
npm run test:container -- --browser firefox # Use specific browser
npm run test:container -- --browser chromium,firefox,webkit # Test multiple browsers
```
## Test Types
### Unit Tests
- **Location**: `tests/unit/{js,fastapi}/`
- **Purpose**: Test isolated functions/classes
- **Run**: `npm run test:unit`
#### Python Unit Tests with FastAPI Routes
Python unit tests for backend plugins and custom routes use a combination of dependency injection overrides and mocking.
##### Pattern: Testing FastAPI Routes with Mixed Dependencies
Routes often mix FastAPI dependency injection with direct function calls. Test both using:
1. **Dependency Overrides** (in setUp) - for consistent mocks across all tests
2. **@patch decorators** - for functions called inside routes
**Example:**
```python
import unittest
from unittest.mock import MagicMock, patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
class TestMyRoute(unittest.IsolatedAsyncioTestCase):
"""Test my custom route."""
def setUp(self):
"""Set up test fixtures."""
from myapp.routes import router
from myapp.dependencies import get_auth_manager, get_session_manager
self.app = FastAPI()
self.app.include_router(router)
# Create mocks for injected dependencies
self.mock_session_manager = MagicMock()
self.mock_auth_manager = MagicMock()
# Override dependencies - these work for all tests
self.app.dependency_overrides[get_session_manager] = lambda: self.mock_session_manager
self.app.dependency_overrides[get_auth_manager] = lambda: self.mock_auth_manager
self.client = TestClient(self.app)
@patch("myapp.config.get_settings") # Patch functions called inside route
@patch("myapp.routes.get_db") # Not dependency-injected, called directly
def test_my_endpoint(self, mock_get_db, mock_settings):
"""Test endpoint with authentication."""
# Mock settings (called inside route via import)
mock_settings_obj = MagicMock()
mock_settings_obj.session_timeout = 3600
mock_settings.return_value = mock_settings_obj
# Configure dependency override mocks (set in setUp)
self.mock_session_manager.is_session_valid.return_value = True
mock_user = MagicMock()
self.mock_auth_manager.get_user_by_session_id.return_value = mock_user
# Mock direct function calls
mock_get_db.return_value = MagicMock()
# Make request
response = self.client.get(
"/api/my-endpoint",
params={"session_id": "valid-session"}
)
self.assertEqual(response.status_code, 200)
```
**Key Points:**
- **Dependency overrides** (`self.app.dependency_overrides`) handle FastAPI `Depends()` parameters
- **@patch decorators** handle functions imported and called inside routes (`get_settings()`, `get_db()`)
- **Mock at import location**: Patch where the function is used, not where it's defined
- Route imports `from fastapi_app.config import get_settings` → patch `"myapp.routes.get_settings"`
- Route imports function from another module → patch at that import path
- **Order matters**: `@patch` decorators apply bottom-to-top, parameters passed in reverse order
**Common Mistakes:**
- Trying to patch dependency-injected functions with `@patch` instead of using `dependency_overrides`
- Patching at definition location instead of import location
- Forgetting to mock functions called inside routes that aren't dependency-injected
- **Calling functions directly instead of using dependency injection** - Routes should use `Depends(get_db)` and `Depends(get_file_storage)` as parameters, not call `db = get_db()` inside the function body. This ensures proper mocking via `dependency_overrides` and prevents database access failures in CI environments where databases may not exist
**Best Practice - Always Use Dependency Injection:**
Routes should declare all dependencies as parameters using `Depends()`:
```python
# ✅ CORRECT - Use dependency injection
@router.get("/export")
async def export_csv(
pdf: str = Query(...),
db=Depends(get_db),
file_storage=Depends(get_file_storage)
):
file_repo = FileRepository(db)
# ... use db and file_storage ...
# ❌ WRONG - Direct function calls
@router.get("/export")
async def export_csv(
pdf: str = Query(...)
):
db = get_db() # Can't be mocked via dependency_overrides
file_storage = get_file_storage() # Fails in CI if database doesn't exist
```
This pattern:
- Enables proper mocking via `app.dependency_overrides` in tests
- Prevents CI failures when databases/resources don't exist
- Aligns with FastAPI best practices
- Matches patterns in existing routes (edit_history plugin)
**Authentication Testing Pattern:**
When testing routes that require authentication (via `session_id` query parameter or `X-Session-ID` header), set up default authentication in `setUp()`:
```python
def setUp(self):
"""Set up test fixtures."""
from fastapi import FastAPI
from myapp.routes import router
from fastapi_app.lib.core.dependencies import (
get_auth_manager,
get_session_manager,
get_db,
get_file_storage,
)
self.app = FastAPI()
self.app.include_router(router)
# Create mocks for dependencies
self.mock_session_manager = MagicMock()
self.mock_auth_manager = MagicMock()
self.mock_db = MagicMock()
self.mock_storage = MagicMock()
# Mock valid authentication by default
self.mock_session_manager.is_session_valid.return_value = True
self.mock_auth_manager.get_user_by_session_id.return_value = MagicMock(
username="testuser",
groups=["*"] # Wildcard access
)
# Override dependencies
self.app.dependency_overrides[get_session_manager] = lambda: self.mock_session_manager
self.app.dependency_overrides[get_auth_manager] = lambda: self.mock_auth_manager
self.app.dependency_overrides[get_db] = lambda: self.mock_db
self.app.dependency_overrides[get_file_storage] = lambda: self.mock_storage
self.client = TestClient(self.app)
```
Then include `session_id` in test requests:
```python
response = self.client.get(
"/api/my-endpoint",
params={"session_id": "test-session", "other_param": "value"}
)
```
**Why This Pattern:**
- Routes using `Depends(get_session_manager)` and `Depends(get_auth_manager)` require dependency overrides
- Setting up valid authentication in `setUp()` makes all tests pass authentication by default
- Individual tests can override these defaults to test authentication failures
- Must include `session_id` parameter in requests to satisfy route authentication requirements
**See Also:**
- [fastapi_app/plugins/edit_history/tests/test_edit_history_export.py](../../fastapi_app/plugins/edit_history/tests/test_edit_history_export.py) - Complete example with authentication, authorization, and data mocking
- [fastapi_app/plugins/annotation_history/tests/test_annotation_history.py](../../fastapi_app/plugins/annotation_history/tests/test_annotation_history.py) - Routes requiring authentication via dependency injection
### API Integration Tests
- **Location**: `tests/api/v1/`
- **Naming**: `*.test.js`
- **Purpose**: Test backend endpoints without browser
- **Run**: `npm run test:api`
- **Features**: Local server, auto-cleanup, fixtures from `tests/api/fixtures/`
#### SSE (Server-Sent Events) Tests
SSE tests require special handling due to long-lived HTTP connections. When tests maintain multiple SSE connections and need to make additional HTTP requests, the Node.js HTTP connection pool can become exhausted.
**HTTP Connection Pool Configuration:**
SSE test files must increase the HTTP agent's max sockets at the top of the file:
```javascript
import http from 'node:http';
// Increase connection pool to handle SSE connections + regular requests
http.globalAgent.maxSockets = 50;
```
**Why This Is Needed:**
- Node.js HTTP agent defaults to ~5-10 concurrent connections per host
- Each SSE connection holds a connection slot for its entire lifetime
- When multiple tests create SSE connections, these accumulate
- Additional HTTP requests (like POST to trigger events) can timeout if no connections are available
- Increasing `maxSockets` to 50 prevents connection pool exhaustion
**Symptoms of Connection Pool Exhaustion:**
- Tests pass in isolation but hang/timeout in full test suite
- HTTP requests timeout after SSE connections are established
- Error: "Broadcast request timed out after 5s" or similar
**Example Pattern:**
```javascript
import { test, describe } from 'node:test';
import http from 'node:http';
import { createEventSource } from 'eventsource-client';
// CRITICAL: Increase connection pool for SSE tests
http.globalAgent.maxSockets = 50;
describe('SSE Tests', () => {
test('Broadcast to multiple sessions', async () => {
// Create SSE connections (long-lived)
const conn1 = createEventSource({ url: '/sse/subscribe' });
const conn2 = createEventSource({ url: '/sse/subscribe' });
// Wait for connections to establish
await new Promise(resolve => setTimeout(resolve, 2000));
// Make additional HTTP request - would fail without increased maxSockets
const response = await fetch('/sse/test/broadcast', { method: 'POST' });
// Clean up
conn1.close();
conn2.close();
});
});
```
**See Also:** [tests/api/v1/sse.test.js](../../tests/api/v1/sse.test.js) for complete SSE testing patterns.
#### Fixture System
API tests use a two-phase fixture loading system:
**Phase 1: Config Loading** (before server starts)
- Copies JSON config files from `tests/api/fixtures/{fixture-name}/config/` to `tests/api/runtime/config/`
- Config files: `users.json`, `groups.json`, `roles.json`, `collections.json`
- Server reads these during initialization to set up RBAC
**Phase 2: File Import** (after server starts)
- Imports PDF/XML files from `tests/api/fixtures/{fixture-name}/files/` using `bin/import_files.py`
- Files are imported into the `default` collection (matches default group access)
- Creates database entries and stores files in content-addressable hash-sharded structure
- TEI files in the fixture directory are detected as gold standard files and linked to their PDFs
**Available Fixtures**:
- `minimal`: Basic config with minimal users/groups (no files)
- `standard`: Standard config + sample PDF/TEI files for testing
**Access Control Considerations**:
- Fixture files are imported into the `default` collection
- Test users need appropriate group membership to access fixture files
- Standard fixture users (`reviewer`, `annotator`, `user`) are in the `default` group with access to `_inbox` and `default` collections
- `admin` user has wildcard access (`*`) to all collections
**CRITICAL:** Do not alter fixtures
- Tests should NOT permanently modify fixture files or delete them in cleanup - they're shared across tests and automatically cleaned up when the runtime directory is wiped.
- Ideally, make a copy of the fixture file or add new files to alter/delete, at a minimun restore altered files after the test.
### E2E Tests
- **Location**: `tests/e2e/tests/`
- **Naming**: `*.spec.js`
- **Purpose**: Test full workflows in browser
- **Run**: `npm run test:e2e`
- **Features**: Playwright, `window.ui` navigation, `testLog()` for state verification
#### Isolated Component Harness Tests
Some specs test individual components in isolation using a standalone HTML harness page rather than the full application. The harness is served by the dev server and loads only the dependencies needed for the component under test (via the importmap from `app/web/importmap.json`, rebased to absolute paths). No login, no fixture loading, no application state required.
Use this approach when:
- The component has browser-engine-specific behavior (e.g. Lezer parser eagerness differences between Chromium and Firefox)
- You need to reproduce or rule out an editor bug without the overhead of the full application
- Fast cross-browser iteration is needed
**Available harnesses:**
| Harness HTML | Spec | Tests |
|---|---|---|
| `tests/e2e/harness/xmleditor-harness.html` | `tests/e2e/tests/xmleditor-cross-browser.spec.js` | `xmlTagSync` CodeMirror extension |
**Running:**
```bash
# All three browsers
npm run test:e2e:xmleditor-browsers
# Single browser for fast iteration
node tests/e2e-runner.js tests/e2e/tests/xmleditor-cross-browser.spec.js --browser firefox
# Headed to observe behaviour
node tests/e2e-runner.js tests/e2e/tests/xmleditor-cross-browser.spec.js --browser chromium --headed
```
**Adding a new harness:**
1. Create `tests/e2e/harness/<name>-harness.html` — use `xmleditor-harness.html` as the template (contains the importmap rebasing logic).
2. Create `tests/e2e/harness/<name>-harness.js` — import the component under test, expose helpers on `window`, and emit `window.testLog('HARNESS_READY', {})` when ready.
3. Create `tests/e2e/tests/<name>-cross-browser.spec.js` — call `setupTestConsoleCapture` + `waitForTestMessage(logs, 'HARNESS_READY')` before each test.
4. Add an npm script in `package.json` following the `test:e2e:xmleditor-browsers` pattern.
#### Container Testing
**Run Tests Inside Container** (`npm run test:container`):
- **Purpose**: Run all tests in an isolated container environment (same as CI)
- **What it does**:
- Builds the `ci` container image with all dependencies and test code
- Runs tests inside the container (not against it)
- Streams output in real-time to your terminal
- Uses smart test runner with dependency analysis
- Supports all test runner options (browsers, grep, specific files)
- **Use when**:
- Testing changes that require a clean environment
- Validating behavior in production-like container
- Reproducing CI failures locally
- Testing Dockerfile or dependency changes
**Examples:**
```bash
# Run all tests with caching (fastest for iterative testing)
npm run test:container
# Force rebuild (use after Dockerfile or dependency changes)
npm run test:container -- --no-cache
# Test specific files
npm run test:container -- app/src/plugins/authentication.js
# Run E2E tests with specific browser
npm run test:container -- --browser firefox
# Test multiple browsers
npm run test:container -- --browser chromium,firefox,webkit
# Run all tests (skip smart selection)
npm run test:container -- --all
```
**Technical Details:**
- Tests run inside container using `docker/entrypoint-ci.sh`
- All fixtures and test code baked into image at build time
- Real-time streaming via `PYTHONUNBUFFERED=1` and `stdio: 'inherit'`
- Playwright browsers installed early for optimal layer caching
- Exit code matches test results (0 = success, non-zero = failure)
## Test Filtering with --grep
**IMPORTANT:** The `--grep` parameter works differently for API vs E2E tests:
### API Tests (backend-test-runner.js)
`--grep` matches **file paths** (test file names):
```bash
# Matches files containing "save" in the path
npm run test:api -- --grep "save"
# → Runs: tests/api/v1/files_save.test.js
# Matches specific test file
npm run test:api -- --grep "files_serve_caching"
# → Runs: tests/api/v1/files_serve_caching.test.js
# Matches multiple files with common pattern
npm run test:api -- --grep "files_"
# → Runs: all files starting with "files_"
```
**Why:** The backend test runner filters test files before passing them to Node.js test runner.
### E2E Tests (e2e-runner.js / Playwright)
`--grep` matches **test names** (test titles in your code):
```bash
# Matches test titles containing "upload"
npm run test:e2e -- --grep "upload"
# → Runs: test('should upload file', ...), test('upload validation', ...), etc.
# Matches specific test case
npm run test:e2e -- --grep "should create new version"
# → Runs: test('should create new version', ...)
# Case-insensitive regex pattern
npm run test:e2e -- --grep "save.*revision"
# → Runs: test('should save current revision', ...), etc.
```
**Alternative: Run specific test files directly**
Instead of using `--grep` with test names, you can pass test file paths as positional arguments:
```bash
# Run single test file
node tests/e2e-runner.js tests/e2e/tests/app-loading.spec.js
# Run multiple test files
node tests/e2e-runner.js tests/e2e/tests/auth-workflow.spec.js tests/e2e/tests/export-workflow.spec.js
# Combine with options
node tests/e2e-runner.js --headed tests/e2e/tests/auth-workflow.spec.js
```
**Why:** Playwright's `--grep` matches `test()` descriptions, not file paths. The smart-test-runner automatically uses file paths when selecting specific tests.
### Rule of Thumb for Code Assistants
- **Debugging API tests**: Use file name patterns → `--grep "files_save"`
- **Debugging E2E tests**: Use test name patterns → `--grep "should upload"` OR pass file paths directly
- **When in doubt**: Check test file extension:
- `*.test.js` → API test → grep by file path
- `*.spec.js` → E2E test → grep by test name OR pass file paths
### Smart Test Runner Behavior
The `smart-test-runner.js` automatically uses the correct approach:
- **API tests**: Constructs `--grep` patterns with file paths
- **E2E tests**: Passes test file paths as positional arguments (NOT via --grep)
## Writing Tests
### Always Add Coverage Annotations
```javascript
/**
* @testCovers fastapi_app/routers/files_save.py
* @testCovers app/src/plugins/filedata.js
*/
test('should save file', async () => {
// Test code
});
```
### Suppressing Expected Log Output
When testing code that produces expected warnings or log messages, use `assertLogs` to suppress output and verify the messages:
**Python Tests:**
```python
import logging
def test_expected_warning(self):
"""Test that verifies expected warning is logged."""
# Suppress and capture expected warnings
with self.assertLogs('module.name', level='WARNING') as cm:
result = function_that_warns()
# Verify expected warning was logged
self.assertTrue(any('expected message' in msg for msg in cm.output))
# Continue with assertions
self.assertEqual(result, expected_value)
```
This pattern:
- Suppresses console output during test runs (keeps test output clean)
- Captures log messages for verification
- Ensures expected warnings/errors are actually being logged
- Prevents test pollution when warnings are intentional
**When to use:**
- Testing error handling that logs warnings
- Testing validation that produces expected errors
- Testing deprecated functionality that warns
- Any scenario where log output is part of the expected behavior
### Ignoring Auto-Generated Files
The smart test runner automatically ignores certain files from change detection:
- `app/src/modules/api-client-v1.js` (auto-generated from OpenAPI schema)
To ignore additional files, configure in the test runner:
```javascript
const runner = new SmartTestRunner({
ignoreChanges: [
'app/src/modules/api-client-v1.js', // Exact file path
/.*-generated\.js$/ // Regex pattern
]
});
```
This prevents unnecessary test runs when only metadata (like timestamps) changes in auto-generated files.
This enables smart test selection via `npm run test:changed`.
### Plugin Integration Tests
Plugin integration tests verify backend plugin functionality by testing custom routes and plugin endpoints with a live server. These tests are located in the plugin's `tests/` directory.
#### Location and Structure
```
fastapi_app/plugins/
└── my-plugin/
├── __init__.py
├── plugin.py
├── routes.py
└── tests/
├── .env.test # Plugin-specific environment config
└── test_*.test.js # Integration tests
```
#### Running Plugin Tests
```bash
# Run plugin tests with test-specific environment
node tests/backend-test-runner.js \
--test-dir fastapi_app/plugins/my-plugin/tests \
--env-file fastapi_app/plugins/my-plugin/tests/.env.test
```
#### Plugin Test Environment Configuration
Plugin tests require a `.env.test` file to configure the test environment. This file must include:
**Required Settings:**
```bash
# Test Paths - runtime data is ephemeral
DATA_ROOT=tests/api/runtime
DB_DIR=tests/api/runtime/db
LOG_DIR=tests/api/runtime/logs
# Plugin Configuration
PLUGIN_MY_PLUGIN_ENABLED=true
PLUGIN_MY_PLUGIN_OPTION=value
```
**Why These Are Required:**
- `DATA_ROOT`, `DB_DIR`, `LOG_DIR` - Tell the server to use test directories instead of production paths
- Plugin config vars - Enable and configure the plugin for testing
Without these paths, the server will try to use production directories (`fastapi_app/db`, etc.) which may not exist in the test environment.
#### Authentication in Plugin Tests
Plugin routes often require authentication. The `login()` helper returns an **object** with `sessionId` and `user` properties:
```javascript
import { login, authenticatedApiCall } from '../../../../tests/api/helpers/test-auth.js';
// CORRECT - Destructure the session ID
const { sessionId } = await login('admin', 'admin', BASE_URL);
// WRONG - login() returns an object, not a string
const sessionId = await login('admin', 'admin', BASE_URL); // sessionId will be an object!
```
Pass session ID in requests via header or query parameter:
```javascript
// Via header (preferred)
const response = await fetch(`${BASE_URL}/api/plugins/my-plugin/route`, {
headers: { 'X-Session-Id': sessionId }
});
// Via query parameter
const response = await authenticatedApiCall(
sessionId,
'/api/plugins/my-plugin/route',
'GET',
null,
BASE_URL
);
```
#### Testing Custom Plugin Routes
Backend plugins typically expose custom routes (not just plugin endpoints). Test these routes directly:
```javascript
import { test, describe } from 'node:test';
import assert from 'node:assert';
import { login, authenticatedApiCall } from '../../../../tests/api/helpers/test-auth.js';
import { logger } from '../../../../tests/api/helpers/test-logger.js';
const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:8000';
describe('My Plugin API Tests', () => {
let sessionId = null;
test('Setup: login to get session ID', async () => {
const loginResult = await login('admin', 'admin', BASE_URL);
sessionId = loginResult.sessionId;
assert.ok(sessionId, 'Should have session ID');
logger.success('Logged in successfully');
});
test('Plugin availability check', async () => {
const response = await authenticatedApiCall(
sessionId,
'/plugins',
'GET',
null,
BASE_URL
);
const myPlugin = response.plugins.find(p => p.id === 'my-plugin');
assert.ok(myPlugin, 'Plugin should be available');
logger.success('Plugin is available');
});
test('Preview route returns HTML', async () => {
const previewUrl = `/api/plugins/my-plugin/preview?param=value`;
const response = await fetch(`${BASE_URL}${previewUrl}`, {
headers: { 'X-Session-Id': sessionId }
});
assert.strictEqual(response.status, 200);
const html = await response.text();
assert.ok(html.includes('expected content'));
logger.success('Preview route works');
});
test('Execute route (GET request)', async () => {
// Note: Check route definition - some use GET, others POST
const executeUrl = `/api/plugins/my-plugin/execute?param=value`;
const response = await fetch(`${BASE_URL}${executeUrl}`, {
headers: { 'X-Session-Id': sessionId }
});
assert.strictEqual(response.status, 200);
logger.success('Execute route completes');
});
});
```
#### Common Patterns
**Check HTTP Method:**
Custom routes may use GET or POST. Check the route definition:
```python
@router.get("/execute") # GET request
@router.post("/execute") # POST request
```
**Test with Fixtures:**
Plugin tests run after fixture import. Use fixture data:
```javascript
test('Works with fixture data', async () => {
// Fixture files are already imported at this point
const response = await authenticatedApiCall(
sessionId,
'/files/list',
'GET',
null,
BASE_URL
);
// Use fixture document IDs in your tests
const fixtureDoc = response.files.find(f => f.doc_id === 'known-fixture-id');
assert.ok(fixtureDoc, 'Fixture document should exist');
});
```
**Temporary Test Files:**
Create temporary files for tests that need filesystem data:
```javascript
import { mkdir, writeFile, rm } from 'fs/promises';
import { join } from 'path';
const testDir = '/tmp/my-plugin-test';
test('Setup: create test files', async () => {
await mkdir(testDir, { recursive: true });
await writeFile(join(testDir, 'test.xml'), '<root>test</root>');
});
test('Cleanup: remove test files', async () => {
await rm(testDir, { recursive: true, force: true });
});
```
#### Debugging Plugin Tests
```bash
# Verbose output
node tests/backend-test-runner.js \
--test-dir fastapi_app/plugins/my-plugin/tests \
--env-file fastapi_app/plugins/my-plugin/tests/.env.test \
--verbose
# Keep server running
node tests/backend-test-runner.js \
--test-dir fastapi_app/plugins/my-plugin/tests \
--env-file fastapi_app/plugins/my-plugin/tests/.env.test \
--no-cleanup
# Keep database for inspection
node tests/backend-test-runner.js \
--test-dir fastapi_app/plugins/my-plugin/tests \
--env-file fastapi_app/plugins/my-plugin/tests/.env.test \
--keep-db
```
#### Complete Example
See `fastapi_app/plugins/local_sync/tests/test_sync_api.test.js` for a complete working example that includes:
- Environment configuration via `.env.test`
- Authentication setup
- Plugin availability verification
- Testing custom routes (preview and execute)
- Temporary file creation and cleanup
- Fixture data integration
### API Test Template
```javascript
import { test, describe } from 'node:test';
import assert from 'node:assert';
import { login, authenticatedApiCall } from '../helpers/test-auth.js';
const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:8000';
describe('Feature Name', () => {
let sessionId;
test('Setup: login', async () => {
const { sessionId: sid } = await login('reviewer', 'reviewer', BASE_URL);
sessionId = sid;
});
test('should do something', async () => {
const result = await authenticatedApiCall(
sessionId,
'/api/endpoint',
'POST',
{ data: 'value' },
BASE_URL
);
assert.strictEqual(result.status, 'success');
});
test('Cleanup: release locks', async () => {
// Clean up resources
});
});
```
### E2E Test Template
```javascript
/** @import { namedElementsTree } from '../../app/src/ui.js' */
import { test, expect } from '../fixtures/debug-on-failure.js';
test('should do something', async ({ page }) => {
await page.goto('http://localhost:8000');
// Use window.ui for interactions
await page.evaluate(() => {
/** @type {namedElementsTree} */
const ui = /** @type {any} */(window).ui;
ui.someButton.click();
});
// Use testLog() for state verification (preferred over DOM queries)
const result = await page.evaluate(() => window.someGlobalState);
expect(result).toBeTruthy();
});
```
### Using API Client in Browser Context (E2E Tests)
**CRITICAL:** Never use manual `fetch()` calls to the API backend in `page.evaluate()`. Always use the `client` object:
```javascript
/**
* @import { api as Client } from '../../../app/src/plugins/client.js'
*/
const result = await page.evaluate(async () => {
/** @type {Client} */
const client = /** @type {any} */(window).client;
// Use typed API methods
return await client.apiClient.sseTestProgress({
steps: 3,
delay_ms: 500,
label_prefix: 'Test step'
});
});
```
- Check `app/src/modules/api-client-v1.js` for all available API methods (auto-generated from OpenAPI schema)
- See [../development/testing.md](../development/testing.md#using-the-api-client-in-browser-context) for full documentation
## Key Principles
1. **Use `@testCovers` annotations** - Enables smart test selection
2. **Clean up after tests** - Release locks, delete test files
3. **API tests run locally** - Fast iteration with `backend-test-runner.js`
4. **E2E tests use local server by default** - Isolation and speed
5. **ALWAYS use helper functions** - Never reimplement auth or API utilities
## Common Patterns
### Authentication in API Tests
```javascript
import { login, authenticatedApiCall, createAdminSession } from '../helpers/test-auth.js';
// Regular user login
const session = await login('reviewer', 'reviewer', BASE_URL);
// Admin session
const adminSession = await createAdminSession(BASE_URL);
// Authenticated API call
const result = await authenticatedApiCall(
session.sessionId,
'/api/endpoint',
'POST',
data,
BASE_URL
);
```
### UI Navigation in E2E Tests
```javascript
await page.evaluate(() => {
/** @type {namedElementsTree} */
const ui = /** @type {any} */(window).ui;
ui.loginDialog.username.value = 'testuser';
ui.loginDialog.submit.click();
});
```
### Test Logging for State Verification
```javascript
import { setupTestConsoleCapture, waitForTestMessage } from './helpers/test-logging.js';
const consoleLogs = setupTestConsoleCapture(page);
// ... perform action ...
const log = await waitForTestMessage(consoleLogs, 'FILE_SAVED');
expect(log.value.file_id).toBeTruthy();
```
**Enhanced Error Reporting:**
`setupTestConsoleCapture()` now includes automatic network request tracking. When a console error occurs, the error message will include:
- The console error message
- Recent failed network requests (within 5 seconds of the error)
- Recent network activity (last 2 seconds)
Example enhanced error output:
```text
Error: Unexpected console error detected: Failed to load resource: the server responded with a status of 404 (Not Found)
Recent failed network requests:
GET http://localhost:8010/api/v1/files/nonexistent → 404 Not Found
Recent network activity (last 2 seconds):
GET http://localhost:8010/api/v1/files/list → 200
POST http://localhost:8010/api/v1/files/save → 200
GET http://localhost:8010/api/v1/files/nonexistent → 404
```
This eliminates the need to access server logs or trace files when debugging network-related errors in CI.
### Checking State for Shoelace Components
Playwright's accessibility-based matchers don't work properly with Shoelace web components. Always check attributes directly:
**Disabled State:**
```javascript
// ❌ WRONG - May fail for Shoelace components
await expect(button).toBeDisabled();
await expect(button).toBeEnabled();
// ✅ CORRECT - Check disabled attribute directly
await expect(button).toHaveAttribute('disabled', ''); // Disabled
await expect(button).not.toHaveAttribute('disabled'); // Enabled
```
**Checked State:**
```javascript
// ❌ WRONG - Fails with "Not a checkbox or radio button"
await expect(checkbox).toBeChecked();
await expect(checkbox).not.toBeChecked();
// ✅ CORRECT - Check checked attribute directly
await expect(checkbox).toHaveAttribute('checked', ''); // Checked
await expect(checkbox).not.toHaveAttribute('checked'); // Unchecked
```
**Dialog Button Clicks:**
Shoelace dialogs require a short delay before button clicks to ensure the dialog is fully rendered and interactive:
```javascript
// Wait for dialog to open
await page.waitForSelector('sl-dialog[name="myDialog"][open]', { timeout: 5000 });
// Fill in form fields
await page.evaluate(() => {
const ui = window.ui;
ui.myDialog.inputField.value = 'test value';
});
// CRITICAL: Add 500ms delay before clicking submit
await page.waitForTimeout(500);
await page.evaluate(() => {
const ui = window.ui;
ui.myDialog.submit.click();
});
```
**Why this is needed:**
- Shoelace dialogs use Shadow DOM and animation
- Clicking too quickly can result in the click being ignored
- 500ms ensures the dialog is fully interactive
This applies to all Shoelace dialog buttons (`<sl-button>` inside `<sl-dialog>`).
## Critical: Always Use Helper Functions
**Never reimplement authentication, API calls, or common test utilities.** This causes maintenance burden and bugs.
**WRONG** - Reimplementing login:
```javascript
async function loginAsAdmin() {
const response = await fetch(`${API_BASE}/api/v1/auth/login`, {
method: 'POST',
body: JSON.stringify({
username: 'admin',
password: 'admin' // Wrong! Should be passwd_hash with hashing
})
});
}
```
**CORRECT** - Use existing helper:
```javascript
import { createAdminSession } from '../helpers/test-auth.js';
async function loginAsAdmin() {
const { sessionId } = await createAdminSession(API_BASE);
return sessionId;
}
```
### Common Helper Locations
```javascript
// Authentication and API calls
import {
login,
logout,
createAdminSession,
hashPassword,
authenticatedApiCall,
authenticatedRequest
} from '../helpers/test-auth.js';
// Role-based login helpers (use standard fixture users)
import {
loginAsAdmin, // Returns {sessionId, user} for admin user
loginAsReviewer, // Returns {sessionId, user} for reviewer user
loginAsAnnotator, // Returns {sessionId, user} for annotator user
loginAsBasicUser // Returns {sessionId, user} for basic user (no special roles)
} from '../helpers/test-auth.js';
// Lock management helpers
import {
tryAcquireLock, // Returns Response object (check .status)
acquireLock, // Throws on failure
releaseLock, // Release a held lock
checkLock // Check lock status: {is_locked, locked_by}
} from '../helpers/test-auth.js';
// Test logging and state verification
import {
setupTestConsoleCapture,
waitForTestMessage
} from './helpers/test-logging.js';
```
### Lock Management in Tests
When testing file operations that require locks:
```javascript
import { loginAsReviewer, tryAcquireLock, releaseLock } from '../helpers/test-auth.js';
const session = await loginAsReviewer(BASE_URL);
// Try to acquire lock (returns Response for status checking)
const response = await tryAcquireLock(session.sessionId, fileId, BASE_URL);
if (response.status === 200) {
// Lock acquired - perform operations
// ...
// Always release lock in cleanup
await releaseLock(session.sessionId, fileId, BASE_URL);
} else if (response.status === 403) {
// Access denied (permission issue)
} else if (response.status === 409) {
// Conflict (already locked by another user)
}
```
**Role-based login helpers** use standard fixture users:
- `loginAsAdmin()` - User with wildcard (`*`) access
- `loginAsReviewer()` - User with `reviewer` role (can edit gold files)
- `loginAsAnnotator()` - User with `annotator` role (can edit version files)
- `loginAsBasicUser()` - User with no special roles (limited permissions)
## Troubleshooting
### Missing Dependencies
If tests fail with "test failed" or module import errors, ensure all dependencies are installed:
```bash
npm install
```
This is particularly important for SSE tests which require the `eventsource-client` package.
## Debugging
### API Tests
```bash
# Verbose output
npm run test:api -- --verbose --grep "save"
# Keep server running for manual testing
npm run test:api -- --no-cleanup
# Keep database for inspection
npm run test:api -- --keep-db
```
### E2E Tests
```bash
# Show browser
npm run test:e2e:headed
# Step-through debugging (Playwright inspector)
npm run test:e2e:debug
# Post-mortem debugging (capture artifacts on failure, headless)
npm run test:e2e:debug-failure
npm run test:e2e:debug-failure -- --grep "new version"
# Combine with headed mode to watch test execution
npm run test:e2e -- --debug-on-failure --headed
# Add breakpoints in test code
await page.pause();
```
#### Debug-on-Failure Mode
When using `--debug-on-failure`, tests run in **headless mode** (faster) and capture comprehensive debug information on failure:
**Captured Artifacts:**
- Console messages → `console-messages.json`
- Page errors → `page-errors.json`
- **Network traffic → `network-log.json`** (all HTTP requests/responses with bodies)
- Screenshots (automatically via Playwright)
- Video recording of test execution
**Behavior:**
- Stops on first failure (`--max-failures=1`)
- Runs headless by default (use `--headed` flag to watch execution)
- All artifacts saved to `tests/e2e/test-results/<test-name>/`
**Network Log Format:**
```json
[
{
"type": "request",
"timestamp": "2025-01-15T10:30:00.000Z",
"method": "GET",
"url": "http://localhost:8000/api/v1/files",
"headers": {...},
"postData": null
},
{
"type": "response",
"timestamp": "2025-01-15T10:30:00.100Z",
"method": "GET",
"url": "http://localhost:8000/api/v1/files",
"status": 200,
"statusText": "OK",
"headers": {...},
"body": "{\"files\": [...]}"
}
]
```
**Note**: All E2E tests should import from the debug-on-failure fixture by default:
```javascript
import { test, expect } from '../fixtures/debug-on-failure.js';
```
This has no effect during normal test runs - it only activates when `--debug-on-failure` is used.
## JSDOM Limitations for Browser-Targeted Code
When testing JavaScript code designed for the browser using Node.js with JSDOM, be aware of these limitations:
### XPath Namespace Support
JSDOM has incomplete XPath support, particularly for namespace resolvers:
```javascript
// This works in browsers but may fail in JSDOM:
const result = xmlDoc.evaluate(
"//tei:bibl",
xmlDoc,
(prefix) => prefix === "tei" ? "http://www.tei-c.org/ns/1.0" : null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null
);
```
**Workaround:** Export helper functions that accept elements directly, bypassing XPath:
```javascript
// In the enhancement module:
export function execute(xmlDoc, currentState, configMap) {
const targetNode = xmlDoc.evaluate(xpath, ...); // Uses XPath
processElement(targetNode, xmlDoc);
}
export function processElement(element, xmlDoc) {
// Core logic - testable without XPath
}
// In tests:
import { processElement } from './enhancement.js';
const bibl = xmlDoc.getElementsByTagNameNS(TEI_NS, 'bibl')[0];
processElement(bibl, xmlDoc); // Direct element reference
```
### Other JSDOM Limitations
- **No `XPathResult` constants** - Must expose via `global.XPathResult = dom.window.XPathResult`
- **Limited `Range` and `Selection` APIs** - May not behave identically to browsers
- **No layout engine** - Properties like `offsetWidth`, `getBoundingClientRect()` return 0
- **Partial Web Component support** - Shadow DOM works but custom elements have limitations
### JSDOM Setup Pattern
```javascript
import { JSDOM } from 'jsdom';
const dom = new JSDOM();
global.window = dom.window;
global.document = dom.window.document;
global.Node = dom.window.Node;
global.Document = dom.window.Document;
global.DOMParser = dom.window.DOMParser;
global.XPathResult = dom.window.XPathResult; // For XPath-using code
```
### When to Use E2E Tests Instead
For code that heavily depends on browser-specific APIs, consider E2E tests with Playwright instead of Node.js unit tests:
- Complex XPath expressions
- Web Component interactions
- Layout-dependent behavior
- CSS styling logic
## Important Notes
- **API tests run against local server** - No containers needed, faster iteration
- **E2E tests use local server by default** - Containerized mode available but slower
- **Use `--keep-db`** - When debugging to preserve state between runs
- **Clean up locks** - Always release locks in test cleanup
- **Fixtures auto-load** - Defined in `tests/api/fixtures/` and `tests/e2e/fixtures/`
- **Check helpers first** - Look in `tests/*/helpers/` before implementing utilities
## Backend Authentication Requirements
When implementing FastAPI endpoints that require authentication:
```python
from ..lib.dependencies import get_current_user
from fastapi import Depends, HTTPException
def require_admin(current_user: Optional[dict] = Depends(get_current_user)) -> dict:
if not current_user:
raise HTTPException(status_code=401, detail="Authentication required")
# ... check admin role ...
return current_user
@router.get("/endpoint")
def endpoint(current_user: dict = Depends(require_admin)):
# Endpoint implementation
```
**Key Points**:
- Use `Depends(get_current_user)` to inject authentication
- FastAPI automatically handles session extraction from `X-Session-Id` header
- Session header is case-sensitive: `X-Session-Id`