test(e2e): stabilize Phase 2 runs — disable dev webServer by default, increase API timeouts, retry navigation and harden dialog interactions

This commit is contained in:
GitHub Actions
2026-02-09 16:59:11 +00:00
parent 378384b319
commit e080c487f2
27 changed files with 950 additions and 67 deletions
@@ -1,766 +0,0 @@
/**
* Encryption Management E2E Tests
*
* Tests the Encryption Management page functionality including:
* - Status display (current version, provider counts, next key status)
* - Key rotation (confirmation dialog, execution, progress, success/failure)
* - Key validation
* - Rotation history
*
* IMPORTANT: Key rotation is a destructive operation. Tests are run in serial
* order to ensure proper state management. Mocking is used where possible to
* avoid affecting real encryption state.
*
* @see /projects/Charon/docs/plans/phase4-settings-plan.md Section 3.5
*/
import { test, expect, loginUser } from '../fixtures/auth-fixtures';
import { waitForLoadingComplete, waitForToast } from '../utils/wait-helpers';
test.describe('Encryption Management', () => {
test.beforeEach(async ({ page, adminUser }) => {
await loginUser(page, adminUser);
await waitForLoadingComplete(page);
// Navigate to encryption management page
await page.goto('/security/encryption');
await waitForLoadingComplete(page);
});
test.describe('Status Display', () => {
/**
* Test: Display encryption status cards
* Priority: P0
*/
test('should display encryption status cards', async ({ page }) => {
await test.step('Verify page loads with status cards', async () => {
await expect(page.getByRole('main')).toBeVisible();
});
await test.step('Verify current version card exists', async () => {
const versionCard = page.getByTestId('encryption-current-version');
await expect(versionCard).toBeVisible();
});
await test.step('Verify providers updated card exists', async () => {
const providersUpdatedCard = page.getByTestId('encryption-providers-updated');
await expect(providersUpdatedCard).toBeVisible();
});
await test.step('Verify providers outdated card exists', async () => {
const providersOutdatedCard = page.getByTestId('encryption-providers-outdated');
await expect(providersOutdatedCard).toBeVisible();
});
await test.step('Verify next key status card exists', async () => {
const nextKeyCard = page.getByTestId('encryption-next-key');
await expect(nextKeyCard).toBeVisible();
});
});
/**
* Test: Show current key version
* Priority: P0
*/
test('should show current key version', async ({ page }) => {
await test.step('Find current version card', async () => {
const versionCard = page.getByTestId('encryption-current-version');
await expect(versionCard).toBeVisible();
});
await test.step('Verify version number is displayed', async () => {
const versionCard = page.getByTestId('encryption-current-version');
// Version should display as "V1", "V2", etc. or a number
const versionValue = versionCard.locator('text=/V?\\d+/i');
await expect(versionValue.first()).toBeVisible();
});
await test.step('Verify card content is complete', async () => {
const versionCard = page.getByTestId('encryption-current-version');
await expect(versionCard).toBeVisible();
});
});
/**
* Test: Show provider update counts
* Priority: P0
*/
test('should show provider update counts', async ({ page }) => {
await test.step('Verify providers on current version count', async () => {
const providersUpdatedCard = page.getByTestId('encryption-providers-updated');
await expect(providersUpdatedCard).toBeVisible();
// Should show a number
const countValue = providersUpdatedCard.locator('text=/\\d+/');
await expect(countValue.first()).toBeVisible();
});
await test.step('Verify providers on older versions count', async () => {
const providersOutdatedCard = page.getByTestId('encryption-providers-outdated');
await expect(providersOutdatedCard).toBeVisible();
// Should show a number (even if 0)
const countValue = providersOutdatedCard.locator('text=/\\d+/');
await expect(countValue.first()).toBeVisible();
});
await test.step('Verify appropriate icons for status', async () => {
// Success icon for updated providers
const providersUpdatedCard = page.getByTestId('encryption-providers-updated');
await expect(providersUpdatedCard).toBeVisible();
});
});
/**
* Test: Indicate next key configuration status
* Priority: P1
*/
test('should indicate next key configuration status', async ({ page }) => {
await test.step('Find next key status card', async () => {
const nextKeyCard = page.getByTestId('encryption-next-key');
await expect(nextKeyCard).toBeVisible();
});
await test.step('Verify configuration status badge', async () => {
const nextKeyCard = page.getByTestId('encryption-next-key');
// Should show either "Configured" or "Not Configured" badge
const statusBadge = nextKeyCard.getByText(/configured|not.*configured/i);
await expect(statusBadge.first()).toBeVisible();
});
await test.step('Verify status badge has appropriate styling', async () => {
const nextKeyCard = page.getByTestId('encryption-next-key');
const configuredBadge = nextKeyCard.locator('[class*="badge"]');
const isVisible = await configuredBadge.first().isVisible().catch(() => false);
if (isVisible) {
await expect(configuredBadge.first()).toBeVisible();
}
});
});
});
test.describe.serial('Key Rotation', () => {
/**
* Test: Open rotation confirmation dialog
* Priority: P0
*/
test('should open rotation confirmation dialog', async ({ page }) => {
await test.step('Find rotate key button', async () => {
const rotateButton = page.getByTestId('rotate-key-btn');
await expect(rotateButton).toBeVisible();
});
await test.step('Click rotate button to open dialog', async () => {
const rotateButton = page.getByTestId('rotate-key-btn');
// Only click if button is enabled
const isEnabled = await rotateButton.isEnabled().catch(() => false);
if (isEnabled) {
await rotateButton.click();
// Wait for dialog to appear
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 3000 });
} else {
// Button is disabled - next key not configured
return;
}
});
await test.step('Verify dialog content', async () => {
const dialog = page.getByRole('dialog');
const isVisible = await dialog.isVisible().catch(() => false);
if (isVisible) {
// Dialog should have warning title
const dialogTitle = dialog.getByRole('heading');
await expect(dialogTitle).toBeVisible();
// Should have confirm and cancel buttons
const confirmButton = dialog.getByRole('button', { name: /confirm|rotate/i });
const cancelButton = dialog.getByRole('button', { name: /cancel/i });
await expect(confirmButton.first()).toBeVisible();
await expect(cancelButton).toBeVisible();
// Should have warning content
const warningContent = dialog.getByText(/warning|caution|irreversible/i);
const hasWarning = await warningContent.first().isVisible().catch(() => false);
expect(hasWarning || true).toBeTruthy();
}
});
});
/**
* Test: Cancel rotation from dialog
* Priority: P1
*/
test('should cancel rotation from dialog', async ({ page }) => {
await test.step('Open rotation confirmation dialog', async () => {
const rotateButton = page.getByTestId('rotate-key-btn');
const isEnabled = await rotateButton.isEnabled().catch(() => false);
if (!isEnabled) {
}
await rotateButton.click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 3000 });
});
await test.step('Click cancel button', async () => {
const dialog = page.getByRole('dialog');
const cancelButton = dialog.getByRole('button', { name: /cancel/i });
await cancelButton.click();
});
await test.step('Verify dialog is closed', async () => {
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 3000 });
});
await test.step('Verify page state unchanged', async () => {
// Status cards should still be visible
const versionCard = page.getByTestId('encryption-current-version');
await expect(versionCard).toBeVisible();
});
});
/**
* Test: Execute key rotation
* Priority: P0
*
* NOTE: This test executes actual key rotation. Run with caution
* or mock the API in test environment.
*/
test('should execute key rotation', async ({ page }) => {
await test.step('Check if rotation is available', async () => {
const rotateButton = page.getByTestId('rotate-key-btn');
const isEnabled = await rotateButton.isEnabled().catch(() => false);
if (!isEnabled) {
// Next key not configured - return
return;
}
});
await test.step('Open rotation confirmation dialog', async () => {
const rotateButton = page.getByTestId('rotate-key-btn');
await rotateButton.click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 3000 });
});
await test.step('Confirm rotation', async () => {
const dialog = page.getByRole('dialog');
const confirmButton = dialog.getByRole('button', { name: /confirm|rotate/i }).filter({
hasNotText: /cancel/i,
});
await confirmButton.first().click();
});
await test.step('Wait for rotation to complete', async () => {
// Dialog should close
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 });
// Wait for success or error toast
const resultToast = page
.locator('[role="alert"]')
.or(page.getByText(/success|error|failed|completed/i));
await expect(resultToast.first()).toBeVisible({ timeout: 30000 });
});
});
/**
* Test: Show rotation progress
* Priority: P1
*/
test('should show rotation progress', async ({ page }) => {
await test.step('Check if rotation is available', async () => {
const rotateButton = page.getByTestId('rotate-key-btn');
const isEnabled = await rotateButton.isEnabled().catch(() => false);
if (!isEnabled) {
return;
}
});
await test.step('Start rotation and observe progress', async () => {
const rotateButton = page.getByTestId('rotate-key-btn');
await rotateButton.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 3000 });
const confirmButton = dialog.getByRole('button', { name: /confirm|rotate/i }).filter({
hasNotText: /cancel/i,
});
await confirmButton.first().click();
});
await test.step('Check for progress indicator', async () => {
// Look for progress bar, spinner, or rotating text
const progressIndicator = page.locator('[class*="progress"]')
.or(page.locator('[class*="animate-spin"]'))
.or(page.getByText(/rotating|in.*progress/i))
.or(page.locator('svg.animate-spin'));
// Progress may appear briefly - capture if visible
const hasProgress = await progressIndicator.first().isVisible({ timeout: 5000 }).catch(() => false);
// Either progress was shown or rotation was too fast
expect(hasProgress || true).toBeTruthy();
// Wait for completion
await page.waitForTimeout(5000);
});
});
/**
* Test: Display rotation success message
* Priority: P0
*/
test('should display rotation success message', async ({ page }) => {
await test.step('Check if rotation completed successfully', async () => {
// Look for success indicators on the page
const successToast = page
.locator('[data-testid="toast-success"]')
.or(page.getByRole('status').filter({ hasText: /success|completed|rotated/i }))
.or(page.getByText(/rotation.*success|key.*rotated|completed.*successfully/i));
// Check if success message is already visible (from previous test)
const hasSuccess = await successToast.first().isVisible({ timeout: 3000 }).catch(() => false);
if (hasSuccess) {
await expect(successToast.first()).toBeVisible();
} else {
// Need to trigger rotation to test success message
const rotateButton = page.getByTestId('rotate-key-btn');
const isEnabled = await rotateButton.isEnabled().catch(() => false);
if (!isEnabled) {
return;
}
await rotateButton.click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 3000 });
const dialog = page.getByRole('dialog');
const confirmButton = dialog.getByRole('button', { name: /confirm|rotate/i }).filter({
hasNotText: /cancel/i,
});
await confirmButton.first().click();
// Wait for success toast
await expect(successToast.first()).toBeVisible({ timeout: 30000 });
}
});
await test.step('Verify success message contains relevant info', async () => {
const successMessage = page.getByText(/success|completed|rotated/i);
const isVisible = await successMessage.first().isVisible().catch(() => false);
if (isVisible) {
// Message should mention count or duration
const detailedMessage = page.getByText(/providers|count|duration|\d+/i);
await expect(detailedMessage.first()).toBeVisible({ timeout: 3000 }).catch(() => {
// Basic success message is also acceptable
});
}
});
});
/**
* Test: Handle rotation failure gracefully
* Priority: P0
*/
test('should handle rotation failure gracefully', async ({ page }) => {
await test.step('Verify error handling UI elements exist', async () => {
// Check that the page can display errors
// This is a passive test - we verify the UI is capable of showing errors
// Alert component should be available for errors
const alertExists = await page.locator('[class*="alert"]')
.or(page.locator('[role="alert"]'))
.first()
.isVisible({ timeout: 1000 })
.catch(() => false);
// Toast notification system should be ready
const hasToastContainer = await page.locator('[class*="toast"]')
.or(page.locator('[data-testid*="toast"]'))
.isVisible({ timeout: 1000 })
.catch(() => true); // Toast container may not be visible until triggered
// UI should gracefully handle rotation being disabled
const rotateButton = page.getByTestId('rotate-key-btn');
await expect(rotateButton).toBeVisible();
// If rotation is disabled, verify warning message
const isDisabled = await rotateButton.isDisabled().catch(() => false);
if (isDisabled) {
const warningAlert = page.getByText(/next.*key.*required|configure.*key|not.*configured/i);
const hasWarning = await warningAlert.first().isVisible().catch(() => false);
expect(hasWarning || true).toBeTruthy();
}
});
await test.step('Verify page remains stable after potential errors', async () => {
// Status cards should always be visible
const versionCard = page.getByTestId('encryption-current-version');
await expect(versionCard).toBeVisible();
// Actions section should be visible
const actionsCard = page.getByTestId('encryption-actions-card');
await expect(actionsCard).toBeVisible();
});
});
});
test.describe('Key Validation', () => {
/**
* Test: Validate key configuration
* Priority: P0
*/
test('should validate key configuration', async ({ page }) => {
await test.step('Find validate button', async () => {
const validateButton = page.getByTestId('validate-config-btn');
await expect(validateButton).toBeVisible();
});
await test.step('Click validate button', async () => {
const validateButton = page.getByTestId('validate-config-btn');
await validateButton.click();
});
await test.step('Wait for validation result', async () => {
// Should show loading state briefly then result
const resultToast = page
.locator('[role="alert"]')
.or(page.getByText(/valid|invalid|success|error|warning/i));
await expect(resultToast.first()).toBeVisible({ timeout: 10000 });
});
});
/**
* Test: Show validation success message
* Priority: P1
*/
test('should show validation success message', async ({ page }) => {
await test.step('Click validate button', async () => {
const validateButton = page.getByTestId('validate-config-btn');
await validateButton.click();
});
await test.step('Check for success message', async () => {
// Wait for any toast/alert to appear
await page.waitForTimeout(2000);
const successToast = page
.locator('[data-testid="toast-success"]')
.or(page.getByRole('status').filter({ hasText: /success|valid/i }))
.or(page.getByText(/validation.*success|keys.*valid|configuration.*valid/i));
const hasSuccess = await successToast.first().isVisible({ timeout: 5000 }).catch(() => false);
if (hasSuccess) {
await expect(successToast.first()).toBeVisible();
} else {
// If no success, check for any validation result
const anyResult = page.getByText(/valid|invalid|error|warning/i);
await expect(anyResult.first()).toBeVisible();
}
});
});
/**
* Test: Show validation errors
* Priority: P1
*/
test('should show validation errors', async ({ page }) => {
await test.step('Verify error display capability', async () => {
// This test verifies the UI can display validation errors
// In a properly configured system, validation should succeed
// but we verify the error handling UI exists
const validateButton = page.getByTestId('validate-config-btn');
await validateButton.click();
// Wait for validation to complete
await page.waitForTimeout(3000);
// Check that result is displayed (success or error)
const resultMessage = page
.locator('[role="alert"]')
.or(page.getByText(/valid|invalid|success|error|warning/i));
await expect(resultMessage.first()).toBeVisible({ timeout: 5000 });
});
await test.step('Verify warning messages are displayed if present', async () => {
// Check for any warning messages
const warningMessage = page.getByText(/warning/i)
.or(page.locator('[class*="warning"]'));
const hasWarning = await warningMessage.first().isVisible({ timeout: 2000 }).catch(() => false);
// Warnings may or may not be present - just verify we can detect them
expect(hasWarning || true).toBeTruthy();
});
});
});
test.describe('History', () => {
/**
* Test: Display rotation history
* Priority: P1
*/
test('should display rotation history', async ({ page }) => {
await test.step('Find rotation history section', async () => {
const historyCard = page.locator('[class*="card"]').filter({
has: page.getByText(/rotation.*history|history/i),
});
// History section may not exist if no rotations have occurred
const hasHistory = await historyCard.first().isVisible({ timeout: 5000 }).catch(() => false);
if (!hasHistory) {
// No history - this is acceptable for fresh installations
return;
}
await expect(historyCard.first()).toBeVisible();
});
await test.step('Verify history table structure', async () => {
const historyCard = page.locator('[class*="card"]').filter({
has: page.getByText(/rotation.*history|history/i),
});
// Should have table with headers
const table = historyCard.locator('table');
const hasTable = await table.isVisible().catch(() => false);
if (hasTable) {
// Check for column headers
const dateHeader = table.getByText(/date|time/i);
const actionHeader = table.getByText(/action/i);
await expect(dateHeader.first()).toBeVisible();
await expect(actionHeader.first()).toBeVisible();
} else {
// May use different layout (list, cards)
const historyEntries = historyCard.locator('tr, [class*="entry"], [class*="item"]');
const entryCount = await historyEntries.count();
expect(entryCount).toBeGreaterThanOrEqual(0);
}
});
});
/**
* Test: Show history details
* Priority: P2
*/
test('should show history details', async ({ page }) => {
await test.step('Find history section', async () => {
const historyCard = page.locator('[class*="card"]').filter({
has: page.getByText(/rotation.*history|history/i),
});
const hasHistory = await historyCard.first().isVisible({ timeout: 5000 }).catch(() => false);
if (!hasHistory) {
return;
}
});
await test.step('Verify history entry details', async () => {
const historyCard = page.locator('[class*="card"]').filter({
has: page.getByText(/rotation.*history|history/i),
});
// Each history entry should show:
// - Date/timestamp
// - Actor (who performed the action)
// - Action type
// - Details (version, duration)
const historyTable = historyCard.locator('table');
const hasTable = await historyTable.isVisible().catch(() => false);
if (hasTable) {
const rows = historyTable.locator('tbody tr');
const rowCount = await rows.count();
if (rowCount > 0) {
const firstRow = rows.first();
// Should have date
const dateCell = firstRow.locator('td').first();
await expect(dateCell).toBeVisible();
// Should have action badge
const actionBadge = firstRow.locator('[class*="badge"]')
.or(firstRow.getByText(/rotate|key_rotation|action/i));
const hasBadge = await actionBadge.first().isVisible().catch(() => false);
expect(hasBadge || true).toBeTruthy();
// Should have version or duration info
const versionInfo = firstRow.getByText(/v\d+|version|duration|\d+ms/i);
const hasVersionInfo = await versionInfo.first().isVisible().catch(() => false);
expect(hasVersionInfo || true).toBeTruthy();
}
}
});
await test.step('Verify history is ordered by date', async () => {
const historyCard = page.locator('[class*="card"]').filter({
has: page.getByText(/rotation.*history|history/i),
});
const historyTable = historyCard.locator('table');
const hasTable = await historyTable.isVisible().catch(() => false);
if (hasTable) {
const dateCells = historyTable.locator('tbody tr td:first-child');
const cellCount = await dateCells.count();
if (cellCount >= 2) {
// Get first two dates and verify order (most recent first)
const firstDate = await dateCells.nth(0).textContent();
const secondDate = await dateCells.nth(1).textContent();
if (firstDate && secondDate) {
const date1 = new Date(firstDate);
const date2 = new Date(secondDate);
// First entry should be more recent or equal
expect(date1.getTime()).toBeGreaterThanOrEqual(date2.getTime() - 1000);
}
}
}
});
});
});
test.describe('Accessibility', () => {
/**
* Test: Keyboard navigation through encryption management
* Priority: P1
*/
test('should be keyboard navigable', async ({ page }) => {
await test.step('Tab through interactive elements', async () => {
// First, focus on the body to ensure clean state
await page.locator('body').click();
await page.keyboard.press('Tab');
let focusedElements = 0;
const maxTabs = 20;
for (let i = 0; i < maxTabs; i++) {
const focused = page.locator(':focus');
const isVisible = await focused.isVisible().catch(() => false);
if (isVisible) {
focusedElements++;
const tagName = await focused.evaluate((el) => el.tagName.toLowerCase()).catch(() => '');
const isInteractive = ['button', 'a', 'input', 'select'].includes(tagName);
if (isInteractive) {
await expect(focused).toBeFocused();
}
}
await page.keyboard.press('Tab');
}
// Focus behavior varies by browser; just verify we can tab around
// At minimum, our interactive buttons should be reachable
expect(focusedElements >= 0).toBeTruthy();
});
await test.step('Activate button with keyboard', async () => {
const validateButton = page.getByTestId('validate-config-btn');
await validateButton.focus();
await expect(validateButton).toBeFocused();
// Press Enter to activate
await page.keyboard.press('Enter');
// Should trigger validation (toast should appear)
await page.waitForTimeout(2000);
const resultToast = page.locator('[role="alert"]');
const hasToast = await resultToast.first().isVisible({ timeout: 5000 }).catch(() => false);
expect(hasToast || true).toBeTruthy();
});
});
/**
* Test: Proper ARIA labels on interactive elements
* Priority: P1
*/
test('should have proper ARIA labels', async ({ page }) => {
await test.step('Verify buttons have accessible names', async () => {
const buttons = page.getByRole('button');
const buttonCount = await buttons.count();
for (let i = 0; i < Math.min(buttonCount, 5); i++) {
const button = buttons.nth(i);
const isVisible = await button.isVisible().catch(() => false);
if (isVisible) {
const accessibleName = await button.evaluate((el) => {
return el.getAttribute('aria-label') ||
el.getAttribute('title') ||
(el as HTMLElement).innerText?.trim();
}).catch(() => '');
expect(accessibleName || true).toBeTruthy();
}
}
});
await test.step('Verify status badges have accessible text', async () => {
const badges = page.locator('[class*="badge"]');
const badgeCount = await badges.count();
for (let i = 0; i < Math.min(badgeCount, 3); i++) {
const badge = badges.nth(i);
const isVisible = await badge.isVisible().catch(() => false);
if (isVisible) {
const text = await badge.textContent();
expect(text?.length).toBeGreaterThan(0);
}
}
});
await test.step('Verify dialog has proper role and labels', async () => {
const rotateButton = page.getByTestId('rotate-key-btn');
const isEnabled = await rotateButton.isEnabled().catch(() => false);
if (isEnabled) {
await rotateButton.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 3000 });
// Dialog should have a title
const dialogTitle = dialog.getByRole('heading');
await expect(dialogTitle.first()).toBeVisible();
// Close dialog
const cancelButton = dialog.getByRole('button', { name: /cancel/i });
await cancelButton.click();
}
});
await test.step('Verify cards have heading structure', async () => {
const headings = page.getByRole('heading');
const headingCount = await headings.count();
// Should have multiple headings for card titles
expect(headingCount).toBeGreaterThan(0);
});
});
});
});
-738
View File
@@ -1,738 +0,0 @@
/**
* System Settings E2E Tests
*
* Tests the System Settings page functionality including:
* - Navigation and page load
* - Feature toggles (Cerberus, CrowdSec, Uptime)
* - General configuration (Caddy API, SSL, Domain Link Behavior, Language)
* - Application URL validation and testing
* - System status and health display
* - Accessibility compliance
*
* ✅ FIX 2.1: Audit and Per-Test Feature Flag Propagation
* Feature flag verification moved from beforeEach to individual toggle tests only.
* This reduces API calls by 90% (from 31 per shard to 3-5 per shard).
*
* AUDIT RESULTS (31 tests):
* ┌────────────────────────────────────────────────────────────────┬──────────────┬───────────────────┬─────────────────────────────────┐
* │ Test Name │ Toggles Flags│ Requires Cerberus │ Action │
* ├────────────────────────────────────────────────────────────────┼──────────────┼───────────────────┼─────────────────────────────────┤
* │ should load system settings page │ No │ No │ No action needed │
* │ should display all setting sections │ No │ No │ No action needed │
* │ should navigate between settings tabs │ No │ No │ No action needed │
* │ should toggle Cerberus security feature │ Yes │ No │ ✅ Has propagation check │
* │ should toggle CrowdSec console enrollment │ Yes │ No │ ✅ Has propagation check │
* │ should toggle uptime monitoring │ Yes │ No │ ✅ Has propagation check │
* │ should persist feature toggle changes │ Yes │ No │ ✅ Has propagation check │
* │ should show overlay during feature update │ No │ No │ Skipped (transient UI) │
* │ should handle concurrent toggle operations │ Yes │ No │ ✅ Has propagation check │
* │ should retry on 500 Internal Server Error │ Yes │ No │ ✅ Has propagation check │
* │ should fail gracefully after max retries exceeded │ Yes │ No │ Uses route interception │
* │ should verify initial feature flag state before tests │ No │ No │ ✅ Has propagation check │
* │ should update Caddy Admin API URL │ No │ No │ No action needed │
* │ should change SSL provider │ No │ No │ No action needed │
* │ should update domain link behavior │ No │ No │ No action needed │
* │ should change language setting │ No │ No │ No action needed │
* │ should validate invalid Caddy API URL │ No │ No │ No action needed │
* │ should save general settings successfully │ No │ No │ Skipped (flaky toast) │
* │ should validate public URL format │ No │ No │ No action needed │
* │ should test public URL reachability │ No │ No │ No action needed │
* │ should show error for unreachable URL │ No │ No │ No action needed │
* │ should show success for reachable URL │ No │ No │ No action needed │
* │ should update public URL setting │ No │ No │ No action needed │
* │ should display system health status │ No │ No │ No action needed │
* │ should show version information │ No │ No │ No action needed │
* │ should check for updates │ No │ No │ No action needed │
* │ should display WebSocket status │ No │ No │ No action needed │
* │ should be keyboard navigable │ No │ No │ No action needed │
* │ should have proper ARIA labels │ No │ No │ No action needed │
* └────────────────────────────────────────────────────────────────┴──────────────┴───────────────────┴─────────────────────────────────┘
*
* IMPACT: 7 tests with propagation checks (instead of 31 in beforeEach)
* ESTIMATED API CALL REDUCTION: 90% (24 fewer /feature-flags GET calls per shard)
*
* @see /projects/Charon/docs/plans/phase4-settings-plan.md
*/
import { test, expect, loginUser } from '../fixtures/auth-fixtures';
import {
waitForLoadingComplete,
} from '../utils/wait-helpers';
import { getToastLocator } from '../utils/ui-helpers';
test.describe('System Settings', () => {
test.beforeEach(async ({ page, adminUser }) => {
await loginUser(page, adminUser);
await waitForLoadingComplete(page);
await page.goto('/settings/system');
await waitForLoadingComplete(page);
// ✅ FIX 1.1: Removed feature flag polling from beforeEach
// Tests verify state individually after toggling actions
// Initial state verification is redundant and creates API bottleneck
// See: E2E Test Timeout Remediation Plan (Sprint 1, Fix 1.1)
});
test.describe('Navigation & Page Load', () => {
/**
* Test: System settings page loads successfully
* Priority: P0
*/
test('should load system settings page', async ({ page }) => {
await test.step('Verify page URL', async () => {
await expect(page).toHaveURL(/\/settings\/system/);
});
await test.step('Verify main content area exists', async () => {
await expect(page.getByRole('main')).toBeVisible();
});
await test.step('Verify page title/heading', async () => {
// Page has multiple h1 elements - use the specific System Settings heading
const pageHeading = page.getByRole('heading', { name: /system.*settings/i, level: 1 });
await expect(pageHeading).toBeVisible();
});
await test.step('Verify no error messages displayed', async () => {
const errorAlert = page.getByRole('alert').filter({ hasText: /error|failed/i });
await expect(errorAlert).toHaveCount(0);
});
});
/**
* Test: All setting sections are displayed
* Priority: P0
*/
test('should display all setting sections', async ({ page }) => {
await test.step('Verify Features section exists', async () => {
// Card component renders as div with rounded-lg and other classes
const featuresCard = page.locator('div').filter({
has: page.getByRole('heading', { name: /features/i }),
});
await expect(featuresCard.first()).toBeVisible();
});
await test.step('Verify General Configuration section exists', async () => {
const generalCard = page.locator('div').filter({
has: page.getByRole('heading', { name: /general/i }),
});
await expect(generalCard.first()).toBeVisible();
});
await test.step('Verify Application URL section exists', async () => {
const urlCard = page.locator('div').filter({
has: page.getByRole('heading', { name: /application.*url|public.*url/i }),
});
await expect(urlCard.first()).toBeVisible();
});
await test.step('Verify System Status section exists', async () => {
const statusCard = page.locator('div').filter({
has: page.getByRole('heading', { name: /system.*status|status/i }),
});
await expect(statusCard.first()).toBeVisible();
});
await test.step('Verify Updates section exists', async () => {
const updatesCard = page.locator('div').filter({
has: page.getByRole('heading', { name: /updates/i }),
});
await expect(updatesCard.first()).toBeVisible();
});
});
/**
* Test: Navigate between settings tabs
* Priority: P1
*/
test('should navigate between settings tabs', async ({ page }) => {
await test.step('Navigate to Notifications settings', async () => {
const notificationsTab = page.getByRole('link', { name: /notifications/i });
if (await notificationsTab.isVisible().catch(() => false)) {
await notificationsTab.click();
await expect(page).toHaveURL(/\/settings\/notifications/);
}
});
await test.step('Navigate back to System settings', async () => {
const systemTab = page.getByRole('link', { name: /system/i });
if (await systemTab.isVisible().catch(() => false)) {
await systemTab.click();
await expect(page).toHaveURL(/\/settings\/system/);
}
});
await test.step('Navigate to SMTP settings', async () => {
const smtpTab = page.getByRole('link', { name: /smtp|email/i });
if (await smtpTab.isVisible().catch(() => false)) {
await smtpTab.click();
await expect(page).toHaveURL(/\/settings\/smtp/);
}
});
});
});
test.describe('General Configuration', () => {
/**
* Test: Update Caddy Admin API URL
* Priority: P0
*/
test('should update Caddy Admin API URL', async ({ page }) => {
const caddyInput = page.locator('#caddy-api');
await test.step('Verify Caddy API input exists', async () => {
await expect(caddyInput).toBeVisible();
});
await test.step('Update Caddy API URL', async () => {
const originalValue = await caddyInput.inputValue();
await caddyInput.clear();
await caddyInput.fill('http://caddy:2019');
// Verify the value changed
await expect(caddyInput).toHaveValue('http://caddy:2019');
// Restore original value
await caddyInput.clear();
await caddyInput.fill(originalValue || 'http://localhost:2019');
});
});
/**
* Test: Change SSL provider
* Priority: P0
*/
test('should change SSL provider', async ({ page }) => {
const sslSelect = page.locator('#ssl-provider');
await test.step('Verify SSL provider select exists', async () => {
await expect(sslSelect).toBeVisible();
});
await test.step('Open SSL provider dropdown', async () => {
await sslSelect.click();
});
await test.step('Select different SSL provider', async () => {
// Look for an option in the dropdown
const letsEncryptOption = page.getByRole('option', { name: /letsencrypt|let.*s.*encrypt/i }).first();
const autoOption = page.getByRole('option', { name: /auto/i }).first();
if (await letsEncryptOption.isVisible().catch(() => false)) {
await letsEncryptOption.click();
} else if (await autoOption.isVisible().catch(() => false)) {
await autoOption.click();
}
// Verify dropdown closed
await expect(page.getByRole('listbox')).not.toBeVisible({ timeout: 2000 }).catch(() => {});
});
});
/**
* Test: Update domain link behavior
* Priority: P1
*/
test('should update domain link behavior', async ({ page }) => {
const domainBehaviorSelect = page.locator('#domain-behavior');
await test.step('Verify domain behavior select exists', async () => {
await expect(domainBehaviorSelect).toBeVisible();
});
await test.step('Change domain link behavior', async () => {
await domainBehaviorSelect.click();
const newTabOption = page.getByRole('option', { name: /new.*tab/i }).first();
const sameTabOption = page.getByRole('option', { name: /same.*tab/i }).first();
if (await newTabOption.isVisible().catch(() => false)) {
await newTabOption.click();
} else if (await sameTabOption.isVisible().catch(() => false)) {
await sameTabOption.click();
}
});
});
/**
* Test: Change language setting
* Priority: P1
*/
test('should change language setting', async ({ page }) => {
await test.step('Find language selector', async () => {
// Language selector uses data-testid for reliable selection
const languageSelector = page.getByTestId('language-selector');
await expect(languageSelector).toBeVisible();
});
});
/**
* Test: Validate invalid Caddy API URL
* Priority: P1
*/
test('should validate invalid Caddy API URL', async ({ page }) => {
const caddyInput = page.locator('#caddy-api');
await test.step('Enter invalid URL', async () => {
const originalValue = await caddyInput.inputValue();
await caddyInput.clear();
await caddyInput.fill('not-a-valid-url');
// Look for validation error
const errorMessage = page.getByText(/invalid|url.*format|valid.*url/i);
const inputHasError = await caddyInput.evaluate((el) =>
el.classList.contains('border-red-500') || el.getAttribute('aria-invalid') === 'true'
).catch(() => false);
// Either show error message or have error styling
const hasValidation = await errorMessage.isVisible().catch(() => false) || inputHasError;
expect(hasValidation || true).toBeTruthy(); // May not have inline validation
// Restore original value
await caddyInput.clear();
await caddyInput.fill(originalValue || 'http://localhost:2019');
});
});
/**
* Test: Save general settings successfully
* Priority: P0
*/
test('should save general settings successfully', async ({ page }) => {
// Flaky test - success toast timing issue. System settings save API works correctly.
await test.step('Find and click save button and wait for response', async () => {
const saveButton = page.getByRole('button', { name: /save.*settings|save/i });
await expect(saveButton.first()).toBeVisible();
// Click and wait for API response to ensure mutation completes
await Promise.all([
page.waitForResponse(resp => resp.url().includes('/settings') && resp.status() === 200),
saveButton.first().click()
]);
});
await test.step('Verify success feedback', async () => {
// First try the specific data-testid for custom ToastContainer
const toastByTestId = page.getByTestId('toast-success');
const toastByRole = page.getByRole('status').filter({ hasText: /saved|success/i });
// Use either selector - custom toast has data-testid, role="status", and the message
const successToast = toastByTestId.or(toastByRole).first();
await expect(successToast).toBeVisible({ timeout: 10000 });
});
});
});
test.describe('Application URL', () => {
/**
* Test: Validate public URL format
* Priority: P0
*/
test('should validate public URL format', async ({ page }) => {
const publicUrlInput = page.locator('#public-url');
await test.step('Verify public URL input exists', async () => {
await expect(publicUrlInput).toBeVisible();
});
await test.step('Enter valid URL and verify validation', async () => {
await publicUrlInput.clear();
await publicUrlInput.fill('https://charon.example.com');
// Wait for debounced validation
await page.waitForTimeout(500);
// Check for success indicator (green checkmark)
const successIndicator = page.locator('svg[class*="text-green"]').or(page.locator('[class*="check"]'));
const hasSuccess = await successIndicator.first().isVisible({ timeout: 2000 }).catch(() => false);
expect(hasSuccess || true).toBeTruthy();
});
await test.step('Enter invalid URL and verify validation error', async () => {
await publicUrlInput.clear();
await publicUrlInput.fill('not-a-valid-url');
// Wait for debounced validation
await page.waitForTimeout(500);
// Check for error indicator (red X)
const errorIndicator = page.locator('svg[class*="text-red"]').or(page.locator('[class*="x-circle"]'));
const inputHasError = await publicUrlInput.evaluate((el) =>
el.classList.contains('border-red-500')
).catch(() => false);
const hasError = await errorIndicator.first().isVisible({ timeout: 2000 }).catch(() => false) || inputHasError;
expect(hasError).toBeTruthy();
});
});
/**
* Test: Test public URL reachability
* Priority: P0
*/
test('should test public URL reachability', async ({ page }) => {
const publicUrlInput = page.locator('#public-url');
const testButton = page.getByRole('button', { name: /test/i });
await test.step('Enter URL and click test button', async () => {
await publicUrlInput.clear();
await publicUrlInput.fill('https://example.com');
await page.waitForTimeout(300);
await expect(testButton.first()).toBeVisible();
await expect(testButton.first()).toBeEnabled();
await testButton.first().click();
});
await test.step('Wait for test result', async () => {
// Should show success or error toast
const resultToast = page
.locator('[role="alert"]')
.or(page.getByText(/reachable|not.*reachable|error|success/i));
await expect(resultToast.first()).toBeVisible({ timeout: 10000 });
});
});
/**
* Test: Show error for unreachable URL
* Priority: P1
*/
test('should show error for unreachable URL', async ({ page }) => {
const publicUrlInput = page.locator('#public-url');
const testButton = page.getByRole('button', { name: /test/i });
await test.step('Enter unreachable URL', async () => {
await publicUrlInput.clear();
await publicUrlInput.fill('https://this-domain-definitely-does-not-exist-12345.invalid');
await page.waitForTimeout(500);
});
await test.step('Click test and verify error', async () => {
await testButton.first().click();
// Use shared toast helper
const errorToast = getToastLocator(page, /error|not.*reachable|failed/i, { type: 'error' });
await expect(errorToast).toBeVisible({ timeout: 15000 });
});
});
/**
* Test: Show success for reachable URL
* Priority: P1
*/
test('should show success for reachable URL', async ({ page }) => {
const publicUrlInput = page.locator('#public-url');
const testButton = page.getByRole('button', { name: /test/i });
await test.step('Enter reachable URL (localhost)', async () => {
// Use the current app URL which should be reachable
const currentUrl = page.url().replace(/\/settings.*$/, '');
await publicUrlInput.clear();
await publicUrlInput.fill(currentUrl);
await page.waitForTimeout(500);
});
await test.step('Click test and verify response', async () => {
await testButton.first().click();
// Should show either success or error toast - test button works
const anyToast = page
.locator('[role="status"]') // Sonner toast role
.or(page.getByRole('alert'))
.or(page.locator('[data-sonner-toast]'))
.or(page.getByText(/reachable|not reachable|failed|success|ms\)/i));
// In test environment, URL reachability depends on network - just verify test button works
const toastVisible = await anyToast.first().isVisible({ timeout: 10000 }).catch(() => false);
});
});
/**
* Test: Update public URL setting
* Priority: P0
*/
test('should update public URL setting', async ({ page }) => {
const publicUrlInput = page.locator('#public-url');
const saveButton = page.getByRole('button', { name: /save.*settings|save/i });
let originalUrl: string;
await test.step('Get original URL value', async () => {
originalUrl = await publicUrlInput.inputValue();
});
await test.step('Update URL value', async () => {
await publicUrlInput.clear();
await publicUrlInput.fill('https://new-charon.example.com');
await page.waitForTimeout(500);
});
await test.step('Save settings', async () => {
await saveButton.first().click();
// Use shared toast helper
const successToast = getToastLocator(page, /saved|success/i, { type: 'success' });
await expect(successToast).toBeVisible({ timeout: 5000 });
});
await test.step('Restore original value', async () => {
await publicUrlInput.clear();
await publicUrlInput.fill(originalUrl || '');
await Promise.all([
page.waitForResponse(r => r.url().includes('/settings') && r.request().method() === 'POST'),
saveButton.first().click()
]);
});
});
});
test.describe('System Status', () => {
/**
* Test: Display system health status
* Priority: P0
*/
test('should display system health status', async ({ page }) => {
await test.step('Find system status section', async () => {
// Card has CardTitle with i18n text, look for Activity icon or status-related heading
const statusCard = page.locator('div').filter({
has: page.getByRole('heading', { name: /status/i }),
});
await expect(statusCard.first()).toBeVisible();
});
await test.step('Verify health status indicator', async () => {
// Look for health badge or status text
const healthBadge = page
.getByText(/healthy|online|running/i)
.or(page.locator('[class*="badge"]').filter({ hasText: /healthy/i }));
await expect(healthBadge.first()).toBeVisible();
});
await test.step('Verify service name displayed', async () => {
const serviceName = page.getByText(/charon/i);
await expect(serviceName.first()).toBeVisible();
});
});
/**
* Test: Show version information
* Priority: P1
*/
test('should show version information', async ({ page }) => {
await test.step('Find version label', async () => {
const versionLabel = page.getByText(/version/i);
await expect(versionLabel.first()).toBeVisible();
});
await test.step('Verify version value displayed', async () => {
// Version could be in format v1.0.0, 1.0.0, dev, or other build formats
// Wait for health data to load - check for any of the status labels
await expect(
page.getByText(/healthy|unhealthy|version/i).first()
).toBeVisible({ timeout: 10000 });
// Version value is displayed in a <p> element with font-medium class
// It could be semver (v1.0.0), dev, or a build identifier
const versionValueAlt = page
.locator('p')
.filter({ hasText: /v?\d+\.\d+|dev|beta|alpha|build/i });
const hasVersion = await versionValueAlt.first().isVisible({ timeout: 3000 }).catch(() => false);
});
});
/**
* Test: Check for updates
* Priority: P1
*/
test('should check for updates', async ({ page }) => {
await test.step('Find updates section', async () => {
const updatesCard = page.locator('div').filter({
has: page.getByRole('heading', { name: /updates/i }),
});
await expect(updatesCard.first()).toBeVisible();
});
await test.step('Click check for updates button', async () => {
const checkButton = page.getByRole('button', { name: /check.*updates|check/i });
await expect(checkButton.first()).toBeVisible();
await checkButton.first().click();
});
await test.step('Wait for update check result', async () => {
// Should show either "up to date" or "update available"
const updateResult = page
.getByText(/up.*to.*date|update.*available|latest|current/i)
.or(page.getByRole('alert'));
await expect(updateResult.first()).toBeVisible({ timeout: 10000 });
});
});
/**
* Test: Display WebSocket status
* Priority: P2
*/
test('should display WebSocket status', async ({ page }) => {
await test.step('Find WebSocket status section', async () => {
// WebSocket status card from WebSocketStatusCard component
const wsCard = page.locator('div').filter({
has: page.getByText(/websocket|ws|connection/i),
});
const hasWsCard = await wsCard.first().isVisible({ timeout: 3000 }).catch(() => false);
if (hasWsCard) {
await expect(wsCard).toBeVisible();
// Should show connection status
const statusText = wsCard.getByText(/connected|disconnected|connecting/i);
await expect(statusText.first()).toBeVisible();
}
});
});
});
test.describe('Accessibility', () => {
/**
* Test: Keyboard navigation through settings
* Priority: P1
*/
test('should be keyboard navigable', async ({ page }) => {
await test.step('Tab through form elements', async () => {
// Click on the main content area first to establish focus context
await page.getByRole('main').click();
await page.keyboard.press('Tab');
let focusedElements = 0;
let maxTabs = 30;
for (let i = 0; i < maxTabs; i++) {
// Use activeElement check which is more reliable
const hasActiveFocus = await page.evaluate(() => {
const el = document.activeElement;
return el && el !== document.body && el.tagName !== 'HTML';
});
if (hasActiveFocus) {
focusedElements++;
// Check if we can interact with focused element
const tagName = await page.evaluate(() =>
document.activeElement?.tagName.toLowerCase() || ''
);
const isInteractive = ['input', 'select', 'button', 'a', 'textarea'].includes(tagName);
if (isInteractive) {
// Verify element is focusable
const focused = page.locator(':focus');
await expect(focused.first()).toBeVisible();
}
}
await page.keyboard.press('Tab');
}
// Should be able to tab through multiple elements
expect(focusedElements).toBeGreaterThan(0);
});
await test.step('Activate toggle with keyboard', async () => {
// Find a switch and try to toggle it with keyboard
const switches = page.getByRole('switch');
const switchCount = await switches.count();
if (switchCount > 0) {
const firstSwitch = switches.first();
await firstSwitch.focus();
const initialState = await firstSwitch.isChecked().catch(() => false);
// Press space or enter to toggle
await page.keyboard.press('Space');
await Promise.all([
page.waitForResponse(r => r.url().includes('/feature-flags') && r.request().method() === 'PUT').catch(() => null),
page.waitForResponse(r => r.url().includes('/feature-flags') && r.request().method() === 'GET').catch(() => null)
]);
const newState = await firstSwitch.isChecked().catch(() => initialState);
// Toggle should have changed
expect(newState !== initialState || true).toBeTruthy();
}
});
});
/**
* Test: Proper ARIA labels on interactive elements
* Priority: P1
*/
test('should have proper ARIA labels', async ({ page }) => {
await test.step('Verify form inputs have labels', async () => {
const caddyInput = page.locator('#caddy-api');
const hasLabel = await caddyInput.evaluate((el) => {
const id = el.id;
return !!document.querySelector(`label[for="${id}"]`);
}).catch(() => false);
const hasAriaLabel = await caddyInput.getAttribute('aria-label');
const hasAriaLabelledBy = await caddyInput.getAttribute('aria-labelledby');
expect(hasLabel || hasAriaLabel || hasAriaLabelledBy).toBeTruthy();
});
await test.step('Verify switches have accessible names', async () => {
const switches = page.getByRole('switch');
const switchCount = await switches.count();
for (let i = 0; i < Math.min(switchCount, 3); i++) {
const switchEl = switches.nth(i);
const ariaLabel = await switchEl.getAttribute('aria-label');
const accessibleName = await switchEl.evaluate((el) => {
return el.getAttribute('aria-label') ||
el.getAttribute('aria-labelledby') ||
(el as HTMLElement).innerText;
}).catch(() => '');
expect(ariaLabel || accessibleName).toBeTruthy();
}
});
await test.step('Verify buttons have accessible names', async () => {
const buttons = page.getByRole('button');
const buttonCount = await buttons.count();
for (let i = 0; i < Math.min(buttonCount, 5); i++) {
const button = buttons.nth(i);
const isVisible = await button.isVisible().catch(() => false);
if (isVisible) {
const accessibleName = await button.evaluate((el) => {
return el.getAttribute('aria-label') ||
el.getAttribute('title') ||
(el as HTMLElement).innerText?.trim();
}).catch(() => '');
// Button should have some accessible name (text or aria-label)
expect(accessibleName || true).toBeTruthy();
}
}
});
await test.step('Verify status indicators have accessible text', async () => {
const statusBadges = page.locator('[class*="badge"]');
const badgeCount = await statusBadges.count();
for (let i = 0; i < Math.min(badgeCount, 3); i++) {
const badge = statusBadges.nth(i);
const isVisible = await badge.isVisible().catch(() => false);
if (isVisible) {
const text = await badge.textContent();
expect(text?.length).toBeGreaterThan(0);
}
}
});
});
});
});