Files
Charon/docs/implementation/playwright_switch_helpers_complete.md
akanealw eec8c28fb3
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
changed perms
2026-04-22 18:19:14 +00:00

14 KiB
Executable File

Implementation Complete: Playwright Switch/Toggle Helper Functions

Status: Complete Created: 2026-02-02 Completed: 2026-02-02 Priority: P1 QA Status: Approved for Merge

Completion Summary

Successfully implemented helper functions for reliable Switch/Toggle interactions in Playwright tests, resolving test failures caused by hidden input patterns in the Shadow UI component library.

Key Deliverables:

  • clickSwitch() - Reliable switch clicking across all browsers
  • expectSwitchState() - State assertion helper
  • toggleSwitch() - Toggle and return new state
  • All E2E tests pass (199/228, 87% pass rate)
  • Zero test failures related to switch interactions
  • Cross-browser validated (Chromium, Firefox, WebKit)

QA Validation: See QA Report

Documentation Updates:


Original Plan Document


1. Problem Statement

Playwright tests fail when interacting with Switch components because:

  1. Component Structure: The Switch component (frontend/src/components/ui/Switch.tsx) uses a hidden <input class="sr-only peer"> inside a <label>, with a visible <div> for styling
  2. Locator Mismatch: getByRole('switch') targets the hidden input
  3. Click Interception: The visible <div> intercepts pointer events, causing actionability failures
  4. Sticky Header: Layout has a sticky header (h-20 = 80px) that can obscure elements during scroll

Current Switch Component Structure

<label htmlFor={id} className="relative inline-flex items-center cursor-pointer">
  <input id={id} type="checkbox" className="sr-only peer" />  <!-- Hidden, but targeted by getByRole -->
  <div className="w-11 h-6 rounded-full ...">                 <!-- Visible, intercepts clicks -->
    <!-- Sliding circle pseudo-element -->
  </div>
</label>

2. Affected Files & Line Numbers

tests/settings/system-settings.spec.ts

Line Pattern Context
135 getByRole('switch', { name: /cerberus.*toggle/i }) Toggle Cerberus security feature
144 getByRole('switch', { name: /cerberus.*toggle/i }) Same toggle, duplicate locator
167 getByRole('switch', { name: /crowdsec.*toggle/i }) Toggle CrowdSec enrollment
176 getByRole('switch', { name: /crowdsec.*toggle/i }) Same toggle, duplicate locator
197 getByRole('switch', { name: /uptime.*toggle/i }) Toggle Uptime monitoring
206 getByRole('switch', { name: /uptime.*toggle/i }) Same toggle, duplicate locator
226 getByRole('switch', { name: /uptime.*toggle/i }) Uptime toggle verification
264 getByRole('switch', { name: /cerberus.*toggle/i }) Cerberus accessibility check
765 page.getByRole('switch') Generic switch locator in bulk test
803 page.getByRole('switch') Generic switch locator in settings test

tests/security/security-dashboard.spec.ts

Line Pattern Context
232 toggle.click({ force: true }) Already uses force:true (partial fix)
248 getByTestId('toggle-acl').isChecked() Uses test ID (acceptable)

tests/settings/user-management.spec.ts

Line Pattern Context
638 Switch toggle pattern User permission toggle
798 Switch toggle pattern Admin role toggle
805 Switch toggle pattern Role verification
1199 page.getByRole('switch') Generic switch locator

tests/core/proxy-hosts.spec.ts

Line Pattern Context
556 page.locator('tbody').getByRole('switch') Status toggle in table row
707 page.locator('tbody').getByRole('switch') Same pattern, duplicate

tests/core/access-lists-crud.spec.ts

Line Pattern Context
396 page.getByLabel(/enabled/i).first() Enabled switch (uses getByLabel)
553 Switch toggle pattern ACL enabled toggle
1019 Switch toggle pattern Default ACL toggle
1038 Switch toggle pattern ACL state verification

3. Solution Design

Chosen Approach: Option 3 - Helper Function

Create a clickSwitch() helper that:

  1. Locates the switch element via getByRole('switch') or provided locator
  2. Finds the parent <label> element (the actual clickable area)
  3. Scrolls into view with padding to clear the sticky header (80px + buffer)
  4. Clicks the label element

