/** * Wait Helpers - Deterministic wait utilities for flaky test prevention * * These utilities replace arbitrary `page.waitForTimeout()` calls with * condition-based waits that poll for specific states. * * @example * ```typescript * // Instead of: * await page.waitForTimeout(1000); * * // Use: * await waitForToast(page, 'Success'); * await waitForLoadingComplete(page); * ``` */ import { expect } from '../fixtures/test'; import type { Page, Locator, Response } from '@playwright/test'; import { clickSwitch } from './ui-helpers'; /** * Click an element and wait for an API response atomically. * Prevents race condition where response completes before wait starts. * * ✅ FIX P0: Added overlay detection and switch component handling * * @param page - Playwright Page instance * @param clickTarget - Locator or selector string for element to click * @param urlPattern - URL string or RegExp to match * @param options - Configuration options * @returns The matched response */ export async function clickAndWaitForResponse( page: Page, clickTarget: Locator | string, urlPattern: string | RegExp, options: { status?: number; timeout?: number } = {} ): Promise { const { status = 200, timeout = 30000 } = options; // ✅ FIX P0: Wait for config reload overlay to disappear const overlay = page.locator('[data-testid="config-reload-overlay"]'); await overlay.waitFor({ state: 'hidden', timeout: 10000 }).catch(() => { // Overlay not present or already hidden - continue }); const locator = typeof clickTarget === 'string' ? page.locator(clickTarget) : clickTarget; // ✅ FIX P0: Detect if clicking a switch component and use proper method const role = await locator.getAttribute('role').catch(() => null); const isSwitch = role === 'switch' || (await locator.getAttribute('type').catch(() => null) === 'checkbox' && await locator.getAttribute('aria-label').then(l => (l || '').includes('toggle')).catch(() => false)); if (isSwitch) { // Use clickSwitch helper for switch components const [response] = await Promise.all([ page.waitForResponse( (resp) => { const urlMatch = typeof urlPattern === 'string' ? resp.url().includes(urlPattern) : urlPattern.test(resp.url()); return urlMatch && resp.status() === status; }, { timeout } ), clickSwitch(locator, { timeout }), ]); return response; } // Regular click for non-switch elements const [response] = await Promise.all([ page.waitForResponse( (resp) => { const urlMatch = typeof urlPattern === 'string' ? resp.url().includes(urlPattern) : urlPattern.test(resp.url()); return urlMatch && resp.status() === status; }, { timeout } ), locator.click(), ]); return response; } /** * Click a Switch/Toggle component and wait for an API response atomically. * Uses the clickSwitch helper to handle the hidden input structure. * @param page - Playwright Page instance * @param switchLocator - Locator for the switch element * @param urlPattern - URL string or RegExp to match * @param options - Configuration options * @returns The matched response */ export async function clickSwitchAndWaitForResponse( page: Page, switchLocator: Locator, urlPattern: string | RegExp, options: { status?: number; timeout?: number; scrollPadding?: number } = {} ): Promise { const { status = 200, timeout = 30000, scrollPadding = 100 } = options; const [response] = await Promise.all([ page.waitForResponse( (resp) => { const urlMatch = typeof urlPattern === 'string' ? resp.url().includes(urlPattern) : urlPattern.test(resp.url()); return urlMatch && resp.status() === status; }, { timeout } ), clickSwitch(switchLocator, { scrollPadding, timeout }), ]); return response; } /** * Options for waitForToast */ export interface ToastOptions { /** Maximum time to wait for toast (default: 10000ms) */ timeout?: number; /** Toast type to match (success, error, info, warning) */ type?: 'success' | 'error' | 'info' | 'warning'; } /** * Wait for a toast notification with specific text * Supports both custom ToastContainer (data-testid) and react-hot-toast * @param page - Playwright Page instance * @param text - Text or RegExp to match in toast * @param options - Configuration options */ export async function waitForToast( page: Page, text: string | RegExp, options: ToastOptions = {} ): Promise { const { timeout = 10000, type } = options; // react-hot-toast uses: // - role="status" for success/info toasts // - role="alert" for error toasts let toast: Locator; if (type === 'error') { // Error toasts use role="alert" toast = page.locator(`[data-testid="toast-${type}"]`) .or(page.getByRole('alert')) .filter({ hasText: text }) .first(); } else if (type === 'success' || type === 'info') { // Success/info toasts use role="status" toast = page.locator(`[data-testid="toast-${type}"]`) .or(page.getByRole('status')) .filter({ hasText: text }) .first(); } else { // Any toast: check both roles toast = page.locator('[data-testid^="toast-"]:not([data-testid="toast-container"])') .or(page.getByRole('status')) .or(page.getByRole('alert')) .filter({ hasText: text }) .first(); } await expect(toast).toBeVisible({ timeout }); } /** * Options for waitForAPIResponse */ export interface APIResponseOptions { /** Expected HTTP status code */ status?: number; /** Maximum time to wait (default: 30000ms) */ timeout?: number; } /** * Wait for a specific API response * @param page - Playwright Page instance * @param urlPattern - URL string or RegExp to match * @param options - Configuration options * @returns The matched response */ export async function waitForAPIResponse( page: Page, urlPattern: string | RegExp, options: APIResponseOptions = {} ): Promise { // Increase default timeout to 60s to tolerate slower CI/backends; individual // tests may override this if they expect faster responses. const { status, timeout = 60000 } = options; const responsePromise = page.waitForResponse( (response) => { const matchesURL = typeof urlPattern === 'string' ? response.url().includes(urlPattern) : urlPattern.test(response.url()); const matchesStatus = status ? response.status() === status : true; return matchesURL && matchesStatus; }, { timeout } ); return await responsePromise; } /** * Options for waitForLoadingComplete */ export interface LoadingOptions { /** Maximum time to wait (default: 10000ms) */ timeout?: number; } /** * Wait for loading spinner/indicator to disappear * @param page - Playwright Page instance * @param options - Configuration options */ export async function waitForLoadingComplete( page: Page, options: LoadingOptions = {} ): Promise { const { timeout = 10000 } = options; if (page.isClosed()) { return; } // Wait for visible loading indicators to disappear. // Avoid broad class-based selectors (e.g. .loading, .spinner) to prevent // false positives from persistent layout/status elements. const loader = page.locator([ '[data-loading="true"]', '[data-testid="config-reload-overlay"]', '[data-testid="loading-spinner"]', '[role="status"][aria-label="Loading"]', '[role="status"][aria-label="Authenticating"]', '[role="status"][aria-label="Security Loading"]' ].join(', ')); try { await expect .poll(async () => { const count = await loader.count(); if (count === 0) { return 0; } let visibleCount = 0; for (let index = 0; index < count; index += 1) { if (await loader.nth(index).isVisible().catch(() => false)) { visibleCount += 1; } } return visibleCount; }, { timeout }) .toBe(0); } catch (error) { if (page.isClosed()) { return; } console.warn( `[waitForLoadingComplete] timed out after ${timeout}ms; continuing to avoid false positives from non-blocking loaders.`, error ); return; } } /** * Options for waitForElementCount */ export interface ElementCountOptions { /** Maximum time to wait (default: 10000ms) */ timeout?: number; } /** * Wait for a specific element count * @param locator - Playwright Locator to count * @param count - Expected number of elements * @param options - Configuration options */ export async function waitForElementCount( locator: Locator, count: number, options: ElementCountOptions = {} ): Promise { const { timeout = 10000 } = options; await expect(locator).toHaveCount(count, { timeout }); } /** * Options for waitForWebSocketConnection */ export interface WebSocketConnectionOptions { /** Maximum time to wait (default: 10000ms) */ timeout?: number; } /** * Wait for WebSocket connection to be established * @param page - Playwright Page instance * @param urlPattern - URL string or RegExp to match * @param options - Configuration options */ export async function waitForWebSocketConnection( page: Page, urlPattern: string | RegExp, options: WebSocketConnectionOptions = {} ): Promise { const { timeout = 10000 } = options; await page.waitForEvent('websocket', { predicate: (ws) => { const matchesURL = typeof urlPattern === 'string' ? ws.url().includes(urlPattern) : urlPattern.test(ws.url()); return matchesURL; }, timeout, }); } /** * Options for waitForWebSocketMessage */ export interface WebSocketMessageOptions { /** Maximum time to wait (default: 10000ms) */ timeout?: number; } /** * Wait for WebSocket message with specific content * @param page - Playwright Page instance * @param matcher - Function to match message data * @param options - Configuration options * @returns The matched message data */ export async function waitForWebSocketMessage( page: Page, matcher: (data: string | Buffer) => boolean, options: WebSocketMessageOptions = {} ): Promise { const { timeout = 10000 } = options; return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error(`WebSocket message not received within ${timeout}ms`)); }, timeout); const cleanup = () => { clearTimeout(timer); }; page.on('websocket', (ws) => { ws.on('framereceived', (event) => { const data = event.payload; if (matcher(data)) { cleanup(); resolve(data); } }); }); }); } /** * Options for waitForProgressComplete */ export interface ProgressOptions { /** Maximum time to wait (default: 30000ms) */ timeout?: number; } /** * Wait for progress bar to complete * @param page - Playwright Page instance * @param options - Configuration options */ export async function waitForProgressComplete( page: Page, options: ProgressOptions = {} ): Promise { const { timeout = 30000 } = options; const progressBar = page.locator('[role="progressbar"]'); // Wait for progress to reach 100% or disappear await page.waitForFunction( () => { const bar = document.querySelector('[role="progressbar"]'); if (!bar) return true; // Progress bar gone = complete const value = bar.getAttribute('aria-valuenow'); return value === '100'; }, { timeout } ); // Wait for progress bar to disappear await expect(progressBar).toHaveCount(0, { timeout: 5000 }); } /** * Options for waitForModal */ export interface ModalOptions { /** Maximum time to wait (default: 10000ms) */ timeout?: number; } /** * Wait for modal dialog to open * @param page - Playwright Page instance * @param titleText - Text or RegExp to match in modal title * @param options - Configuration options * @returns Locator for the modal */ export async function waitForModal( page: Page, titleText: string | RegExp, options: ModalOptions = {} ): Promise { const { timeout = 10000 } = options; // Try to find a modal dialog first, then fall back to a slide-out panel with matching heading // Use .first() to avoid specific strict mode violations if multiple exist in DOM const dialogModal = page .locator('[role="dialog"], .modal') .filter({ hasText: titleText }) .first(); const slideOutPanel = page .locator('h2, h3') .filter({ hasText: titleText }) .first(); // Wait for either the dialog modal or the slide-out panel heading to be visible try { // FIX STRICT MODE VIOLATION: // If we match both the dialog AND the heading inside it, .or() returns 2 elements. // We strictly want to wait until *at least one* is visible. // Using .first() on the combined locator prevents 'strict mode violation' when both match. await expect(dialogModal.or(slideOutPanel).first()).toBeVisible({ timeout }); } catch (e) { // If neither is found, throw a more helpful error throw new Error( `waitForModal: Could not find visible modal dialog or slide-out panel matching "${titleText}". Error: ${e instanceof Error ? e.message : String(e)}` ); } // If dialog modal is visible, use it if (await dialogModal.isVisible()) { return dialogModal; } // Return the parent container of the heading for slide-out panels return slideOutPanel.locator('..'); } /** * Options for waitForDropdown */ export interface DropdownOptions { /** Maximum time to wait (default: 5000ms) */ timeout?: number; } /** * Wait for dropdown/listbox to open * @param page - Playwright Page instance * @param triggerId - ID of the dropdown trigger element * @param options - Configuration options * @returns Locator for the listbox */ export async function waitForDropdown( page: Page, triggerId: string, options: DropdownOptions = {} ): Promise { const { timeout = 5000 } = options; const trigger = page.locator(`#${triggerId}`); const expanded = await trigger.getAttribute('aria-expanded'); if (expanded !== 'true') { throw new Error(`Dropdown ${triggerId} is not expanded`); } const listboxId = await trigger.getAttribute('aria-controls'); if (!listboxId) { // Try finding listbox by common patterns const listbox = page.locator('[role="listbox"], [role="menu"]').first(); await expect(listbox).toBeVisible({ timeout }); return listbox; } const listbox = page.locator(`#${listboxId}`); await expect(listbox).toBeVisible({ timeout }); return listbox; } /** * Options for waitForTableLoad */ export interface TableLoadOptions { /** Minimum number of rows expected (default: 0) */ minRows?: number; /** Maximum time to wait (default: 10000ms) */ timeout?: number; } /** * Wait for table to finish loading and render rows * @param page - Playwright Page instance * @param tableRole - ARIA role for the table (default: 'table') * @param options - Configuration options */ export async function waitForTableLoad( page: Page, tableRole: string = 'table', options: TableLoadOptions = {} ): Promise { const { minRows = 0, timeout = 10000 } = options; const table = page.getByRole(tableRole as 'table'); await expect(table).toBeVisible({ timeout }); // Wait for loading state to clear await waitForLoadingComplete(page, { timeout }); // If minimum rows specified, wait for them if (minRows > 0) { const rows = table.locator('tbody tr, [role="row"]').filter({ hasNot: page.locator('th') }); await expect(rows).toHaveCount(minRows, { timeout }); } } /** * Options for waitForFeatureFlagPropagation */ export interface FeatureFlagPropagationOptions { /** Polling interval in ms (default: 500ms) */ interval?: number; /** Maximum time to wait (default: 30000ms) */ timeout?: number; /** Maximum number of polling attempts (calculated from timeout/interval) */ maxAttempts?: number; } // ✅ FIX 1.3: Cache for in-flight requests (per-worker isolation) // Prevents duplicate API calls when multiple tests wait for same flag state // See: E2E Test Timeout Remediation Plan (Sprint 1, Fix 1.3) const inflightRequests = new Map>>(); // ✅ FIX 3.2: Track API call metrics for performance monitoring // See: E2E Test Timeout Remediation Plan (Phase 3, Fix 3.2) const apiMetrics = { featureFlagCalls: 0, cacheHits: 0, cacheMisses: 0, }; /** * Get current API call metrics * Returns a copy to prevent external mutation */ export function getAPIMetrics() { return { ...apiMetrics }; } /** * Reset all API call metrics to zero * Useful for cleanup between test suites */ export function resetAPIMetrics() { apiMetrics.featureFlagCalls = 0; apiMetrics.cacheHits = 0; apiMetrics.cacheMisses = 0; } /** * Normalize feature flag keys to handle API prefix inconsistencies. * Accepts both "cerberus.enabled" and "feature.cerberus.enabled" formats. * * ✅ FIX P0: Handles API key format mismatch where tests expect "cerberus.enabled" * but API returns "feature.cerberus.enabled" * * @param key - Feature flag key (with or without "feature." prefix) * @returns Normalized key with "feature." prefix */ function normalizeKey(key: string): string { // If key already has "feature." prefix, return as-is if (key.startsWith('feature.')) { return key; } // Otherwise, add the "feature." prefix return `feature.${key}`; } /** * Generate stable cache key with worker isolation * Prevents cache collisions between parallel workers * * ✅ FIX P0: Uses normalized keys to ensure cache hits work correctly */ function generateCacheKey( expectedFlags: Record, workerIndex: number ): string { // Sort keys and normalize them to ensure consistent cache keys // {cerberus.enabled:true} === {feature.cerberus.enabled:true} const sortedFlags = Object.keys(expectedFlags) .sort() .reduce((acc, key) => { const normalizedKey = normalizeKey(key); acc[normalizedKey] = expectedFlags[key]; return acc; }, {} as Record); // Include worker index to isolate parallel processes return `${workerIndex}:${JSON.stringify(sortedFlags)}`; } /** * Polls the /feature-flags endpoint until expected state is returned. * Replaces hard-coded waits with condition-based verification. * Includes request coalescing to reduce API load. * * ✅ FIX P1: Increased timeout from 30s to 60s and added overlay detection * to handle config reload delays during feature flag propagation. * * ✅ FIX 2.3: Quick check for expected state before polling * Skips polling if flags are already in expected state (50% fewer iterations). * * @param page - Playwright page object * @param expectedFlags - Map of flag names to expected boolean values * @param options - Polling configuration * @returns The response once expected state is confirmed * * @example * ```typescript * // Wait for Cerberus flag to be disabled * await waitForFeatureFlagPropagation(page, { * 'cerberus.enabled': false * }); * ``` */ export async function waitForFeatureFlagPropagation( page: Page, expectedFlags: Record, options: FeatureFlagPropagationOptions = {} ): Promise> { // ✅ FIX 3.2: Track feature flag API calls apiMetrics.featureFlagCalls++; // ✅ FIX P1: Wait for config reload overlay to disappear first // The overlay delays feature flag propagation when Caddy reloads config const overlay = page.locator('[data-testid="config-reload-overlay"]'); await overlay.waitFor({ state: 'hidden', timeout: 10000 }).catch(() => { // Overlay not present or already hidden - continue }); // ✅ FIX 2.3: Quick check - are we already in expected state? const currentState = await page.evaluate(async () => { const res = await fetch('/api/v1/feature-flags'); return res.json(); }); const alreadyMatches = Object.entries(expectedFlags).every( ([key, expectedValue]) => { const normalizedKey = normalizeKey(key); return currentState[normalizedKey] === expectedValue; } ); if (alreadyMatches) { console.log('[POLL] Feature flags already in expected state - skipping poll'); return currentState; } // ✅ FIX 1.3: Request coalescing with worker isolation const { test } = await import('@playwright/test'); const workerIndex = test.info().parallelIndex; const cacheKey = generateCacheKey(expectedFlags, workerIndex); // Return cached promise if request already in flight for this worker if (inflightRequests.has(cacheKey)) { console.log(`[CACHE HIT] Worker ${workerIndex}: ${cacheKey}`); // ✅ FIX 3.2: Track cache hit apiMetrics.cacheHits++; return inflightRequests.get(cacheKey)!; } console.log(`[CACHE MISS] Worker ${workerIndex}: ${cacheKey}`); // ✅ FIX 3.2: Track cache miss apiMetrics.cacheMisses++; const interval = options.interval ?? 500; const timeout = options.timeout ?? 60000; // ✅ FIX P1: Increased from 30s to 60s const maxAttempts = options.maxAttempts ?? Math.ceil(timeout / interval); // Create new polling promise const pollingPromise = (async () => { let lastResponse: Record | null = null; let attemptCount = 0; while (attemptCount < maxAttempts) { attemptCount++; // GET /feature-flags via page context to respect CORS and auth const response = await page.evaluate(async () => { const res = await fetch('/api/v1/feature-flags', { method: 'GET', headers: { 'Content-Type': 'application/json' }, }); return { ok: res.ok, status: res.status, data: await res.json(), }; }); lastResponse = response.data as Record; // ✅ FIX P0: Check if all expected flags match (with normalization) const allMatch = Object.entries(expectedFlags).every( ([key, expectedValue]) => { const normalizedKey = normalizeKey(key); const actualValue = response.data[normalizedKey]; if (actualValue === undefined) { console.log(`[WARN] Key "${normalizedKey}" not found in API response`); return false; } const matches = actualValue === expectedValue; if (!matches) { console.log(`[MISMATCH] ${normalizedKey}: expected ${expectedValue}, got ${actualValue}`); } return matches; } ); if (allMatch) { console.log( `[POLL] Feature flags propagated after ${attemptCount} attempts (${attemptCount * interval}ms)` ); return lastResponse; } // Wait before next attempt await page.waitForTimeout(interval); } // Timeout: throw error with diagnostic info throw new Error( `Feature flag propagation timeout after ${attemptCount} attempts (${timeout}ms).\n` + `Expected: ${JSON.stringify(expectedFlags)}\n` + `Actual: ${JSON.stringify(lastResponse)}` ); })(); // Cache the promise inflightRequests.set(cacheKey, pollingPromise); try { const result = await pollingPromise; return result; } finally { // Remove from cache after completion inflightRequests.delete(cacheKey); } } /** * Options for retryAction */ export interface RetryOptions { /** Maximum number of attempts (default: 3) */ maxAttempts?: number; /** Base delay between attempts in ms for exponential backoff (default: 2000ms) */ baseDelay?: number; /** Maximum delay cap in ms (default: 10000ms) */ maxDelay?: number; /** Maximum total time in ms (default: 15000ms per attempt) */ timeout?: number; } /** * Retries an action with exponential backoff. * Handles transient network/DB failures gracefully. * * Retry sequence with defaults: 2s, 4s, 8s (capped at maxDelay) * * @param action - Async function to retry * @param options - Retry configuration * @returns Result of successful action * * @example * ```typescript * await retryAction(async () => { * const response = await clickAndWaitForResponse(page, toggle, /\/feature-flags/); * expect(response.ok()).toBeTruthy(); * }); * ``` */ export async function retryAction( action: () => Promise, options: RetryOptions = {} ): Promise { const maxAttempts = options.maxAttempts ?? 3; const baseDelay = options.baseDelay ?? 2000; const maxDelay = options.maxDelay ?? 10000; let lastError: Error | null = null; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { console.log(`[RETRY] Attempt ${attempt}/${maxAttempts}`); return await action(); // Success! } catch (error) { lastError = error as Error; console.log(`[RETRY] Attempt ${attempt} failed: ${lastError.message}`); if (attempt < maxAttempts) { // Exponential backoff: 2s, 4s, 8s (capped at maxDelay) const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay); console.log(`[RETRY] Waiting ${delay}ms before retry...`); await new Promise((resolve) => setTimeout(resolve, delay)); } } } // All attempts failed throw new Error( `Action failed after ${maxAttempts} attempts.\n` + `Last error: ${lastError?.message}` ); } /** * Options for waitForResourceInUI */ export interface WaitForResourceOptions { /** Maximum time to wait (default: 15000ms) */ timeout?: number; /** Whether to reload the page if resource not found initially (default: true) */ reloadIfNotFound?: boolean; /** Delay after API call before checking UI (default: 500ms) */ initialDelay?: number; } /** * Wait for a resource created via API to appear in the UI * This handles the common case where API creates a resource but UI needs time to reflect it. * Will attempt to find the resource, and if not found, will reload the page and retry. * * @param page - Playwright Page instance * @param identifier - Text or RegExp to identify the resource in UI (e.g., domain name) * @param options - Configuration options * * @example * ```typescript * // After creating a proxy host via API * const { domain } = await testData.createProxyHost(config); * await waitForResourceInUI(page, domain); * ``` */ export async function waitForResourceInUI( page: Page, identifier: string | RegExp, options: WaitForResourceOptions = {} ): Promise { const { timeout = 15000, reloadIfNotFound = true, initialDelay = 500 } = options; // Small initial delay to allow API response to propagate await page.waitForTimeout(initialDelay); const startTime = Date.now(); let reloadCount = 0; const maxReloads = reloadIfNotFound ? 2 : 0; // For long strings, search for a significant portion (first 40 chars after any prefix) // to handle cases where UI truncates long domain names let searchPattern: string | RegExp; if (typeof identifier === 'string' && identifier.length > 50) { // Extract the unique part after the namespace prefix (usually after the first .) const dotIndex = identifier.indexOf('.'); if (dotIndex > 0 && dotIndex < identifier.length - 10) { // Use the part after the first dot (the unique domain portion) const uniquePart = identifier.substring(dotIndex + 1, dotIndex + 40); searchPattern = new RegExp(uniquePart.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); } else { // Fallback: use first 40 chars searchPattern = identifier.substring(0, 40); } } else { searchPattern = identifier; } const isResourcePresent = async (): Promise => { const textMatchVisible = await page.getByText(searchPattern).first().isVisible().catch(() => false); if (textMatchVisible) { return true; } if (typeof searchPattern === 'string' && searchPattern.length > 0) { const normalizedSearch = searchPattern.toLowerCase(); const bodyText = await page.locator('body').innerText().catch(() => ''); if (bodyText.toLowerCase().includes(normalizedSearch)) { return true; } } const headingMatchVisible = await page.getByRole('heading', { name: searchPattern }).first().isVisible().catch(() => false); return headingMatchVisible; }; while (Date.now() - startTime < timeout) { // Wait for any loading to complete first await waitForLoadingComplete(page, { timeout: 5000 }).catch(() => { // Ignore loading timeout - might not have a loader }); if (await isResourcePresent()) { return; // Resource found } // If not found and we have reload attempts left, try reloading if (reloadCount < maxReloads) { reloadCount += 1; await page.reload({ waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page, { timeout: 5000 }).catch(() => {}); continue; } // Wait a bit before retrying await page.waitForTimeout(500); } // Take a screenshot for debugging before throwing const screenshotPath = `test-results/debug-resource-not-found-${Date.now()}.png`; await page.screenshot({ path: screenshotPath, fullPage: true }).catch(() => {}); throw new Error( `Resource with identifier "${identifier}" not found in UI after ${timeout}ms. Screenshot saved to: ${screenshotPath}` ); } /** * Navigate to a page and wait for resources to load after an API mutation. * Use this after creating/updating resources via API to ensure UI is ready. * * @param page - Playwright Page instance * @param url - URL to navigate to * @param options - Configuration options */ export async function navigateAndWaitForData( page: Page, url: string, options: { timeout?: number } = {} ): Promise { const { timeout = 10000 } = options; await page.goto(url); await waitForLoadingComplete(page, { timeout }); // Wait for any data-loading states to clear const dataLoading = page.locator('[data-loading], [aria-busy="true"]'); await expect(dataLoading).toHaveCount(0, { timeout: 5000 }).catch(() => { // Ignore if no data-loading elements exist }); } /** * Clear the feature flag cache * Useful for cleanup or resetting cache state in test hooks */ export function clearFeatureFlagCache(): void { inflightRequests.clear(); console.log('[CACHE] Cleared all cached feature flag requests'); } // ============================================================================ // Phase 2.1: Semantic Wait Helpers for Browser Alignment Triage // ============================================================================ /** * Options for waitForDialog */ export interface DialogOptions { /** ARIA role to match (default: 'dialog') */ role?: 'dialog' | 'alertdialog'; /** Maximum time to wait (default: 5000ms) */ timeout?: number; } /** * Wait for dialog to be visible and interactive. * Replaces: await page.waitForTimeout(500) after dialog open * * This function ensures the dialog is fully rendered and ready for interaction, * handling loading states and ensuring no aria-busy attributes remain. * * @param page - Playwright Page instance * @param options - Configuration options * @returns Locator for the dialog * * @example * ```typescript * // Instead of: * await getAddCertButton(page).click(); * await page.waitForTimeout(500); * * // Use: * await getAddCertButton(page).click(); * const dialog = await waitForDialog(page); * await expect(dialog).toBeVisible(); * ``` */ export async function waitForDialog( page: Page, options: DialogOptions = {} ): Promise { const { role = 'dialog', timeout = 5000 } = options; const dialog = page.getByRole(role); // Wait for dialog to be visible await expect(dialog).toBeVisible({ timeout }); // Ensure dialog is fully rendered and interactive (not busy) await expect(dialog).not.toHaveAttribute('aria-busy', 'true', { timeout: 1000 }).catch(() => { // aria-busy might not be present, which is fine }); // Wait for any loading states within the dialog to clear const dialogLoader = dialog.locator('[role="progressbar"], [aria-busy="true"], .loading-spinner'); await expect(dialogLoader).toHaveCount(0, { timeout: 2000 }).catch(() => { // No loaders present is acceptable }); return dialog; } /** * Options for waitForFormFields */ export interface FormFieldsOptions { /** Maximum time to wait (default: 5000ms) */ timeout?: number; /** Whether field should be enabled (default: true) */ shouldBeEnabled?: boolean; } /** * Wait for dynamically loaded form fields to be ready. * Replaces: await page.waitForTimeout(1000) after selecting form type * * This function waits for form fields to be visible and enabled, * handling dynamic field rendering based on form selection. * * @param page - Playwright Page instance * @param fieldSelector - Selector for the field to wait for * @param options - Configuration options * * @example * ```typescript * // Instead of: * await providerSelect.selectOption('manual'); * await page.waitForTimeout(1000); * * // Use: * await providerSelect.selectOption('manual'); * await waitForFormFields(page, 'input[name="domain"]'); * ``` */ export async function waitForFormFields( page: Page, fieldSelector: string, options: FormFieldsOptions = {} ): Promise { const { timeout = 5000, shouldBeEnabled = true } = options; const field = page.locator(fieldSelector); // Wait for field to be visible await expect(field).toBeVisible({ timeout }); // Wait for field to be enabled if required if (shouldBeEnabled) { await expect(field).toBeEnabled({ timeout: 1000 }); } // Ensure field is attached to DOM (not detached during render) await expect(field).toBeAttached({ timeout: 1000 }); } /** * Options for waitForDebounce */ export interface DebounceOptions { /** Selector for loading indicator (optional) */ indicatorSelector?: string; /** Maximum time to wait (default: 3000ms) */ timeout?: number; /** Optional delay for debounce settling (default: 300ms) */ delay?: number; } /** * Wait for debounced input to settle (e.g., search, autocomplete). * Replaces: await page.waitForTimeout(500) after input typing * * This function waits for either a loading indicator to appear/disappear * or for the network to be idle, handling debounced search scenarios. * * @param page - Playwright Page instance * @param options - Configuration options * * @example * ```typescript * // Instead of: * await searchInput.fill('test'); * await page.waitForTimeout(500); * * // Use: * await searchInput.fill('test'); * await waitForDebounce(page, { indicatorSelector: '.search-loading' }); * ``` */ export async function waitForDebounce( page: Page, options: DebounceOptions = {} ): Promise { const { indicatorSelector, timeout = 3000, delay = 300 } = options; if (indicatorSelector) { // Wait for loading indicator to appear and disappear const indicator = page.locator(indicatorSelector); await indicator.waitFor({ state: 'visible', timeout: 1000 }).catch(() => { // Indicator might not appear if response is very fast }); await indicator.waitFor({ state: 'hidden', timeout }); } else { // Manually wait for the debounce delay to ensure subsequent requests are triggered if (delay > 0) { await page.waitForTimeout(delay); } // Wait for network to be idle (default debounce strategy) await page.waitForLoadState('networkidle', { timeout }); } } /** * Options for waitForConfigReload */ export interface ConfigReloadOptions { /** Maximum time to wait (default: 10000ms) */ timeout?: number; } /** * Wait for config reload overlay to appear and disappear. * Replaces: await page.waitForTimeout(500) after settings change * * This function handles the "Reloading configuration..." overlay that appears * when Caddy configuration is reloaded after settings changes. * * @param page - Playwright Page instance * @param options - Configuration options * * @example * ```typescript * // Instead of: * await saveButton.click(); * await page.waitForTimeout(2000); * * // Use: * await saveButton.click(); * await waitForConfigReload(page); * ``` */ export async function waitForConfigReload( page: Page, options: ConfigReloadOptions = {} ): Promise { const { timeout = 10000 } = options; // Config reload shows overlay with "Reloading configuration..." or similar const overlay = page.locator( '[data-testid="config-reload-overlay"], [role="status"]' ).filter({ hasText: /reloading|loading/i }); // Wait for overlay to appear (may be very fast) await overlay.waitFor({ state: 'visible', timeout: 2000 }).catch(() => { // Overlay may not appear if reload is instant }); // Wait for overlay to disappear await overlay.waitFor({ state: 'hidden', timeout }).catch(() => { // If overlay never appeared, continue }); // Verify page is interactive again await page.waitForLoadState('domcontentloaded', { timeout: 3000 }); } /** * Options for waitForNavigation */ export interface NavigationOptions { /** Maximum time to wait (default: 10000ms) */ timeout?: number; /** Wait for load state (default: 'load') */ waitUntil?: 'load' | 'domcontentloaded' | 'networkidle' | 'commit'; } /** * Wait for URL change with proper assertions. * Replaces: await page.waitForTimeout(1000) then checking URL * * This function waits for navigation to complete and verifies the URL, * handling SPA-style navigation and page loads. * * @param page - Playwright Page instance * @param expectedUrl - Expected URL (string or RegExp) * @param options - Configuration options * * @example * ```typescript * // Instead of: * await link.click(); * await page.waitForTimeout(1000); * expect(page.url()).toContain('/settings'); * * // Use: * await link.click(); * await waitForNavigation(page, /\/settings/); * ``` */ export async function waitForNavigation( page: Page, expectedUrl: string | RegExp, options: NavigationOptions = {} ): Promise { const { timeout = 10000, waitUntil = 'load' } = options; // Wait for URL to change to expected value (commit-level avoids SPA timeouts) await page.waitForURL(expectedUrl, { timeout, waitUntil: 'commit' }); // Additional verification using auto-waiting assertion if (typeof expectedUrl === 'string') { await expect(page).toHaveURL(expectedUrl, { timeout: 1000 }); } else { await expect(page).toHaveURL(expectedUrl, { timeout: 1000 }); } // Ensure page is fully loaded when a navigation actually triggers a load event if (waitUntil !== 'commit') { await page.waitForLoadState(waitUntil, { timeout }).catch(() => { // Same-document navigations (SPA) may not fire the requested load state. }); } }