Files
Charon/tests/utils/wait-helpers.ts
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

1287 lines
38 KiB
TypeScript
Executable File

/**
* 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<Response> {
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<Response> {
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<void> {
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<Response> {
// 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<void> {
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<void> {
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<void> {
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<string | Buffer> {
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<void> {
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<Locator> {
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<Locator> {
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<void> {
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<string, Promise<Record<string, boolean>>>();
// ✅ 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<string, boolean>,
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<string, boolean>);
// 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<string, boolean>,
options: FeatureFlagPropagationOptions = {}
): Promise<Record<string, boolean>> {
// ✅ 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<string, boolean> | 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<string, boolean>;
// ✅ 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<T>(
action: () => Promise<T>,
options: RetryOptions = {}
): Promise<T> {
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<void> {
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<boolean> => {
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<void> {
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<Locator> {
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<void> {
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<void> {
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<void> {
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<void> {
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.
});
}
}