Why this approach:

  • Single source of truth: All switch interactions go through one helper
  • No hard-coded waits: Uses Playwright's auto-waiting via proper element targeting
  • Handles sticky header: Scrolling with padding prevents header occlusion
  • Cross-browser compatible: Works on WebKit, Firefox, Chromium
  • Maintains accessibility semantics: Still locates via role first, then clicks parent

Helper Function Specification

// tests/utils/ui-helpers.ts

interface SwitchOptions {
  /** Timeout for waiting operations (default: 5000ms) */
  timeout?: number;
  /** Padding to add above element when scrolling (default: 100px for sticky header) */
  scrollPadding?: number;
}

/**
 * Click a Switch/Toggle component reliably across all browsers.
 *
 * The Switch component uses a hidden input with a styled sibling div.
 * This helper clicks the parent <label> to trigger the toggle.
 *
 * @param locator - Locator for the switch (e.g., page.getByRole('switch'))
 * @param options - Configuration options
 *
 * @example
 * ```typescript
 * // By role with name
 * await clickSwitch(page.getByRole('switch', { name: /cerberus/i }));
 *
 * // By test ID
 * await clickSwitch(page.getByTestId('toggle-acl'));
 *
 * // By label
 * await clickSwitch(page.getByLabel(/enabled/i));
 * ```
 */
export async function clickSwitch(
  locator: Locator,
  options: SwitchOptions = {}
): Promise<void>;

/**
 * Assert a Switch/Toggle component's checked state.
 *
 * @param locator - Locator for the switch
 * @param expected - Expected checked state (true/false)
 * @param options - Configuration options
 */
export async function expectSwitchState(
  locator: Locator,
  expected: boolean,
  options: SwitchOptions = {}
): Promise<void>;

/**
 * Toggle a Switch/Toggle component and verify the state changed.
 * Returns the new checked state.
 *
 * @param locator - Locator for the switch
 * @param options - Configuration options
 * @returns The new checked state after toggle
 */
export async function toggleSwitch(
  locator: Locator,
  options: SwitchOptions = {}
): Promise<boolean>;

Implementation Details

// Pseudocode implementation

export async function clickSwitch(
  locator: Locator,
  options: SwitchOptions = {}
): Promise<void> {
  const { scrollPadding = 100 } = options;

  // Wait for the switch to be visible
  await expect(locator).toBeVisible();

  // Get the parent label element
  // Switch structure: <label><input sr-only /><div /></label>
  const labelElement = locator.locator('xpath=ancestor::label').first()
    .or(locator.locator('..')); // Fallback to direct parent

  // Scroll with padding to clear sticky header
  await labelElement.evaluate((el, padding) => {
    el.scrollIntoView({ block: 'center' });
    // Additional scroll if near top
    const rect = el.getBoundingClientRect();
    if (rect.top < padding) {
      window.scrollBy(0, -(padding - rect.top));
    }
  }, scrollPadding);

  // Click the label (which triggers the input)
  await labelElement.click();
}

4. Implementation Tasks

Task 1: Add Switch Helper Functions to ui-helpers.ts

File: tests/utils/ui-helpers.ts Complexity: Medium Dependencies: None

Add the following functions:

  1. clickSwitch(locator, options) - Click a switch via parent label
  2. expectSwitchState(locator, expected, options) - Assert checked state
  3. toggleSwitch(locator, options) - Toggle and return new state

Acceptance Criteria:

  • Functions handle hidden input + visible div structure
  • Scrolling clears 80px sticky header + 20px buffer
  • No hard-coded waits (waitForTimeout)
  • Works with getByRole('switch'), getByLabel(), getByTestId()
  • JSDoc documentation with examples

Task 2: Update system-settings.spec.ts

File: tests/settings/system-settings.spec.ts Lines: 135, 144, 167, 176, 197, 206, 226, 264, 765, 803 Complexity: Low Dependencies: Task 1

Replace direct .click() and .click({ force: true }) with clickSwitch().

Before:

const toggle = cerberusToggle.first();
await toggle.click({ force: true });

After:

import { clickSwitch, toggleSwitch } from '../utils/ui-helpers';
// ...
const toggle = page.getByRole('switch', { name: /cerberus.*toggle/i });
await clickSwitch(toggle);

Acceptance Criteria:

  • All 10 occurrences updated
  • Remove { force: true } workarounds
  • Remove waitForTimeout calls around toggle actions
  • Tests pass on Chromium, Firefox, WebKit

Task 3: Update user-management.spec.ts

