Files
Charon/tests
GitHub Actions 1de29fe6fc fix(frontend): stabilize CrowdSec first-enable UX and guard empty-value regression
When CrowdSec is first enabled, the 10-60 second startup window caused
the toggle to immediately flicker back to unchecked, the card badge to
show 'Disabled' throughout startup, CrowdSecKeyWarning to flash before
bouncer registration completed, and CrowdSecConfig to show alarming
LAPI-not-ready banners to the user.

Root cause: the toggle, badge, and warning conditions all read from
stale sources (crowdsecStatus local state and status.crowdsec.enabled
server data) which neither reflects user intent during a pending mutation.

- Derive crowdsecChecked from crowdsecPowerMutation.variables during
  the pending window so the UI reflects intent immediately on click,
  not the lagging server state
- Show a 'Starting...' badge in warning variant throughout the startup
  window so the user knows the operation is in progress
- Suppress CrowdSecKeyWarning unconditionally while the mutation is
  pending, preventing the bouncer key alert from flashing before
  registration completes on the backend
- Broadcast the mutation's running state to the QueryClient cache via
  a synthetic crowdsec-starting key so CrowdSecConfig.tsx can read it
  without prop drilling
- In CrowdSecConfig, suppress the LAPI 'not running' (red) and
  'initializing' (yellow) banners while the startup broadcast is active,
  with a 90-second safety cap to prevent stale state from persisting
  if the tab is closed mid-mutation
- Add security.crowdsec.starting translation key to all five locales
- Add two backend regression tests confirming that empty-string setting
  values are accepted (not rejected by binding validation), preventing
  silent re-introduction of the Issue 4 bug
- Add nine RTL tests covering toggle stabilization, badge text, warning
  suppression, and LAPI banner suppression/expiry
- Add four Playwright E2E tests using route interception to simulate
  the startup delay in a real browser context

Fixes Issues 3 and 4 from the fresh-install bug report.
2026-03-18 16:57:23 +00:00
..
2026-03-04 18:34:49 +00:00
2026-03-04 18:34:49 +00:00
2026-03-04 18:34:49 +00:00
2026-03-04 18:34:49 +00:00
2026-03-04 18:34:49 +00:00
2026-03-04 18:34:49 +00:00
2026-03-04 18:34:49 +00:00
2026-03-04 18:34:49 +00:00
2026-03-04 18:34:49 +00:00
2026-03-04 18:34:49 +00:00
2026-03-04 18:34:49 +00:00
2026-03-04 18:34:49 +00:00

Charon E2E Test Suite

Playwright-based end-to-end tests for the Charon management interface.

Quick Links:


Running Tests

# 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

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)

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

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)

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

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

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

// 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)

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):

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):

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:

// 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:

// 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:

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

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

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

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:

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:
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):

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


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 for full CI configuration.


Questions? See docs/testing/ or open an issue.