# Charon E2E Test Suite **Playwright-based end-to-end tests for the Charon management interface.** Quick Links: - 📖 [Complete Testing Documentation](../docs/testing/) - 📝 [E2E Test Writing Guide](../docs/testing/e2e-test-writing-guide.md) - 🐛 [Debugging Guide](../docs/testing/debugging-guide.md) --- ## Running Tests ```bash # All tests (Chromium only) npm run e2e # All browsers (Chromium, Firefox, WebKit) npm run e2e:all # Headed mode (visible browser) npm run e2e:headed # Single test file npx playwright test tests/settings/system-settings.spec.ts # Specific test by name npx playwright test --grep "Enable Cerberus" # Debug mode with inspector npx playwright test --debug # Generate code (record interactions) npx playwright codegen http://localhost:8080 ``` --- ## Project Structure ``` tests/ ├── core/ # Core application tests ├── dns-provider-crud.spec.ts # DNS provider CRUD tests ├── dns-provider-types.spec.ts # DNS provider type-specific tests ├── emergency-server/ # Emergency API tests ├── manual-dns-provider.spec.ts # Manual DNS provider tests ├── monitoring/ # Uptime monitoring tests ├── security/ # Security dashboard tests ├── security-enforcement/ # ACL, WAF, Rate Limiting enforcement tests ├── settings/ # Settings page tests │ └── system-settings.spec.ts # Feature flag toggle tests ├── tasks/ # Async task tests ├── utils/ # Test helper utilities │ ├── debug-logger.ts # Structured logging │ ├── test-steps.ts # Step and assertion helpers │ ├── ui-helpers.ts # UI interaction helpers (switches, toasts, forms) │ └── wait-helpers.ts # Wait/polling utilities (feature flags, API) ├── fixtures/ # Shared test fixtures ├── reporters/ # Custom Playwright reporters ├── auth.setup.ts # Global authentication setup └── global-setup.ts # Global test initialization ``` --- ## Available Helper Functions ### UI Interaction Helpers (`utils/ui-helpers.ts`) #### Switch/Toggle Components ```typescript import { clickSwitch, expectSwitchState, toggleSwitch } from './utils/ui-helpers'; // Click a switch reliably (handles hidden input pattern) await clickSwitch(page.getByRole('switch', { name: /cerberus/i })); // Assert switch state await expectSwitchState(switchLocator, true); // Checked await expectSwitchState(switchLocator, false); // Unchecked // Toggle and get new state const newState = await toggleSwitch(switchLocator); console.log(`Switch is now ${newState ? 'enabled' : 'disabled'}`); ``` **Why**: Switch components use a hidden `` with styled siblings. Direct clicks fail in WebKit/Firefox. #### Cross-Browser Form Field Locators (Phase 2) ```typescript import { getFormFieldByLabel } from './utils/ui-helpers'; // Basic usage const nameInput = getFormFieldByLabel(page, /name/i); await nameInput.fill('John Doe'); // With fallbacks for robust cross-browser support const scriptPath = getFormFieldByLabel( page, /script.*path/i, { placeholder: /dns-challenge\.sh/i, fieldId: 'field-script_path' } ); await scriptPath.fill('/usr/local/bin/dns-challenge.sh'); ``` **Why**: Browsers handle label association differently. This helper provides 4-tier fallback: 1. `getByLabel()` — Standard label association 2. `getByPlaceholder()` — Fallback to placeholder text 3. `locator('#id')` — Fallback to direct ID 4. `getByRole()` with proximity — Fallback to role + nearby label text **Impact**: Prevents timeout errors in Firefox/WebKit. #### Toast Notifications ```typescript import { waitForToast, getToastLocator } from './utils/ui-helpers'; // Wait for toast with text await waitForToast(page, /success/i, { type: 'success', timeout: 5000 }); // Get toast locator for custom assertions const toast = getToastLocator(page, /error/i, { type: 'error' }); await expect(toast).toBeVisible(); ``` ### Wait/Polling Helpers (`utils/wait-helpers.ts`) #### Feature Flag Propagation (Phase 2 Optimized) ```typescript import { waitForFeatureFlagPropagation } from './utils/wait-helpers'; // Wait for feature flag to propagate after toggle await clickSwitch(cerberusToggle); await waitForFeatureFlagPropagation(page, { 'cerberus.enabled': true }); // Wait for multiple flags await waitForFeatureFlagPropagation(page, { 'cerberus.enabled': false, 'crowdsec.enabled': false }, { timeout: 60000 }); ``` **Performance**: Includes conditional skip optimization — exits immediately if flags already match. #### API Responses ```typescript import { clickAndWaitForResponse, waitForAPIResponse } from './utils/wait-helpers'; // Click and wait for response atomically (prevents race conditions) const response = await clickAndWaitForResponse( page, saveButton, /\/api\/v1\/proxy-hosts/, { status: 200 } ); expect(response.ok()).toBeTruthy(); // Wait for response without interaction const response = await waitForAPIResponse(page, /\/api\/v1\/feature-flags/, { status: 200, timeout: 10000 }); ``` #### Retry with Exponential Backoff ```typescript import { retryAction } from './utils/wait-helpers'; // Retry action with backoff (2s, 4s, 8s) await retryAction(async () => { await clickSwitch(toggle); await waitForFeatureFlagPropagation(page, { 'flag': true }); }, { maxAttempts: 3, baseDelay: 2000 }); ``` #### Other Wait Utilities ```typescript // Wait for loading to complete await waitForLoadingComplete(page, { timeout: 10000 }); // Wait for modal dialog const modal = await waitForModal(page, /edit.*host/i); await modal.getByLabel(/domain/i).fill('example.com'); // Wait for table rows await waitForTableLoad(page, 'table', { minRows: 5 }); // Wait for WebSocket connection await waitForWebSocketConnection(page, /\/ws\/logs/); ``` ### Debug Helpers (`utils/debug-logger.ts`) ```typescript import { DebugLogger } from './utils/debug-logger'; const logger = new DebugLogger('test-name'); logger.step('Navigate to settings'); logger.network({ method: 'GET', url: '/api/v1/feature-flags', status: 200, elapsedMs: 123 }); logger.assertion('Cerberus toggle is visible', true); logger.error('Failed to load settings', new Error('Network timeout')); ``` --- ## Performance Best Practices (Phase 2) ### 1. Only Poll When State Changes ❌ **Before (Inefficient)**: ```typescript test.beforeEach(async ({ page }) => { // Polls even if flags already correct await waitForFeatureFlagPropagation(page, { 'cerberus.enabled': false }); }); test('Test', async ({ page }) => { await clickSwitch(toggle); await waitForFeatureFlagPropagation(page, { 'cerberus.enabled': true }); }); ``` ✅ **After (Optimized)**: ```typescript test.afterEach(async ({ request }) => { // Restore defaults once at end await request.post('/api/v1/settings/restore', { data: { module: 'system', defaults: true } }); }); test('Test', async ({ page }) => { // Test starts from defaults (no beforeEach poll needed) await clickSwitch(toggle); // Only poll when state changes await waitForFeatureFlagPropagation(page, { 'cerberus.enabled': true }); }); ``` **Impact**: Removed ~90% of unnecessary API calls. ### 2. Use Conditional Skip Optimization The helper automatically checks if flags are already in the expected state: ```typescript // If flags match, exits immediately (no polling) await waitForFeatureFlagPropagation(page, { 'cerberus.enabled': false }); // Console: "[POLL] Already in expected state - skipping poll" ``` **Impact**: ~50% reduction in polling iterations. ### 3. Request Coalescing for Parallel Workers Tests running in parallel share in-flight requests: ```typescript // Worker 0: Waits for {cerberus: false} // Worker 1: Waits for {cerberus: false} // Result: 1 polling loop per worker (cached promise), not 2 separate loops ``` **Cache Key Format**: `[worker_index]:[sorted_flags_json]` --- ## Test Isolation Pattern Always clean up in `afterEach`, not `beforeEach`: ```typescript test.describe('Feature', () => { test.afterEach(async ({ request }) => { // Restore defaults after each test await request.post('/api/v1/settings/restore', { data: { module: 'system', defaults: true } }); }); test('Test A', async ({ page }) => { // Starts from defaults (restored by previous test) // ...test logic... // Cleanup happens in afterEach }); test('Test B', async ({ page }) => { // Also starts from defaults }); }); ``` **Why**: Prevents test pollution and ensures each test starts from known state. --- ## Common Patterns ### Toggle Feature Flag ```typescript test('Enable feature', async ({ page }) => { const toggle = page.getByRole('switch', { name: /feature/i }); await test.step('Toggle on', async () => { await clickSwitch(toggle); await waitForFeatureFlagPropagation(page, { 'feature.enabled': true }); }); await test.step('Verify UI', async () => { await expectSwitchState(toggle, true); }); }); ``` ### Create Resource via API, Verify in UI ```typescript test('Create proxy host', async ({ page, testData }) => { await test.step('Create via API', async () => { const host = await testData.createProxyHost({ domain: 'example.com', forward_host: '192.168.1.100' }); }); await test.step('Verify in UI', async () => { await page.goto('/proxy-hosts'); await waitForResourceInUI(page, 'example.com'); }); }); ``` ### Wait for Async Task ```typescript test('Start long task', async ({ page }) => { await page.getByRole('button', { name: /start/i }).click(); // Wait for progress bar await waitForProgressComplete(page, { timeout: 30000 }); // Verify completion await expect(page.getByText(/complete/i)).toBeVisible(); }); ``` --- ## Cross-Browser Compatibility | Strategy | Purpose | Supported Browsers | |----------|---------|-------------------| | `getFormFieldByLabel()` | Form field location | ✅ Chromium ✅ Firefox ✅ WebKit | | `clickSwitch()` | Switch interaction | ✅ Chromium ✅ Firefox ✅ WebKit | | `getByRole()` | Semantic locators | ✅ Chromium ✅ Firefox ✅ WebKit | **Avoid**: - CSS selectors (brittle, browser-specific) - `{ force: true }` clicks (bypasses real user behavior) - `waitForTimeout()` (non-deterministic) --- ## Troubleshooting ### Test Fails in Firefox/WebKit Only **Symptom**: `TimeoutError: locator.fill: Timeout exceeded` **Cause**: Label matching differs between browsers. **Fix**: Use `getFormFieldByLabel()` with fallbacks: ```typescript const field = getFormFieldByLabel(page, /field name/i, { placeholder: /enter value/i }); ``` ### Feature Flag Polling Times Out **Symptom**: `Feature flag propagation timeout after 120 attempts` **Causes**: 1. Config reload overlay stuck visible 2. Backend not updating flags 3. Database transaction not committed **Fix**: 1. Check backend logs for PUT `/api/v1/feature-flags` errors 2. Check if overlay is stuck: `page.locator('[data-testid="config-reload-overlay"]').isVisible()` 3. Add retry wrapper: ```typescript await retryAction(async () => { await clickSwitch(toggle); await waitForFeatureFlagPropagation(page, { 'flag': true }); }); ``` ### Switch Click Intercepted **Symptom**: `click intercepted by overlay` **Cause**: Config reload overlay or sticky header blocking interaction. **Fix**: Use `clickSwitch()` (handles overlay automatically): ```typescript await clickSwitch(page.getByRole('switch', { name: /feature/i })); ``` --- ## Test Execution Metrics (Phase 2) | Metric | Before Phase 2 | After Phase 2 | Improvement | |--------|----------------|---------------|-------------| | System Settings Tests | 23 minutes | 16 minutes | 31% faster | | Feature Flag API Calls | ~300 calls | ~30 calls | 90% reduction | | Polling Iterations (avg) | 60 per test | 30 per test | 50% reduction | | Cross-Browser Pass Rate | 96% (Firefox flaky) | 100% (all browsers) | +4% | --- ## Documentation - **[Testing README](../docs/testing/README.md)** — Quick reference, debugging, VS Code tasks - **[E2E Test Writing Guide](../docs/testing/e2e-test-writing-guide.md)** — Comprehensive best practices - **[Debugging Guide](../docs/testing/debugging-guide.md)** — Troubleshooting guide - **[Security Helpers](../docs/testing/security-helpers.md)** — ACL/WAF/CrowdSec test utilities --- ## CI/CD Integration Tests run on every PR and push: - **Browsers**: Chromium, Firefox, WebKit - **Sharding**: 4 parallel workers per browser - **Artifacts**: Videos (on failure), traces, screenshots, logs - **Reports**: HTML report, GitHub Job Summary See [`.github/workflows/playwright.yml`](../.github/workflows/playwright.yml) for full CI configuration. --- **Questions?** See [docs/testing/](../docs/testing/) or open an issue.