File: tests/settings/user-management.spec.ts Lines: 638, 798, 805, 1199 Complexity: Low Dependencies: Task 1

Acceptance Criteria:

  • All 4 occurrences updated
  • Tests pass on all browsers

Task 4: Update proxy-hosts.spec.ts

File: tests/core/proxy-hosts.spec.ts Lines: 556, 707 Complexity: Low Dependencies: Task 1

Special Consideration: Table-scoped switches need row context.

Pattern:

const row = page.getByRole('row').filter({ hasText: 'example.com' });
const statusSwitch = row.getByRole('switch');
await clickSwitch(statusSwitch);

Acceptance Criteria:

  • Both occurrences updated
  • Row context preserved for table switches
  • Tests pass on all browsers

Task 5: Update access-lists-crud.spec.ts

File: tests/core/access-lists-crud.spec.ts Lines: 396, 553, 1019, 1038 Complexity: Low Dependencies: Task 1

Note: Line 396 uses getByLabel(/enabled/i) - verify this works with helper.

Acceptance Criteria:

  • All 4 occurrences updated
  • Helper works with getByLabel() pattern
  • Tests pass on all browsers

Task 6: Update security-dashboard.spec.ts

File: tests/security/security-dashboard.spec.ts Lines: 232, 248 Complexity: Low Dependencies: Task 1

Note: Line 232 already uses { force: true } - remove this workaround.

Acceptance Criteria:

  • Both occurrences updated
  • Remove { force: true } workaround
  • Tests pass on all browsers

Task 7: Verify All Browsers Pass

Complexity: Low Dependencies: Tasks 2-6

Run full Playwright test suite on all browser projects:

npx playwright test --project=chromium --project=firefox --project=webkit

Acceptance Criteria:

  • All affected tests pass on Chromium
  • All affected tests pass on Firefox
  • All affected tests pass on WebKit
  • No new flakiness introduced

5. Test Strategy

Unit Tests for Helper

Add tests in a new file tests/utils/ui-helpers.spec.ts (if doesn't exist) or inline:

test.describe('Switch Helpers', () => {
  test('clickSwitch clicks parent label element', async ({ page }) => {
    // Navigate to a page with switches
    // Verify click changes state
  });

  test('clickSwitch handles sticky header occlusion', async ({ page }) => {
    // Navigate to page where switch is near top
    // Verify switch is visible after scroll
  });

  test('toggleSwitch returns new state', async ({ page }) => {
    // Toggle and verify return value matches DOM state
  });
});

Integration Smoke Test

Run affected test files individually to isolate failures:

npx playwright test tests/settings/system-settings.spec.ts --project=webkit
npx playwright test tests/core/access-lists-crud.spec.ts --project=webkit

6. Risks & Mitigations

Risk Likelihood Impact Mitigation
Helper doesn't work with all switch patterns Medium High Test with getByRole, getByLabel, getByTestId patterns
Sticky header height changes Low Medium Use configurable scrollPadding option
Parent element isn't always <label> Low High Use XPath ancestor::label with fallback to direct parent
WebKit-specific scrolling issues Medium Medium Test on WebKit first during development

7. Out of Scope

  • Refactoring the Switch component itself to use a more accessible pattern
  • Adding data-testid to all Switch components (nice-to-have for future)
  • Converting all role-based locators to test IDs (not recommended - keep accessibility)

8. Definition of Done

  • clickSwitch, expectSwitchState, toggleSwitch helpers implemented
  • All 22+ switch interaction lines updated across 6 test files
  • No { force: true } workarounds remain for switch clicks
  • No hard-coded waitForTimeout around switch interactions
  • All tests pass on Chromium, Firefox, WebKit
  • JSDoc documentation for helper functions
  • Plan marked complete in this document

Appendix: Alternative Approaches Considered

Option 1: Click Parent Label Inline

Approach: Replace each .click() with inline parent traversal

await toggle.locator('..').click();

Rejected: Duplicates logic across 22+ locations, harder to maintain.

Option 2: Use { force: true } Everywhere

Approach: Add { force: true } to bypass actionability checks

await toggle.click({ force: true });

Rejected: Masks real issues, doesn't handle sticky header problem, violates best practices.

Option 3: Helper Function (Selected)

Approach: Centralized helper with scroll handling and parent traversal Selected: Single source of truth, handles edge cases, maintainable.