Some checks are pending
Go Benchmark / Performance Regression Check (push) Waiting to run
Cerberus Integration / Cerberus Security Stack Integration (push) Waiting to run
Upload Coverage to Codecov / Backend Codecov Upload (push) Waiting to run
Upload Coverage to Codecov / Frontend Codecov Upload (push) Waiting to run
CodeQL - Analyze / CodeQL analysis (go) (push) Waiting to run
CodeQL - Analyze / CodeQL analysis (javascript-typescript) (push) Waiting to run
CrowdSec Integration / CrowdSec Bouncer Integration (push) Waiting to run
Docker Build, Publish & Test / build-and-push (push) Waiting to run
Docker Build, Publish & Test / Security Scan PR Image (push) Blocked by required conditions
Quality Checks / Auth Route Protection Contract (push) Waiting to run
Quality Checks / Codecov Trigger/Comment Parity Guard (push) Waiting to run
Quality Checks / Backend (Go) (push) Waiting to run
Quality Checks / Frontend (React) (push) Waiting to run
Rate Limit integration / Rate Limiting Integration (push) Waiting to run
Security Scan (PR) / Trivy Binary Scan (push) Waiting to run
Supply Chain Verification (PR) / Verify Supply Chain (push) Waiting to run
WAF integration / Coraza WAF Integration (push) Waiting to run
471 lines
13 KiB
Markdown
Executable File
471 lines
13 KiB
Markdown
Executable File
# 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 `<input>` 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.
|