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
518 lines
14 KiB
Markdown
Executable File
518 lines
14 KiB
Markdown
Executable File
# E2E Test Writing Guide
|
|
|
|
**Last Updated**: February 2, 2026
|
|
|
|
This guide provides best practices for writing maintainable, performant, and cross-browser compatible Playwright E2E tests for Charon.
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
- [Cross-Browser Compatibility](#cross-browser-compatibility)
|
|
- [Performance Best Practices](#performance-best-practices)
|
|
- [Feature Flag Testing](#feature-flag-testing)
|
|
- [Test Isolation](#test-isolation)
|
|
- [Common Patterns](#common-patterns)
|
|
- [Troubleshooting](#troubleshooting)
|
|
|
|
---
|
|
|
|
## Cross-Browser Compatibility
|
|
|
|
### Why It Matters
|
|
|
|
Charon E2E tests run across **Chromium**, **Firefox**, and **WebKit** (Safari engine). Browser differences in how they handle label association, form controls, and DOM queries can cause tests to pass in one browser but fail in others.
|
|
|
|
**Phase 2 Fix**: The `getFormFieldByLabel()` helper was added to address cross-browser label matching inconsistencies.
|
|
|
|
### Problem: Browser-Specific Label Handling
|
|
|
|
Different browsers handle `getByLabel()` differently:
|
|
|
|
- **Chromium**: Lenient label matching, searches visible text aggressively
|
|
- **Firefox**: Stricter matching, requires explicit `for` attribute or nesting
|
|
- **WebKit**: Strictest, often fails on complex label structures
|
|
|
|
**Example Failure**:
|
|
|
|
```typescript
|
|
// ❌ FRAGILE: Fails in Firefox/WebKit when label structure is complex
|
|
const scriptPath = page.getByLabel(/script.*path/i);
|
|
await scriptPath.fill('/path/to/script.sh');
|
|
```
|
|
|
|
**Error (Firefox/WebKit)**:
|
|
|
|
```
|
|
TimeoutError: locator.fill: Timeout 5000ms exceeded.
|
|
=========================== logs ===========================
|
|
waiting for getByLabel(/script.*path/i)
|
|
============================================================
|
|
```
|
|
|
|
### Solution: Multi-Tier Fallback Strategy
|
|
|
|
Use the `getFormFieldByLabel()` helper for robust cross-browser field location:
|
|
|
|
```typescript
|
|
import { getFormFieldByLabel } from '../utils/ui-helpers';
|
|
|
|
// ✅ ROBUST: 4-tier fallback strategy
|
|
const scriptPath = getFormFieldByLabel(
|
|
page,
|
|
/script.*path/i,
|
|
{
|
|
placeholder: /dns-challenge\.sh/i,
|
|
fieldId: 'field-script_path'
|
|
}
|
|
);
|
|
await scriptPath.fill('/path/to/script.sh');
|
|
```
|
|
|
|
**Fallback Chain**:
|
|
|
|
1. **Primary**: `getByLabel(labelPattern)` — Standard label association
|
|
2. **Fallback 1**: `getByPlaceholder(options.placeholder)` — Placeholder text match
|
|
3. **Fallback 2**: `locator('#' + options.fieldId)` — Direct ID selector
|
|
4. **Fallback 3**: Role-based with label proximity — `getByRole('textbox')` near label text
|
|
|
|
### When to Use `getFormFieldByLabel()`
|
|
|
|
✅ **Use when**:
|
|
|
|
- Form fields have complex label structures (nested elements, icons, tooltips)
|
|
- Tests fail in Firefox/WebKit but pass in Chromium
|
|
- Label text is dynamic or internationalized
|
|
- Multiple fields have similar labels
|
|
|
|
❌ **Don't use when**:
|
|
|
|
- Standard `getByLabel()` works reliably across all browsers
|
|
- Field has a unique `data-testid` or `name` attribute
|
|
- Field is the only one of its type on the page
|
|
|
|
---
|
|
|
|
## Performance Best Practices
|
|
|
|
### Avoid Unnecessary API Polling
|
|
|
|
**Problem**: Excessive API polling adds latency and increases flakiness.
|
|
|
|
**Before Phase 2 (❌ Inefficient)**:
|
|
|
|
```typescript
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.goto('/settings/system');
|
|
|
|
// ❌ BAD: Polls API even when flags are already correct
|
|
await waitForFeatureFlagPropagation(page, {
|
|
'cerberus.enabled': false,
|
|
'crowdsec.enabled': false
|
|
});
|
|
});
|
|
|
|
test('Enable Cerberus', async ({ page }) => {
|
|
const toggle = page.getByRole('switch', { name: /cerberus/i });
|
|
await clickSwitch(toggle);
|
|
|
|
// ❌ BAD: Another full polling cycle
|
|
await waitForFeatureFlagPropagation(page, {
|
|
'cerberus.enabled': true
|
|
});
|
|
});
|
|
```
|
|
|
|
**After Phase 2 (✅ Optimized)**:
|
|
|
|
```typescript
|
|
test.afterEach(async ({ page, request }) => {
|
|
// ✅ GOOD: Cleanup once at the end
|
|
await request.post('/api/v1/settings/restore', {
|
|
data: { module: 'system', defaults: true }
|
|
});
|
|
});
|
|
|
|
test('Enable Cerberus', async ({ page }) => {
|
|
const toggle = page.getByRole('switch', { name: /cerberus/i });
|
|
|
|
await test.step('Toggle Cerberus on', async () => {
|
|
await clickSwitch(toggle);
|
|
|
|
// ✅ GOOD: Only poll when state changes
|
|
await waitForFeatureFlagPropagation(page, {
|
|
'cerberus.enabled': true
|
|
});
|
|
});
|
|
|
|
await test.step('Verify toggle reflects new state', async () => {
|
|
await expectSwitchState(toggle, true);
|
|
});
|
|
});
|
|
```
|
|
|
|
### How Conditional Polling Works
|
|
|
|
The `waitForFeatureFlagPropagation()` helper includes an **early-exit optimization** (Phase 2 Fix 2.3):
|
|
|
|
```typescript
|
|
// Before polling, check if flags are already in expected state
|
|
const currentState = await page.evaluate(async () => {
|
|
const res = await fetch('/api/v1/feature-flags');
|
|
return res.json();
|
|
});
|
|
|
|
if (alreadyMatches(currentState, expectedFlags)) {
|
|
console.log('[POLL] Already in expected state - skipping poll');
|
|
return currentState; // Exit immediately
|
|
}
|
|
|
|
// Otherwise, start polling...
|
|
```
|
|
|
|
**Performance Impact**: ~50% reduction in polling iterations for tests that restore defaults in `afterEach`.
|
|
|
|
### Request Coalescing (Worker Isolation)
|
|
|
|
**Problem**: Parallel Playwright workers polling the same flag state cause redundant API calls.
|
|
|
|
**Solution**: The helper caches in-flight requests per worker:
|
|
|
|
```typescript
|
|
// Worker 1: Waits for {cerberus: false, crowdsec: false}
|
|
// Worker 2: Waits for {cerberus: false, crowdsec: false}
|
|
|
|
// Without coalescing: 2 separate polling loops (30+ API calls)
|
|
// With coalescing: 1 shared promise (15 API calls, cached per worker)
|
|
```
|
|
|
|
**Cache Key Format**:
|
|
|
|
```
|
|
[worker_index]:[sorted_flags_json]
|
|
```
|
|
|
|
**Example**:
|
|
|
|
```
|
|
Worker 0: "0:{\"feature.cerberus.enabled\":false,\"feature.crowdsec.enabled\":false}"
|
|
Worker 1: "1:{\"feature.cerberus.enabled\":false,\"feature.crowdsec.enabled\":false}"
|
|
```
|
|
|
|
---
|
|
|
|
## Feature Flag Testing
|
|
|
|
### When to Use `waitForFeatureFlagPropagation()`
|
|
|
|
✅ **Use when**:
|
|
|
|
- A test **toggles** a feature flag via the UI
|
|
- Backend state changes and you need to verify propagation
|
|
- Test depends on a specific flag state being active
|
|
|
|
❌ **Don't use when**:
|
|
|
|
- Setting up initial state in `beforeEach` (use API directly instead)
|
|
- Flags haven't changed since last verification
|
|
- Test doesn't modify flags
|
|
|
|
### Pattern: Cleanup in `afterEach`
|
|
|
|
**Best Practice**: Restore defaults at the end, not the beginning.
|
|
|
|
```typescript
|
|
test.describe('System Settings', () => {
|
|
test.afterEach(async ({ request }) => {
|
|
// Restore all defaults once
|
|
await request.post('/api/v1/settings/restore', {
|
|
data: { module: 'system', defaults: true }
|
|
});
|
|
});
|
|
|
|
test('Enable and disable Cerberus', async ({ page }) => {
|
|
await page.goto('/settings/system');
|
|
|
|
const toggle = page.getByRole('switch', { name: /cerberus/i });
|
|
|
|
// Test starts from whatever state exists (defaults expected)
|
|
await clickSwitch(toggle);
|
|
await waitForFeatureFlagPropagation(page, { 'cerberus.enabled': true });
|
|
|
|
await clickSwitch(toggle);
|
|
await waitForFeatureFlagPropagation(page, { 'cerberus.enabled': false });
|
|
});
|
|
});
|
|
```
|
|
|
|
**Why This Works**:
|
|
|
|
- Each test starts from known defaults (restored by previous test's `afterEach`)
|
|
- No unnecessary polling in `beforeEach`
|
|
- Cleanup happens once, not N times per describe block
|
|
|
|
### Handling Config Reload Overlay
|
|
|
|
When toggling security features (Cerberus, ACL, WAF), Caddy reloads its configuration. A blocking overlay prevents interactions during this reload.
|
|
|
|
**Helper Handles This Automatically**:
|
|
|
|
```typescript
|
|
export async function waitForFeatureFlagPropagation(...) {
|
|
// ✅ Wait for overlay to disappear before polling
|
|
const overlay = page.locator('[data-testid="config-reload-overlay"]');
|
|
await overlay.waitFor({ state: 'hidden', timeout: 10000 })
|
|
.catch(() => {});
|
|
|
|
// Now safe to poll API...
|
|
}
|
|
```
|
|
|
|
**You don't need to manually wait for the overlay** — it's handled by:
|
|
|
|
- `clickSwitch()`
|
|
- `clickAndWaitForResponse()`
|
|
- `waitForFeatureFlagPropagation()`
|
|
|
|
---
|
|
|
|
## Test Isolation
|
|
|
|
### Why Isolation Matters
|
|
|
|
Tests running in parallel can interfere with each other if they:
|
|
|
|
- Share mutable state (database, config files, feature flags)
|
|
- Don't clean up resources
|
|
- Rely on global defaults
|
|
|
|
**Phase 2 Fix**: Added explicit `afterEach` cleanup to restore defaults.
|
|
|
|
### Pattern: Isolated Flag Toggles
|
|
|
|
**Before (❌ Not Isolated)**:
|
|
|
|
```typescript
|
|
test('Test A', async ({ page }) => {
|
|
// Enable Cerberus
|
|
// ...
|
|
// ❌ Leaves flag enabled for next test
|
|
});
|
|
|
|
test('Test B', async ({ page }) => {
|
|
// Assumes Cerberus is disabled
|
|
// ❌ May fail if Test A ran first
|
|
});
|
|
```
|
|
|
|
**After (✅ Isolated)**:
|
|
|
|
```typescript
|
|
test.afterEach(async ({ request }) => {
|
|
await request.post('/api/v1/settings/restore', {
|
|
data: { module: 'system', defaults: true }
|
|
});
|
|
});
|
|
|
|
test('Test A', async ({ page }) => {
|
|
// Enable Cerberus
|
|
// ...
|
|
// ✅ Cleanup restores defaults after test
|
|
});
|
|
|
|
test('Test B', async ({ page }) => {
|
|
// ✅ Starts from known defaults
|
|
});
|
|
```
|
|
|
|
### Cleanup Order of Operations
|
|
|
|
```
|
|
1. Test A runs → modifies state
|
|
2. Test A finishes → afterEach runs → restores defaults
|
|
3. Test B runs → starts from defaults
|
|
4. Test B finishes → afterEach runs → restores defaults
|
|
```
|
|
|
|
---
|
|
|
|
## Common Patterns
|
|
|
|
### Toggle Feature Flag
|
|
|
|
```typescript
|
|
test('Enable and verify feature', async ({ page }) => {
|
|
await page.goto('/settings/system');
|
|
|
|
const toggle = page.getByRole('switch', { name: /feature name/i });
|
|
|
|
await test.step('Enable feature', async () => {
|
|
await clickSwitch(toggle);
|
|
await waitForFeatureFlagPropagation(page, { 'feature.enabled': true });
|
|
});
|
|
|
|
await test.step('Verify UI reflects state', async () => {
|
|
await expectSwitchState(toggle, true);
|
|
await expect(page.getByText(/feature active/i)).toBeVisible();
|
|
});
|
|
});
|
|
```
|
|
|
|
### Form Field with Cross-Browser Locator
|
|
|
|
```typescript
|
|
test('Fill DNS provider config', async ({ page }) => {
|
|
await page.goto('/dns-providers/new');
|
|
|
|
await test.step('Select provider type', async () => {
|
|
await page.getByRole('combobox', { name: /type/i }).click();
|
|
await page.getByRole('option', { name: /manual/i }).click();
|
|
});
|
|
|
|
await test.step('Fill script path', async () => {
|
|
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');
|
|
});
|
|
});
|
|
```
|
|
|
|
### Wait for API Response After Action
|
|
|
|
```typescript
|
|
test('Create resource and verify', async ({ page }) => {
|
|
await page.goto('/resources');
|
|
|
|
const createBtn = page.getByRole('button', { name: /create/i });
|
|
|
|
const response = await clickAndWaitForResponse(
|
|
page,
|
|
createBtn,
|
|
/\/api\/v1\/resources/,
|
|
{ status: 201 }
|
|
);
|
|
|
|
expect(response.ok()).toBeTruthy();
|
|
|
|
const json = await response.json();
|
|
await expect(page.getByText(json.name)).toBeVisible();
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
### Test Fails in Firefox/WebKit, Passes in Chromium
|
|
|
|
**Symptom**: `TimeoutError: locator.fill: Timeout 5000ms exceeded`
|
|
|
|
**Cause**: Label matching strategy differs between browsers.
|
|
|
|
**Fix**: Use `getFormFieldByLabel()` with fallbacks:
|
|
|
|
```typescript
|
|
// ❌ BEFORE
|
|
await page.getByLabel(/field name/i).fill('value');
|
|
|
|
// ✅ AFTER
|
|
const field = getFormFieldByLabel(page, /field name/i, {
|
|
placeholder: /enter value/i
|
|
});
|
|
await field.fill('value');
|
|
```
|
|
|
|
### Feature Flag Polling Times Out
|
|
|
|
**Symptom**: `Feature flag propagation timeout after 120 attempts (60000ms)`
|
|
|
|
**Causes**:
|
|
|
|
1. Backend not updating flags
|
|
2. Config reload overlay blocking UI
|
|
3. Database transaction not committed
|
|
|
|
**Fix Steps**:
|
|
|
|
1. Check backend logs: Does PUT `/api/v1/feature-flags` succeed?
|
|
2. Check overlay state: Is `[data-testid="config-reload-overlay"]` stuck visible?
|
|
3. Increase timeout temporarily: `waitForFeatureFlagPropagation(page, flags, { timeout: 120000 })`
|
|
4. Add retry wrapper: Use `retryAction()` for transient failures
|
|
|
|
```typescript
|
|
await retryAction(async () => {
|
|
await clickSwitch(toggle);
|
|
await waitForFeatureFlagPropagation(page, { 'flag': true });
|
|
}, { maxAttempts: 3, baseDelay: 2000 });
|
|
```
|
|
|
|
### Switch Click Intercepted
|
|
|
|
**Symptom**: `Error: Element is not visible` or `click intercepted by overlay`
|
|
|
|
**Cause**: Config reload overlay or sticky header blocking interaction.
|
|
|
|
**Fix**: Use `clickSwitch()` helper (handles overlay automatically):
|
|
|
|
```typescript
|
|
// ❌ BEFORE
|
|
await page.getByRole('switch').click({ force: true }); // Bad!
|
|
|
|
// ✅ AFTER
|
|
await clickSwitch(page.getByRole('switch', { name: /feature/i }));
|
|
```
|
|
|
|
### Test Pollution (Fails When Run in Suite, Passes Alone)
|
|
|
|
**Symptom**: Test passes when run solo (`--grep`), fails in full suite.
|
|
|
|
**Cause**: Previous test left state modified (flags enabled, resources created).
|
|
|
|
**Fix**: Add cleanup in `afterEach`:
|
|
|
|
```typescript
|
|
test.afterEach(async ({ request }) => {
|
|
// Restore defaults
|
|
await request.post('/api/v1/settings/restore', {
|
|
data: { module: 'system', defaults: true }
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Reference
|
|
|
|
### Helper Functions
|
|
|
|
| Helper | Purpose | File |
|
|
|--------|---------|------|
|
|
| `getFormFieldByLabel()` | Cross-browser form field locator | `tests/utils/ui-helpers.ts` |
|
|
| `clickSwitch()` | Reliable switch/toggle interaction | `tests/utils/ui-helpers.ts` |
|
|
| `expectSwitchState()` | Assert switch checked state | `tests/utils/ui-helpers.ts` |
|
|
| `waitForFeatureFlagPropagation()` | Poll for flag state | `tests/utils/wait-helpers.ts` |
|
|
| `clickAndWaitForResponse()` | Atomic click + wait | `tests/utils/wait-helpers.ts` |
|
|
| `retryAction()` | Retry with exponential backoff | `tests/utils/wait-helpers.ts` |
|
|
|
|
### Best Practices Summary
|
|
|
|
1. ✅ **Cross-Browser**: Use `getFormFieldByLabel()` for complex label structures
|
|
2. ✅ **Performance**: Only poll when flags change, not in `beforeEach`
|
|
3. ✅ **Isolation**: Restore defaults in `afterEach`, not `beforeEach`
|
|
4. ✅ **Reliability**: Use semantic locators (`getByRole`, `getByLabel`) over CSS selectors
|
|
5. ✅ **Debugging**: Use `test.step()` for clear failure context
|
|
|
|
---
|
|
|
|
**See Also**:
|
|
|
|
- [Testing README](./README.md) — Quick reference and debugging guide
|
|
- [Switch Component Testing](./README.md#-switchtoggle-component-testing) — Detailed switch patterns
|
|
- [Debugging Guide](./debugging-guide.md) — Troubleshooting slow/flaky tests
|