chore: clean .gitignore cache

This commit is contained in:
GitHub Actions
2026-01-26 19:21:33 +00:00
parent 1b1b3a70b1
commit e5f0fec5db
1483 changed files with 0 additions and 472793 deletions

View File

@@ -1,565 +0,0 @@
/**
* TestDataManager - Manages test data with namespace isolation and automatic cleanup
*
* This utility provides:
* - Unique namespace per test to avoid conflicts in parallel execution
* - Resource tracking for automatic cleanup
* - Cleanup in reverse order (newest first) to respect FK constraints
*
* @example
* ```typescript
* let testData: TestDataManager;
*
* test.beforeEach(async ({ request }, testInfo) => {
* testData = new TestDataManager(request, testInfo.title);
* });
*
* test.afterEach(async () => {
* await testData.cleanup();
* });
*
* test('example', async () => {
* const { id, domain } = await testData.createProxyHost({
* domain: 'app.example.com',
* forwardHost: '192.168.1.100',
* forwardPort: 3000
* });
* });
* ```
*/
import { APIRequestContext } from '@playwright/test';
import * as crypto from 'crypto';
/**
* Represents a managed resource created during tests
*/
export interface ManagedResource {
/** Unique identifier of the resource */
id: string;
/** Type of resource for cleanup routing */
type: 'proxy-host' | 'certificate' | 'access-list' | 'dns-provider' | 'user';
/** Namespace that owns this resource */
namespace: string;
/** When the resource was created (for ordering cleanup) */
createdAt: Date;
}
/**
* Data required to create a proxy host
*/
export interface ProxyHostData {
domain: string;
forwardHost: string;
forwardPort: number;
name?: string;
scheme?: 'http' | 'https';
websocketSupport?: boolean;
}
/**
* Data required to create an access list
*/
export interface AccessListData {
name: string;
/** Access list type - determines allow/deny behavior */
type: 'whitelist' | 'blacklist' | 'geo_whitelist' | 'geo_blacklist';
/** Optional description */
description?: string;
/** IP/CIDR rules for whitelist/blacklist types */
ipRules?: Array<{ cidr: string; description?: string }>;
/** Comma-separated country codes for geo types */
countryCodes?: string;
/** Restrict to local RFC1918 networks */
localNetworkOnly?: boolean;
/** Whether the access list is enabled */
enabled?: boolean;
}
/**
* Data required to create a certificate
*/
export interface CertificateData {
domains: string[];
type: 'letsencrypt' | 'custom';
privateKey?: string;
certificate?: string;
}
/**
* Data required to create a DNS provider
*/
export interface DNSProviderData {
/** Provider type identifier from backend registry */
providerType: 'manual' | 'cloudflare' | 'route53' | 'webhook' | 'rfc2136' | string;
/** Display name for the provider */
name: string;
/** Provider-specific credentials */
credentials: Record<string, string>;
/** Propagation timeout in seconds */
propagationTimeout?: number;
/** Polling interval in seconds */
pollingInterval?: number;
/** Whether this is the default provider */
isDefault?: boolean;
}
/**
* Data required to create a user
*/
export interface UserData {
name: string;
email: string;
password: string;
role: 'admin' | 'user' | 'guest';
}
/**
* Result of creating a proxy host
*/
export interface ProxyHostResult {
id: string;
domain: string;
}
/**
* Result of creating an access list
*/
export interface AccessListResult {
id: string;
name: string;
}
/**
* Result of creating a certificate
*/
export interface CertificateResult {
id: string;
domains: string[];
}
/**
* Result of creating a DNS provider
*/
export interface DNSProviderResult {
id: string;
name: string;
}
/**
* Result of creating a user
*/
export interface UserResult {
id: string;
email: string;
token: string;
}
/**
* Manages test data lifecycle with namespace isolation
*/
export class TestDataManager {
private resources: ManagedResource[] = [];
private namespace: string;
private request: APIRequestContext;
/**
* Creates a new TestDataManager instance
* @param request - Playwright API request context
* @param testName - Optional test name for namespace generation
*/
constructor(request: APIRequestContext, testName?: string) {
this.request = request;
// Create unique namespace per test to avoid conflicts
this.namespace = testName
? `test-${this.sanitize(testName)}-${Date.now()}`
: `test-${crypto.randomUUID()}`;
}
/**
* Sanitizes a test name for use in identifiers
* Keeps it short to avoid overly long domain names
*/
private sanitize(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-') // Collapse multiple dashes
.substring(0, 15); // Keep short to avoid long domains
}
/**
* Create a proxy host with automatic cleanup tracking
* @param data - Proxy host configuration
* @returns Created proxy host details
*/
async createProxyHost(data: ProxyHostData): Promise<ProxyHostResult> {
// Domain already contains uniqueness from generateDomain() in fixtures
// Only add namespace prefix for test identification/cleanup purposes
const namespacedDomain = `${this.namespace}.${data.domain}`;
// Build payload matching backend ProxyHost model field names
const payload: Record<string, unknown> = {
domain_names: namespacedDomain, // API expects domain_names, not domain
forward_host: data.forwardHost,
forward_port: data.forwardPort,
forward_scheme: data.scheme ?? 'http',
websocket_support: data.websocketSupport ?? false,
enabled: true,
};
// Include name if provided (required for UI edits)
if (data.name) {
payload.name = data.name;
}
const response = await this.request.post('/api/v1/proxy-hosts', {
data: payload,
});
if (!response.ok()) {
throw new Error(`Failed to create proxy host: ${await response.text()}`);
}
const result = await response.json();
this.resources.push({
id: result.uuid || result.id,
type: 'proxy-host',
namespace: this.namespace,
createdAt: new Date(),
});
return { id: result.uuid || result.id, domain: namespacedDomain };
}
/**
* Create an access list with automatic cleanup tracking
* @param data - Access list configuration
* @returns Created access list details
*/
async createAccessList(data: AccessListData): Promise<AccessListResult> {
const namespacedName = `${this.namespace}-${data.name}`;
// Build payload matching backend AccessList model
const payload: Record<string, unknown> = {
name: namespacedName,
type: data.type,
description: data.description ?? '',
enabled: data.enabled ?? true,
local_network_only: data.localNetworkOnly ?? false,
};
// Convert ipRules array to JSON string for ip_rules field
if (data.ipRules && data.ipRules.length > 0) {
payload.ip_rules = JSON.stringify(data.ipRules);
}
// Add country codes for geo types
if (data.countryCodes) {
payload.country_codes = data.countryCodes;
}
const response = await this.request.post('/api/v1/access-lists', {
data: payload,
});
if (!response.ok()) {
throw new Error(`Failed to create access list: ${await response.text()}`);
}
const result = await response.json();
this.resources.push({
id: result.id?.toString() ?? result.uuid,
type: 'access-list',
namespace: this.namespace,
createdAt: new Date(),
});
return { id: result.id?.toString() ?? result.uuid, name: namespacedName };
}
/**
* Create a certificate with automatic cleanup tracking
* @param data - Certificate configuration
* @returns Created certificate details
*/
async createCertificate(data: CertificateData): Promise<CertificateResult> {
const namespacedDomains = data.domains.map((d) => `${this.namespace}.${d}`);
const namespaced = {
...data,
domains: namespacedDomains,
};
const response = await this.request.post('/api/v1/certificates', {
data: namespaced,
});
if (!response.ok()) {
throw new Error(`Failed to create certificate: ${await response.text()}`);
}
const result = await response.json();
this.resources.push({
id: result.id,
type: 'certificate',
namespace: this.namespace,
createdAt: new Date(),
});
return { id: result.id, domains: namespacedDomains };
}
/**
* Create a DNS provider with automatic cleanup tracking
* @param data - DNS provider configuration
* @returns Created DNS provider details
*/
async createDNSProvider(data: DNSProviderData): Promise<DNSProviderResult> {
const namespacedName = `${this.namespace}-${data.name}`;
// Build payload matching backend CreateDNSProviderRequest struct
const payload: Record<string, unknown> = {
name: namespacedName,
provider_type: data.providerType,
credentials: data.credentials,
};
// Add optional fields if provided
if (data.propagationTimeout !== undefined) {
payload.propagation_timeout = data.propagationTimeout;
}
if (data.pollingInterval !== undefined) {
payload.polling_interval = data.pollingInterval;
}
if (data.isDefault !== undefined) {
payload.is_default = data.isDefault;
}
const response = await this.request.post('/api/v1/dns-providers', {
data: payload,
});
if (!response.ok()) {
throw new Error(`Failed to create DNS provider: ${await response.text()}`);
}
const result = await response.json();
this.resources.push({
id: result.id?.toString() ?? result.uuid,
type: 'dns-provider',
namespace: this.namespace,
createdAt: new Date(),
});
return { id: result.id?.toString() ?? result.uuid, name: namespacedName };
}
/**
* Create a test user with automatic cleanup tracking
* @param data - User configuration
* @returns Created user details including auth token
*/
async createUser(data: UserData): Promise<UserResult> {
const namespacedEmail = `${this.namespace}+${data.email}`;
const namespaced = {
name: data.name,
email: namespacedEmail,
password: data.password,
role: data.role,
};
const response = await this.request.post('/api/v1/users', {
data: namespaced,
});
if (!response.ok()) {
throw new Error(`Failed to create user: ${await response.text()}`);
}
const result = await response.json();
this.resources.push({
id: result.id,
type: 'user',
namespace: this.namespace,
createdAt: new Date(),
});
// Automatically log in the user and return token
const loginResponse = await this.request.post('/api/v1/auth/login', {
data: { email: namespacedEmail, password: data.password },
});
if (!loginResponse.ok()) {
// User created but login failed - still return user info
console.warn(`User created but login failed: ${await loginResponse.text()}`);
return { id: result.id, email: namespacedEmail, token: '' };
}
const { token } = await loginResponse.json();
return { id: result.id, email: namespacedEmail, token };
}
/**
* Clean up all resources in reverse order (respects FK constraints)
* Resources are deleted newest-first to handle dependencies
*/
async cleanup(): Promise<void> {
// Sort by creation time (newest first) to respect dependencies
const sortedResources = [...this.resources].sort(
(a, b) => b.createdAt.getTime() - a.createdAt.getTime()
);
const errors: Error[] = [];
for (const resource of sortedResources) {
try {
await this.deleteResource(resource);
} catch (error) {
errors.push(error as Error);
console.error(`Failed to cleanup ${resource.type}:${resource.id}:`, error);
}
}
this.resources = [];
if (errors.length > 0) {
console.warn(`Cleanup completed with ${errors.length} errors`);
}
}
/**
* Delete a single managed resource
*/
private async deleteResource(resource: ManagedResource): Promise<void> {
const endpoints: Record<ManagedResource['type'], string> = {
'proxy-host': `/api/v1/proxy-hosts/${resource.id}`,
certificate: `/api/v1/certificates/${resource.id}`,
'access-list': `/api/v1/access-lists/${resource.id}`,
'dns-provider': `/api/v1/dns-providers/${resource.id}`,
user: `/api/v1/users/${resource.id}`,
};
const endpoint = endpoints[resource.type];
const response = await this.request.delete(endpoint);
// 404 is acceptable - resource may have been deleted by another test
if (!response.ok() && response.status() !== 404) {
const errorText = await response.text();
// Skip "Cannot delete your own account" errors - the test user is logged in
// and will be cleaned up when the auth session ends or by admin cleanup
if (resource.type === 'user' && errorText.includes('Cannot delete your own account')) {
return; // Silently skip - this is expected for the authenticated test user
}
throw new Error(`Failed to delete ${resource.type}: ${errorText}`);
}
}
/**
* Get all resources created in this namespace
* @returns Copy of the resources array
*/
getResources(): ManagedResource[] {
return [...this.resources];
}
/**
* Get namespace identifier
* @returns The unique namespace for this test
*/
getNamespace(): string {
return this.namespace;
}
/**
* Force cleanup all test-created resources by pattern matching.
* This is a nuclear option for cleaning up orphaned test data from previous runs.
* Use sparingly - prefer the regular cleanup() method.
*
* @param request - Playwright API request context
* @returns Object with counts of deleted resources
*/
static async forceCleanupAll(request: APIRequestContext): Promise<{
proxyHosts: number;
accessLists: number;
dnsProviders: number;
certificates: number;
}> {
const results = {
proxyHosts: 0,
accessLists: 0,
dnsProviders: 0,
certificates: 0,
};
// Pattern to match test-created resources (namespace starts with 'test-')
const testPattern = /^test-/;
try {
// Clean up proxy hosts
const hostsResponse = await request.get('/api/v1/proxy-hosts');
if (hostsResponse.ok()) {
const hosts = await hostsResponse.json();
for (const host of hosts) {
// Match domains that contain test namespace pattern
if (host.domain && testPattern.test(host.domain)) {
const deleteResponse = await request.delete(`/api/v1/proxy-hosts/${host.uuid || host.id}`);
if (deleteResponse.ok() || deleteResponse.status() === 404) {
results.proxyHosts++;
}
}
}
}
// Clean up access lists
const aclResponse = await request.get('/api/v1/access-lists');
if (aclResponse.ok()) {
const acls = await aclResponse.json();
for (const acl of acls) {
if (acl.name && testPattern.test(acl.name)) {
const deleteResponse = await request.delete(`/api/v1/access-lists/${acl.id}`);
if (deleteResponse.ok() || deleteResponse.status() === 404) {
results.accessLists++;
}
}
}
}
// Clean up DNS providers
const dnsResponse = await request.get('/api/v1/dns-providers');
if (dnsResponse.ok()) {
const providers = await dnsResponse.json();
for (const provider of providers) {
if (provider.name && testPattern.test(provider.name)) {
const deleteResponse = await request.delete(`/api/v1/dns-providers/${provider.id}`);
if (deleteResponse.ok() || deleteResponse.status() === 404) {
results.dnsProviders++;
}
}
}
}
// Clean up certificates
const certResponse = await request.get('/api/v1/certificates');
if (certResponse.ok()) {
const certs = await certResponse.json();
for (const cert of certs) {
// Check if any domain matches test pattern
const domains = cert.domains || [];
const isTestCert = domains.some((d: string) => testPattern.test(d));
if (isTestCert) {
const deleteResponse = await request.delete(`/api/v1/certificates/${cert.id}`);
if (deleteResponse.ok() || deleteResponse.status() === 404) {
results.certificates++;
}
}
}
}
} catch (error) {
console.error('Error during force cleanup:', error);
}
console.log(`Force cleanup completed: ${JSON.stringify(results)}`);
return results;
}
}

View File

@@ -1,595 +0,0 @@
/**
* API Helpers - Common API operations for E2E tests
*
* This module provides utility functions for interacting with the Charon API
* in E2E tests. These helpers abstract common operations and provide
* consistent error handling.
*
* @example
* ```typescript
* import { createProxyHostViaAPI, deleteProxyHostViaAPI } from './utils/api-helpers';
*
* test('create and delete proxy host', async ({ request }) => {
* const auth = await authenticateViaAPI(request, 'admin@test.local', 'TestPass123!');
* const { id } = await createProxyHostViaAPI(request, {
* domain: 'test.example.com',
* forwardHost: '192.168.1.100',
* forwardPort: 3000
* }, auth.token);
* await deleteProxyHostViaAPI(request, id, auth.token);
* });
* ```
*/
import { APIRequestContext, APIResponse } from '@playwright/test';
/**
* API error response
*/
export interface APIError {
status: number;
message: string;
details?: Record<string, unknown>;
}
/**
* Authentication response
*/
export interface AuthResponse {
token: string;
user: {
id: string;
email: string;
role: string;
};
expiresAt: string;
}
/**
* Proxy host creation data
*/
export interface ProxyHostCreateData {
domain: string;
forwardHost: string;
forwardPort: number;
scheme?: 'http' | 'https';
websocketSupport?: boolean;
enabled?: boolean;
certificateId?: string;
accessListId?: string;
}
/**
* Proxy host response from API
*/
export interface ProxyHostResponse {
id: string;
uuid: string;
domain: string;
forward_host: string;
forward_port: number;
scheme: string;
websocket_support: boolean;
enabled: boolean;
created_at: string;
updated_at: string;
}
/**
* Access list creation data
*/
export interface AccessListCreateData {
name: string;
rules: Array<{ type: 'allow' | 'deny'; value: string }>;
description?: string;
authEnabled?: boolean;
authUsers?: Array<{ username: string; password: string }>;
}
/**
* Access list response from API
*/
export interface AccessListResponse {
id: string;
name: string;
rules: Array<{ type: string; value: string }>;
description?: string;
auth_enabled: boolean;
enabled: boolean;
created_at: string;
updated_at: string;
}
/**
* Certificate creation data
*/
export interface CertificateCreateData {
domains: string[];
type: 'letsencrypt' | 'custom';
certificate?: string;
privateKey?: string;
intermediates?: string;
dnsProviderId?: string;
acmeEmail?: string;
}
/**
* Certificate response from API
*/
export interface CertificateResponse {
id: string;
domains: string[];
type: string;
status: string;
issuer?: string;
expires_at?: string;
created_at: string;
updated_at: string;
}
/**
* Default request options with authentication
*/
function getAuthHeaders(token?: string): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
/**
* Parse API response and throw on error
*/
async function parseResponse<T>(response: APIResponse): Promise<T> {
if (!response.ok()) {
const text = await response.text();
let message = `API Error: ${response.status()} ${response.statusText()}`;
try {
const error = JSON.parse(text);
message = error.message || error.error || message;
} catch {
message = text || message;
}
throw new Error(message);
}
return response.json();
}
/**
* Authenticate via API and return token
* @param request - Playwright APIRequestContext
* @param email - User email
* @param password - User password
* @returns Authentication response with token
*
* @example
* ```typescript
* const auth = await authenticateViaAPI(request, 'admin@test.local', 'TestPass123!');
* console.log(auth.token);
* ```
*/
export async function authenticateViaAPI(
request: APIRequestContext,
email: string,
password: string
): Promise<AuthResponse> {
const response = await request.post('/api/v1/auth/login', {
data: { email, password },
headers: { 'Content-Type': 'application/json' },
});
return parseResponse<AuthResponse>(response);
}
/**
* Create a proxy host via API
* @param request - Playwright APIRequestContext
* @param data - Proxy host configuration
* @param token - Authentication token (optional if using cookie auth)
* @returns Created proxy host details
*
* @example
* ```typescript
* const { id, domain } = await createProxyHostViaAPI(request, {
* domain: 'app.example.com',
* forwardHost: '192.168.1.100',
* forwardPort: 3000
* });
* ```
*/
export async function createProxyHostViaAPI(
request: APIRequestContext,
data: ProxyHostCreateData,
token?: string
): Promise<ProxyHostResponse> {
const response = await request.post('/api/v1/proxy-hosts', {
data,
headers: getAuthHeaders(token),
});
return parseResponse<ProxyHostResponse>(response);
}
/**
* Get all proxy hosts via API
* @param request - Playwright APIRequestContext
* @param token - Authentication token (optional)
* @returns Array of proxy hosts
*
* @example
* ```typescript
* const hosts = await getProxyHostsViaAPI(request);
* console.log(`Found ${hosts.length} proxy hosts`);
* ```
*/
export async function getProxyHostsViaAPI(
request: APIRequestContext,
token?: string
): Promise<ProxyHostResponse[]> {
const response = await request.get('/api/v1/proxy-hosts', {
headers: getAuthHeaders(token),
});
return parseResponse<ProxyHostResponse[]>(response);
}
/**
* Get a single proxy host by ID via API
* @param request - Playwright APIRequestContext
* @param id - Proxy host ID or UUID
* @param token - Authentication token (optional)
* @returns Proxy host details
*/
export async function getProxyHostViaAPI(
request: APIRequestContext,
id: string,
token?: string
): Promise<ProxyHostResponse> {
const response = await request.get(`/api/v1/proxy-hosts/${id}`, {
headers: getAuthHeaders(token),
});
return parseResponse<ProxyHostResponse>(response);
}
/**
* Update a proxy host via API
* @param request - Playwright APIRequestContext
* @param id - Proxy host ID or UUID
* @param data - Updated configuration
* @param token - Authentication token (optional)
* @returns Updated proxy host details
*/
export async function updateProxyHostViaAPI(
request: APIRequestContext,
id: string,
data: Partial<ProxyHostCreateData>,
token?: string
): Promise<ProxyHostResponse> {
const response = await request.put(`/api/v1/proxy-hosts/${id}`, {
data,
headers: getAuthHeaders(token),
});
return parseResponse<ProxyHostResponse>(response);
}
/**
* Delete a proxy host via API
* @param request - Playwright APIRequestContext
* @param id - Proxy host ID or UUID
* @param token - Authentication token (optional)
*
* @example
* ```typescript
* await deleteProxyHostViaAPI(request, 'uuid-123');
* ```
*/
export async function deleteProxyHostViaAPI(
request: APIRequestContext,
id: string,
token?: string
): Promise<void> {
const response = await request.delete(`/api/v1/proxy-hosts/${id}`, {
headers: getAuthHeaders(token),
});
if (!response.ok() && response.status() !== 404) {
throw new Error(
`Failed to delete proxy host: ${response.status()} ${await response.text()}`
);
}
}
/**
* Create an access list via API
* @param request - Playwright APIRequestContext
* @param data - Access list configuration
* @param token - Authentication token (optional)
* @returns Created access list details
*
* @example
* ```typescript
* const { id } = await createAccessListViaAPI(request, {
* name: 'My ACL',
* rules: [{ type: 'allow', value: '192.168.1.0/24' }]
* });
* ```
*/
export async function createAccessListViaAPI(
request: APIRequestContext,
data: AccessListCreateData,
token?: string
): Promise<AccessListResponse> {
const response = await request.post('/api/v1/access-lists', {
data,
headers: getAuthHeaders(token),
});
return parseResponse<AccessListResponse>(response);
}
/**
* Get all access lists via API
* @param request - Playwright APIRequestContext
* @param token - Authentication token (optional)
* @returns Array of access lists
*/
export async function getAccessListsViaAPI(
request: APIRequestContext,
token?: string
): Promise<AccessListResponse[]> {
const response = await request.get('/api/v1/access-lists', {
headers: getAuthHeaders(token),
});
return parseResponse<AccessListResponse[]>(response);
}
/**
* Get a single access list by ID via API
* @param request - Playwright APIRequestContext
* @param id - Access list ID
* @param token - Authentication token (optional)
* @returns Access list details
*/
export async function getAccessListViaAPI(
request: APIRequestContext,
id: string,
token?: string
): Promise<AccessListResponse> {
const response = await request.get(`/api/v1/access-lists/${id}`, {
headers: getAuthHeaders(token),
});
return parseResponse<AccessListResponse>(response);
}
/**
* Update an access list via API
* @param request - Playwright APIRequestContext
* @param id - Access list ID
* @param data - Updated configuration
* @param token - Authentication token (optional)
* @returns Updated access list details
*/
export async function updateAccessListViaAPI(
request: APIRequestContext,
id: string,
data: Partial<AccessListCreateData>,
token?: string
): Promise<AccessListResponse> {
const response = await request.put(`/api/v1/access-lists/${id}`, {
data,
headers: getAuthHeaders(token),
});
return parseResponse<AccessListResponse>(response);
}
/**
* Delete an access list via API
* @param request - Playwright APIRequestContext
* @param id - Access list ID
* @param token - Authentication token (optional)
*/
export async function deleteAccessListViaAPI(
request: APIRequestContext,
id: string,
token?: string
): Promise<void> {
const response = await request.delete(`/api/v1/access-lists/${id}`, {
headers: getAuthHeaders(token),
});
if (!response.ok() && response.status() !== 404) {
throw new Error(
`Failed to delete access list: ${response.status()} ${await response.text()}`
);
}
}
/**
* Create a certificate via API
* @param request - Playwright APIRequestContext
* @param data - Certificate configuration
* @param token - Authentication token (optional)
* @returns Created certificate details
*
* @example
* ```typescript
* const { id } = await createCertificateViaAPI(request, {
* domains: ['app.example.com'],
* type: 'letsencrypt'
* });
* ```
*/
export async function createCertificateViaAPI(
request: APIRequestContext,
data: CertificateCreateData,
token?: string
): Promise<CertificateResponse> {
const response = await request.post('/api/v1/certificates', {
data,
headers: getAuthHeaders(token),
});
return parseResponse<CertificateResponse>(response);
}
/**
* Get all certificates via API
* @param request - Playwright APIRequestContext
* @param token - Authentication token (optional)
* @returns Array of certificates
*/
export async function getCertificatesViaAPI(
request: APIRequestContext,
token?: string
): Promise<CertificateResponse[]> {
const response = await request.get('/api/v1/certificates', {
headers: getAuthHeaders(token),
});
return parseResponse<CertificateResponse[]>(response);
}
/**
* Get a single certificate by ID via API
* @param request - Playwright APIRequestContext
* @param id - Certificate ID
* @param token - Authentication token (optional)
* @returns Certificate details
*/
export async function getCertificateViaAPI(
request: APIRequestContext,
id: string,
token?: string
): Promise<CertificateResponse> {
const response = await request.get(`/api/v1/certificates/${id}`, {
headers: getAuthHeaders(token),
});
return parseResponse<CertificateResponse>(response);
}
/**
* Delete a certificate via API
* @param request - Playwright APIRequestContext
* @param id - Certificate ID
* @param token - Authentication token (optional)
*/
export async function deleteCertificateViaAPI(
request: APIRequestContext,
id: string,
token?: string
): Promise<void> {
const response = await request.delete(`/api/v1/certificates/${id}`, {
headers: getAuthHeaders(token),
});
if (!response.ok() && response.status() !== 404) {
throw new Error(
`Failed to delete certificate: ${response.status()} ${await response.text()}`
);
}
}
/**
* Renew a certificate via API
* @param request - Playwright APIRequestContext
* @param id - Certificate ID
* @param token - Authentication token (optional)
* @returns Updated certificate details
*/
export async function renewCertificateViaAPI(
request: APIRequestContext,
id: string,
token?: string
): Promise<CertificateResponse> {
const response = await request.post(`/api/v1/certificates/${id}/renew`, {
headers: getAuthHeaders(token),
});
return parseResponse<CertificateResponse>(response);
}
/**
* Check API health
* @param request - Playwright APIRequestContext
* @returns Health status
*/
export async function checkAPIHealth(
request: APIRequestContext
): Promise<{ status: string; database: string; version?: string }> {
const response = await request.get('/api/v1/health');
return parseResponse(response);
}
/**
* Wait for API to be healthy
* @param request - Playwright APIRequestContext
* @param timeout - Maximum time to wait in ms (default: 30000)
* @param interval - Polling interval in ms (default: 1000)
*/
export async function waitForAPIHealth(
request: APIRequestContext,
timeout: number = 30000,
interval: number = 1000
): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
try {
const health = await checkAPIHealth(request);
if (health.status === 'healthy' || health.status === 'ok') {
return;
}
} catch {
// API not ready yet
}
await new Promise((resolve) => setTimeout(resolve, interval));
}
throw new Error(`API not healthy after ${timeout}ms`);
}
/**
* Make an authenticated API request with automatic error handling
* @param request - Playwright APIRequestContext
* @param method - HTTP method
* @param path - API path
* @param options - Request options
* @returns Parsed response
*/
export async function apiRequest<T>(
request: APIRequestContext,
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
path: string,
options: {
data?: unknown;
token?: string;
params?: Record<string, string>;
} = {}
): Promise<T> {
const { data, token, params } = options;
const requestOptions: Parameters<APIRequestContext['fetch']>[1] = {
method,
headers: getAuthHeaders(token),
};
if (data) {
requestOptions.data = data;
}
if (params) {
requestOptions.params = params;
}
const response = await request.fetch(path, requestOptions);
return parseResponse<T>(response);
}

View File

@@ -1,421 +0,0 @@
/**
* Health Check Utilities - Environment verification for E2E tests
*
* These utilities ensure the test environment is healthy and ready
* before running tests, preventing false failures from infrastructure issues.
*
* @example
* ```typescript
* // In playwright.config.ts or global setup
* import { waitForHealthyEnvironment, verifyTestPrerequisites } from './utils/health-check';
*
* await waitForHealthyEnvironment('http://localhost:8080');
* await verifyTestPrerequisites();
* ```
*/
/**
* Health response from the API
*/
interface HealthResponse {
status: string;
database?: string;
version?: string;
uptime?: number;
}
/**
* Options for health check
*/
export interface HealthCheckOptions {
/** Maximum time to wait for healthy status (default: 60000ms) */
timeout?: number;
/** Interval between health check attempts (default: 2000ms) */
interval?: number;
/** Whether to log progress (default: true) */
verbose?: boolean;
}
/**
* Wait for the environment to be healthy
*
* Polls the health endpoint until the service reports healthy status
* or the timeout is reached.
*
* @param baseURL - Base URL of the application
* @param options - Configuration options
* @throws Error if environment doesn't become healthy within timeout
*/
export async function waitForHealthyEnvironment(
baseURL: string,
options: HealthCheckOptions = {}
): Promise<void> {
const { timeout = 60000, interval = 2000, verbose = true } = options;
const startTime = Date.now();
if (verbose) {
console.log(`⏳ Waiting for environment to be healthy at ${baseURL}...`);
}
while (Date.now() - startTime < timeout) {
try {
const response = await fetch(`${baseURL}/api/v1/health`);
if (response.ok) {
const health = (await response.json()) as HealthResponse;
// Check for healthy status
const isHealthy =
health.status === 'healthy' ||
health.status === 'ok' ||
health.status === 'up';
// Check database connectivity if present
const dbHealthy =
!health.database ||
health.database === 'connected' ||
health.database === 'ok' ||
health.database === 'healthy';
if (isHealthy && dbHealthy) {
if (verbose) {
console.log('✅ Environment is healthy');
if (health.version) {
console.log(` Version: ${health.version}`);
}
}
return;
}
if (verbose) {
console.log(` Status: ${health.status}, Database: ${health.database || 'unknown'}`);
}
}
} catch (error) {
// Service not ready yet - continue waiting
if (verbose && Date.now() - startTime > 10000) {
console.log(` Still waiting... (${Math.round((Date.now() - startTime) / 1000)}s)`);
}
}
await new Promise((resolve) => setTimeout(resolve, interval));
}
throw new Error(
`Environment not healthy after ${timeout}ms. ` +
`Check that the application is running at ${baseURL}`
);
}
/**
* Prerequisite check result
*/
export interface PrerequisiteCheck {
name: string;
passed: boolean;
message?: string;
}
/**
* Options for prerequisite verification
*/
export interface PrerequisiteOptions {
/** Base URL (defaults to PLAYWRIGHT_BASE_URL env var) */
baseURL?: string;
/** Whether to throw on failure (default: true) */
throwOnFailure?: boolean;
/** Whether to log results (default: true) */
verbose?: boolean;
}
/**
* Verify all test prerequisites are met
*
* Checks critical system requirements before running tests:
* - API is accessible
* - Database is writable
* - Docker is accessible (if needed for proxy tests)
*
* @param options - Configuration options
* @returns Array of check results
* @throws Error if any critical check fails and throwOnFailure is true
*/
export async function verifyTestPrerequisites(
options: PrerequisiteOptions = {}
): Promise<PrerequisiteCheck[]> {
const {
baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8080',
throwOnFailure = true,
verbose = true,
} = options;
const results: PrerequisiteCheck[] = [];
if (verbose) {
console.log('🔍 Verifying test prerequisites...');
}
// Check 1: API Health
const apiCheck = await checkAPIHealth(baseURL);
results.push(apiCheck);
if (verbose) {
logCheckResult(apiCheck);
}
// Check 2: Database writability (via test endpoint if available)
const dbCheck = await checkDatabaseWritable(baseURL);
results.push(dbCheck);
if (verbose) {
logCheckResult(dbCheck);
}
// Check 3: Docker accessibility (optional - for proxy host tests)
const dockerCheck = await checkDockerAccessible(baseURL);
results.push(dockerCheck);
if (verbose) {
logCheckResult(dockerCheck);
}
// Check 4: Authentication service
const authCheck = await checkAuthService(baseURL);
results.push(authCheck);
if (verbose) {
logCheckResult(authCheck);
}
// Determine if critical checks failed
const criticalChecks = results.filter(
(r) => r.name === 'API Health' || r.name === 'Database Writable'
);
const failedCritical = criticalChecks.filter((r) => !r.passed);
if (failedCritical.length > 0 && throwOnFailure) {
const failedNames = failedCritical.map((r) => r.name).join(', ');
throw new Error(`Critical prerequisite checks failed: ${failedNames}`);
}
if (verbose) {
const passed = results.filter((r) => r.passed).length;
console.log(`\n📋 Prerequisites: ${passed}/${results.length} passed`);
}
return results;
}
/**
* Check if the API is responding
*/
async function checkAPIHealth(baseURL: string): Promise<PrerequisiteCheck> {
try {
const response = await fetch(`${baseURL}/api/v1/health`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (response.ok) {
return { name: 'API Health', passed: true };
}
return {
name: 'API Health',
passed: false,
message: `HTTP ${response.status}: ${response.statusText}`,
};
} catch (error) {
return {
name: 'API Health',
passed: false,
message: `Connection failed: ${(error as Error).message}`,
};
}
}
/**
* Check if the database is writable
*/
async function checkDatabaseWritable(baseURL: string): Promise<PrerequisiteCheck> {
try {
// Try the test endpoint if available
const response = await fetch(`${baseURL}/api/v1/test/db-check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (response.ok) {
return { name: 'Database Writable', passed: true };
}
// If test endpoint doesn't exist, check via health endpoint
if (response.status === 404) {
const healthResponse = await fetch(`${baseURL}/api/v1/health`);
if (healthResponse.ok) {
const health = (await healthResponse.json()) as HealthResponse;
const dbOk =
health.database === 'connected' ||
health.database === 'ok' ||
!health.database; // Assume OK if not reported
return {
name: 'Database Writable',
passed: dbOk,
message: dbOk ? undefined : `Database status: ${health.database}`,
};
}
}
return {
name: 'Database Writable',
passed: false,
message: `Check failed: HTTP ${response.status}`,
};
} catch (error) {
return {
name: 'Database Writable',
passed: false,
message: `Check failed: ${(error as Error).message}`,
};
}
}
/**
* Check if Docker is accessible (for proxy host tests)
*/
async function checkDockerAccessible(baseURL: string): Promise<PrerequisiteCheck> {
try {
const response = await fetch(`${baseURL}/api/v1/test/docker-check`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (response.ok) {
return { name: 'Docker Accessible', passed: true };
}
// If endpoint doesn't exist, mark as skipped (not critical)
if (response.status === 404) {
return {
name: 'Docker Accessible',
passed: true,
message: 'Check endpoint not available (skipped)',
};
}
return {
name: 'Docker Accessible',
passed: false,
message: `HTTP ${response.status}`,
};
} catch (error) {
// Docker check is optional - mark as passed with warning
return {
name: 'Docker Accessible',
passed: true,
message: 'Could not verify (optional)',
};
}
}
/**
* Check if authentication service is working
*/
async function checkAuthService(baseURL: string): Promise<PrerequisiteCheck> {
try {
// Try to access login page or auth endpoint
const response = await fetch(`${baseURL}/api/v1/auth/status`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
// 401 Unauthorized is expected without auth token
if (response.ok || response.status === 401) {
return { name: 'Auth Service', passed: true };
}
// Try login endpoint
if (response.status === 404) {
const loginResponse = await fetch(`${baseURL}/login`, {
method: 'GET',
});
if (loginResponse.ok || loginResponse.status === 200) {
return { name: 'Auth Service', passed: true };
}
}
return {
name: 'Auth Service',
passed: false,
message: `Unexpected status: HTTP ${response.status}`,
};
} catch (error) {
return {
name: 'Auth Service',
passed: false,
message: `Check failed: ${(error as Error).message}`,
};
}
}
/**
* Log a check result to console
*/
function logCheckResult(check: PrerequisiteCheck): void {
const icon = check.passed ? '✅' : '❌';
const suffix = check.message ? ` (${check.message})` : '';
console.log(` ${icon} ${check.name}${suffix}`);
}
/**
* Quick health check - returns true if environment is ready
*
* Use this for conditional test skipping or quick validation.
*
* @param baseURL - Base URL of the application
* @returns true if environment is healthy
*/
export async function isEnvironmentReady(baseURL: string): Promise<boolean> {
try {
const response = await fetch(`${baseURL}/api/v1/health`);
if (!response.ok) return false;
const health = (await response.json()) as HealthResponse;
return (
health.status === 'healthy' ||
health.status === 'ok' ||
health.status === 'up'
);
} catch {
return false;
}
}
/**
* Get environment info for debugging
*
* @param baseURL - Base URL of the application
* @returns Environment information object
*/
export async function getEnvironmentInfo(
baseURL: string
): Promise<Record<string, unknown>> {
try {
const response = await fetch(`${baseURL}/api/v1/health`);
if (!response.ok) {
return { status: 'unhealthy', httpStatus: response.status };
}
const health = await response.json();
return {
...health,
baseURL,
timestamp: new Date().toISOString(),
};
} catch (error) {
return {
status: 'unreachable',
error: (error as Error).message,
baseURL,
timestamp: new Date().toISOString(),
};
}
}

View File

@@ -1,635 +0,0 @@
/**
* Phase 5: Tasks & Monitoring - Test Helper Functions
*
* Provides mock data setup, API mocking utilities, and test helpers
* for backup, logs, import, and monitoring E2E tests.
*/
import { Page } from '@playwright/test';
import { waitForAPIResponse, waitForWebSocketConnection } from './wait-helpers';
// ============================================================================
// Type Definitions
// ============================================================================
export interface BackupFile {
filename: string;
size: number;
time: string;
}
export interface LogFile {
name: string;
size: number;
modified: string;
}
export interface CaddyAccessLog {
level: string;
ts: number;
logger: string;
msg: string;
request: {
remote_ip: string;
method: string;
host: string;
uri: string;
proto: string;
};
status: number;
duration: number;
size: number;
}
export interface LogResponse {
entries: CaddyAccessLog[];
total: number;
page: number;
limit: number;
}
export interface UptimeMonitor {
id: string;
upstream_host?: string;
proxy_host_id?: number;
remote_server_id?: number;
name: string;
type: string;
url: string;
interval: number;
enabled: boolean;
status: string;
last_check?: string | null;
latency: number;
max_retries: number;
}
export interface UptimeHeartbeat {
id: number;
monitor_id: string;
status: string;
latency: number;
message: string;
created_at: string;
}
export interface ImportSession {
id: string;
state: 'pending' | 'reviewing' | 'completed' | 'failed' | 'transient';
created_at: string;
updated_at: string;
source_file?: string;
}
export interface ImportPreview {
session: ImportSession;
preview: {
hosts: Array<{ domain_names: string; [key: string]: unknown }>;
conflicts: string[];
errors: string[];
};
caddyfile_content?: string;
conflict_details?: Record<string, {
existing: {
forward_scheme: string;
forward_host: string;
forward_port: number;
ssl_forced: boolean;
websocket: boolean;
enabled: boolean;
};
imported: {
forward_scheme: string;
forward_host: string;
forward_port: number;
ssl_forced: boolean;
websocket: boolean;
};
}>;
}
export interface LiveLogEntry {
level: string;
timestamp: string;
message: string;
source?: string;
data?: Record<string, unknown>;
}
export interface SecurityLogEntry {
timestamp: string;
level: string;
logger: string;
client_ip: string;
method: string;
uri: string;
status: number;
duration: number;
size: number;
user_agent: string;
host: string;
source: 'waf' | 'crowdsec' | 'ratelimit' | 'acl' | 'normal';
blocked: boolean;
block_reason?: string;
details?: Record<string, unknown>;
}
// ============================================================================
// Backup Helpers
// ============================================================================
/**
* Sets up mock backup list for testing
*/
export async function setupBackupsList(page: Page, backups?: BackupFile[]): Promise<void> {
const defaultBackups: BackupFile[] = backups || [
{ filename: 'backup_2024-01-15_120000.tar.gz', size: 1048576, time: '2024-01-15T12:00:00Z' },
{ filename: 'backup_2024-01-14_120000.tar.gz', size: 2097152, time: '2024-01-14T12:00:00Z' },
];
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: defaultBackups });
} else {
await route.continue();
}
});
}
/**
* Completes a full backup restore flow for testing post-restore behavior
*/
export async function completeRestoreFlow(page: Page, filename?: string): Promise<void> {
const targetFilename = filename || 'backup_2024-01-15_120000.tar.gz';
await page.route(`**/api/v1/backups/${targetFilename}/restore`, (route) => {
route.fulfill({ status: 200, json: { message: 'Restore completed successfully' } });
});
await page.goto('/tasks/backups');
// Click restore button
await page.locator('button:has-text("Restore")').first().click();
// Fill confirmation input
const confirmInput = page.locator('input[placeholder*="backup name"]');
if (await confirmInput.isVisible()) {
await confirmInput.fill('backup_2024-01-15');
}
// Confirm restore
await page.locator('[role="dialog"] button:has-text("Restore")').click();
await waitForAPIResponse(page, `/api/v1/backups/${targetFilename}/restore`, 200);
}
// ============================================================================
// Log Helpers
// ============================================================================
/**
* Sets up mock log files list for testing
*/
export async function setupLogFiles(page: Page, files?: LogFile[]): Promise<void> {
const defaultFiles: LogFile[] = files || [
{ name: 'access.log', size: 1048576, modified: '2024-01-15T12:00:00Z' },
{ name: 'error.log', size: 256000, modified: '2024-01-15T11:30:00Z' },
];
await page.route('**/api/v1/logs', (route) => {
route.fulfill({ status: 200, json: defaultFiles });
});
}
/**
* Selects a log file and waits for content to load
*/
export async function selectLogFile(page: Page, filename: string): Promise<void> {
await page.click(`button:has-text("${filename}")`);
await waitForAPIResponse(page, `/api/v1/logs/${filename}`, 200);
}
/**
* Generates mock log entries for pagination testing
*/
export function generateMockEntries(count: number, pageNum: number): CaddyAccessLog[] {
return Array.from({ length: count }, (_, i) => ({
level: 'info',
ts: Date.now() / 1000 - (pageNum * count + i) * 60,
logger: 'http.log.access',
msg: 'handled request',
request: {
remote_ip: `192.168.1.${i % 255}`,
method: 'GET',
host: 'example.com',
uri: `/page/${pageNum * count + i}`,
proto: 'HTTP/2',
},
status: 200,
duration: 0.05,
size: 1234,
}));
}
/**
* Sets up mock log content for a specific file
*/
export async function setupLogContent(
page: Page,
filename: string,
entries: CaddyAccessLog[],
total?: number
): Promise<void> {
await page.route(`**/api/v1/logs/${filename}*`, (route) => {
const url = new URL(route.request().url());
const requestedPage = parseInt(url.searchParams.get('page') || '1');
const limit = parseInt(url.searchParams.get('limit') || '50');
route.fulfill({
status: 200,
json: {
entries: entries.slice((requestedPage - 1) * limit, requestedPage * limit),
total: total || entries.length,
page: requestedPage,
limit,
} as LogResponse,
});
});
}
// ============================================================================
// Import Helpers
// ============================================================================
/**
* Sets up mock import API for Caddyfile testing
*/
export async function mockImportAPI(page: Page): Promise<void> {
const mockPreview: ImportPreview = {
session: {
id: 'test-session',
state: 'reviewing',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
preview: {
hosts: [{ domain_names: 'example.com', forward_host: 'localhost', forward_port: 3000 }],
conflicts: [],
errors: [],
},
};
await page.route('**/api/v1/import/upload', (route) => {
route.fulfill({ status: 200, json: mockPreview });
});
await page.route('**/api/v1/import/preview', (route) => {
route.fulfill({ status: 200, json: mockPreview });
});
await page.route('**/api/v1/import/status', (route) => {
route.fulfill({ status: 200, json: { has_pending: true, session: mockPreview.session } });
});
await page.route('**/api/v1/import/commit', (route) => {
route.fulfill({ status: 200, json: { created: 1, updated: 0, skipped: 0, errors: [] } });
});
}
/**
* Sets up mock import preview with specific hosts
*/
export async function mockImportPreview(page: Page, preview: ImportPreview): Promise<void> {
await page.route('**/api/v1/import/upload', (route) => {
route.fulfill({ status: 200, json: preview });
});
await page.route('**/api/v1/import/preview', (route) => {
route.fulfill({ status: 200, json: preview });
});
}
/**
* Uploads a Caddyfile via the UI
*/
export async function uploadCaddyfile(page: Page, content: string): Promise<void> {
await page.goto('/tasks/import/caddyfile');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles({
name: 'Caddyfile',
mimeType: 'text/plain',
buffer: Buffer.from(content),
});
await waitForAPIResponse(page, '/api/v1/import/upload', 200);
}
/**
* Sets up import review state with specified number of hosts
*/
export async function setupImportReview(page: Page, hostCount: number): Promise<void> {
const hosts = Array.from({ length: hostCount }, (_, i) => ({
domain_names: `host${i + 1}.example.com`,
forward_host: `server${i + 1}`,
forward_port: 8080 + i,
}));
const preview: ImportPreview = {
session: {
id: 'test-session',
state: 'reviewing',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
preview: { hosts, conflicts: [], errors: [] },
};
await mockImportPreview(page, preview);
await page.goto('/tasks/import/caddyfile');
// Trigger upload to get to review state
const pasteArea = page.locator('textarea[placeholder*="Paste"]');
if (await pasteArea.isVisible()) {
await pasteArea.fill('# mock content');
await page.click('button:has-text("Upload")');
await waitForAPIResponse(page, '/api/v1/import/upload', 200);
}
}
/**
* Mocks CrowdSec import API
*/
export async function mockCrowdSecImportAPI(page: Page): Promise<void> {
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({
status: 201,
json: { filename: 'pre-import-backup.tar.gz', size: 1000, time: new Date().toISOString() },
});
} else {
await route.continue();
}
});
await page.route('**/api/v1/crowdsec/import', (route) => {
route.fulfill({ status: 200, json: { message: 'Import successful' } });
});
}
/**
* Uploads a CrowdSec config file via the UI
*/
export async function uploadCrowdSecConfig(page: Page): Promise<void> {
const fileInput = page.locator('input[data-testid="crowdsec-import-file"]');
await fileInput.setInputFiles({
name: 'crowdsec-config.tar.gz',
mimeType: 'application/gzip',
buffer: Buffer.from('mock tar content'),
});
await page.click('button:has-text("Import")');
}
// ============================================================================
// Uptime Monitor Helpers
// ============================================================================
/**
* Sets up mock monitors list for testing
*/
export async function setupMonitorsList(page: Page, monitors?: UptimeMonitor[]): Promise<void> {
const defaultMonitors: UptimeMonitor[] = monitors || [
{
id: '1',
name: 'API Server',
type: 'http',
url: 'https://api.example.com',
interval: 60,
enabled: true,
status: 'up',
latency: 45,
max_retries: 3,
},
{
id: '2',
name: 'Database',
type: 'tcp',
url: 'tcp://db:5432',
interval: 30,
enabled: true,
status: 'down',
latency: 0,
max_retries: 3,
},
];
await page.route('**/api/v1/uptime/monitors', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: defaultMonitors });
} else {
await route.continue();
}
});
}
/**
* Sets up mock monitor history (heartbeats)
*/
export async function setupMonitorHistory(
page: Page,
monitorId: string,
heartbeats: UptimeHeartbeat[]
): Promise<void> {
await page.route(`**/api/v1/uptime/monitors/${monitorId}/history*`, (route) => {
route.fulfill({ status: 200, json: heartbeats });
});
}
/**
* Sets up monitors with history data
*/
export async function mockMonitorsWithHistory(page: Page, history: UptimeHeartbeat[]): Promise<void> {
await setupMonitorsList(page);
await setupMonitorHistory(page, '1', history);
}
/**
* Generates mock heartbeat history
*/
export function generateMockHeartbeats(count: number, monitorId: string): UptimeHeartbeat[] {
return Array.from({ length: count }, (_, i) => ({
id: i,
monitor_id: monitorId,
status: i % 5 === 0 ? 'down' : 'up',
latency: Math.random() * 100,
message: i % 5 === 0 ? 'Connection timeout' : 'OK',
created_at: new Date(Date.now() - i * 60000).toISOString(),
}));
}
// ============================================================================
// WebSocket / Real-time Log Helpers
// ============================================================================
/**
* Sets up mock live logs with initial data
*/
export async function setupLiveLogsWithMockData(
page: Page,
entries: Partial<SecurityLogEntry>[]
): Promise<void> {
const fullEntries: SecurityLogEntry[] = entries.map((entry, i) => ({
timestamp: new Date().toISOString(),
level: 'info',
logger: 'http',
client_ip: `192.168.1.${i + 1}`,
method: 'GET',
uri: '/test',
status: 200,
duration: 0.05,
size: 1000,
user_agent: 'Mozilla/5.0',
host: 'example.com',
source: 'normal',
blocked: false,
...entry,
}));
// Store entries for later retrieval in tests
await page.evaluate((data) => {
(window as any).__mockLiveLogEntries = data;
}, fullEntries);
}
/**
* Sends a mock log entry via custom event (for WebSocket simulation)
*/
export async function sendMockLogEntry(page: Page, entry?: Partial<SecurityLogEntry>): Promise<void> {
const fullEntry: SecurityLogEntry = {
timestamp: new Date().toISOString(),
level: 'info',
logger: 'http',
client_ip: '192.168.1.100',
method: 'GET',
uri: '/test',
status: 200,
duration: 0.05,
size: 1000,
user_agent: 'Mozilla/5.0',
host: 'example.com',
source: 'normal',
blocked: false,
...entry,
};
await page.evaluate((data) => {
window.dispatchEvent(new CustomEvent('mock-ws-message', { detail: data }));
}, fullEntry);
}
/**
* Simulates WebSocket network interruption for reconnection testing
*/
export async function simulateNetworkInterruption(page: Page, durationMs: number = 1000): Promise<void> {
// Block WebSocket endpoints
await page.route('**/api/v1/logs/live', (route) => route.abort());
await page.route('**/api/v1/cerberus/logs/ws', (route) => route.abort());
await page.waitForTimeout(durationMs);
// Restore WebSocket endpoints
await page.unroute('**/api/v1/logs/live');
await page.unroute('**/api/v1/cerberus/logs/ws');
}
// ============================================================================
// Selector Constants
// ============================================================================
export const BACKUP_SELECTORS = {
pageTitle: 'h1 >> text=Backups',
createBackupButton: 'button:has-text("Create Backup")',
backupTable: '[role="table"]',
backupRows: '[role="row"]',
emptyState: '[data-testid="empty-state"]',
restoreButton: 'button:has-text("Restore")',
deleteButton: 'button:has-text("Delete")',
downloadButton: 'button:has([data-icon="download"])',
confirmDialog: '[role="dialog"]',
confirmButton: 'button:has-text("Confirm")',
cancelButton: 'button:has-text("Cancel")',
} as const;
export const LOG_SELECTORS = {
pageTitle: 'h1 >> text=Logs',
logFileList: '[data-testid="log-file-list"]',
logFileButton: 'button[data-log-file]',
logTable: '[data-testid="log-table"]',
searchInput: 'input[placeholder*="Search"]',
levelSelect: 'select[name="level"]',
prevPageButton: 'button[aria-label="Previous page"]',
nextPageButton: 'button[aria-label="Next page"]',
pageInfo: '[data-testid="page-info"]',
emptyLogState: '[data-testid="empty-log"]',
} as const;
export const UPTIME_SELECTORS = {
pageTitle: 'h1 >> text=Uptime',
monitorCard: '[data-testid="monitor-card"]',
statusBadge: '[data-testid="status-badge"]',
refreshButton: 'button[aria-label="Check now"]',
settingsDropdown: 'button[aria-label="Settings"]',
editOption: '[role="menuitem"]:has-text("Edit")',
deleteOption: '[role="menuitem"]:has-text("Delete")',
editModal: '[role="dialog"]',
nameInput: 'input[name="name"]',
urlInput: 'input[name="url"]',
saveButton: 'button:has-text("Save")',
createButton: 'button:has-text("Add Monitor")',
syncButton: 'button:has-text("Sync")',
emptyState: '[data-testid="empty-state"]',
confirmDialog: '[role="dialog"]',
confirmDelete: 'button:has-text("Delete")',
heartbeatBar: '[data-testid="heartbeat-bar"]',
} as const;
export const LIVE_LOG_SELECTORS = {
connectionStatus: '[data-testid="connection-status"]',
connectedIndicator: '.bg-green-900',
disconnectedIndicator: '.bg-red-900',
connectionError: '[data-testid="connection-error"]',
modeToggle: '[data-testid="mode-toggle"]',
applicationModeButton: 'button:has-text("App")',
securityModeButton: 'button:has-text("Security")',
pauseButton: 'button[title="Pause"]',
playButton: 'button[title="Resume"]',
clearButton: 'button[title="Clear logs"]',
textFilter: 'input[placeholder*="Filter by text"]',
levelSelect: 'select >> text=All Levels',
sourceSelect: 'select >> text=All Sources',
blockedOnlyCheckbox: 'input[type="checkbox"]',
logContainer: '.font-mono.text-xs',
logEntry: '[data-testid="log-entry"]',
blockedEntry: '.bg-red-900\\/30',
logCount: '[data-testid="log-count"]',
pausedIndicator: '.text-yellow-400 >> text=Paused',
} as const;
export const IMPORT_SELECTORS = {
fileDropzone: '[data-testid="file-dropzone"]',
fileInput: 'input[type="file"]',
pasteTextarea: 'textarea[placeholder*="Paste"]',
uploadButton: 'button:has-text("Upload")',
importBanner: '[data-testid="import-banner"]',
continueButton: 'button:has-text("Continue")',
cancelButton: 'button:has-text("Cancel")',
reviewTable: '[data-testid="import-review-table"]',
hostRow: '[data-testid="import-host-row"]',
hostCheckbox: 'input[type="checkbox"][name="selected"]',
conflictBadge: '[data-testid="conflict-badge"]',
errorBadge: '[data-testid="error-badge"]',
commitButton: 'button:has-text("Commit")',
selectAllCheckbox: 'input[type="checkbox"][name="select-all"]',
successModal: '[data-testid="import-success-modal"]',
viewHostsButton: 'button:has-text("View Hosts")',
expiryWarning: '[data-testid="session-expiry-warning"]',
} as const;

View File

@@ -1,283 +0,0 @@
/**
* Security Test Helpers - Safe ACL/WAF/Rate Limit toggle for E2E tests
*
* These helpers provide safe mechanisms to temporarily enable security features
* during tests, with guaranteed cleanup even on test failure.
*
* Problem: If ACL is left enabled after a test failure, it blocks all API requests
* causing subsequent tests to fail with 403 Forbidden (deadlock).
*
* Solution: Use Playwright's test.afterAll() with captured original state to
* guarantee restoration regardless of test outcome.
*
* @example
* ```typescript
* import { withSecurityEnabled, getSecurityStatus } from './utils/security-helpers';
*
* test.describe('ACL Tests', () => {
* let cleanup: () => Promise<void>;
*
* test.beforeAll(async ({ request }) => {
* cleanup = await withSecurityEnabled(request, { acl: true });
* });
*
* test.afterAll(async () => {
* await cleanup();
* });
*
* test('should enforce ACL', async ({ page }) => {
* // ACL is now enabled, test enforcement
* });
* });
* ```
*/
import { APIRequestContext } from '@playwright/test';
/**
* Security module status from GET /api/v1/security/status
*/
export interface SecurityStatus {
cerberus: { enabled: boolean };
crowdsec: { mode: string; api_url: string; enabled: boolean };
waf: { mode: string; enabled: boolean };
rate_limit: { mode: string; enabled: boolean };
acl: { mode: string; enabled: boolean };
}
/**
* Options for enabling specific security modules
*/
export interface SecurityModuleOptions {
/** Enable ACL enforcement */
acl?: boolean;
/** Enable WAF protection */
waf?: boolean;
/** Enable rate limiting */
rateLimit?: boolean;
/** Enable CrowdSec */
crowdsec?: boolean;
/** Enable master Cerberus toggle (required for other modules) */
cerberus?: boolean;
}
/**
* Captured state for restoration
*/
export interface CapturedSecurityState {
acl: boolean;
waf: boolean;
rateLimit: boolean;
crowdsec: boolean;
cerberus: boolean;
}
/**
* Mapping of module names to their settings keys
*/
const SECURITY_SETTINGS_KEYS: Record<keyof SecurityModuleOptions, string> = {
acl: 'security.acl.enabled',
waf: 'security.waf.enabled',
rateLimit: 'security.rate_limit.enabled',
crowdsec: 'security.crowdsec.enabled',
cerberus: 'feature.cerberus.enabled',
};
/**
* Get current security status from the API
* @param request - Playwright APIRequestContext (authenticated)
* @returns Current security status
*/
export async function getSecurityStatus(
request: APIRequestContext
): Promise<SecurityStatus> {
const response = await request.get('/api/v1/security/status');
if (!response.ok()) {
throw new Error(
`Failed to get security status: ${response.status()} ${await response.text()}`
);
}
return response.json();
}
/**
* Set a specific security module's enabled state
* @param request - Playwright APIRequestContext (authenticated)
* @param module - Which module to toggle
* @param enabled - Whether to enable or disable
*/
export async function setSecurityModuleEnabled(
request: APIRequestContext,
module: keyof SecurityModuleOptions,
enabled: boolean
): Promise<void> {
const key = SECURITY_SETTINGS_KEYS[module];
const value = enabled ? 'true' : 'false';
const response = await request.post('/api/v1/settings', {
data: { key, value },
});
if (!response.ok()) {
throw new Error(
`Failed to set ${module} to ${enabled}: ${response.status()} ${await response.text()}`
);
}
// Wait a brief moment for Caddy config reload
await new Promise((resolve) => setTimeout(resolve, 500));
}
/**
* Capture current security state for later restoration
* @param request - Playwright APIRequestContext (authenticated)
* @returns Captured state object
*/
export async function captureSecurityState(
request: APIRequestContext
): Promise<CapturedSecurityState> {
const status = await getSecurityStatus(request);
return {
acl: status.acl.enabled,
waf: status.waf.enabled,
rateLimit: status.rate_limit.enabled,
crowdsec: status.crowdsec.enabled,
cerberus: status.cerberus.enabled,
};
}
/**
* Restore security state to previously captured values
* @param request - Playwright APIRequestContext (authenticated)
* @param state - Previously captured state
*/
export async function restoreSecurityState(
request: APIRequestContext,
state: CapturedSecurityState
): Promise<void> {
const currentStatus = await getSecurityStatus(request);
// Restore in reverse dependency order (features before master toggle)
const modules: (keyof SecurityModuleOptions)[] = ['acl', 'waf', 'rateLimit', 'crowdsec', 'cerberus'];
for (const module of modules) {
const currentValue = module === 'rateLimit'
? currentStatus.rate_limit.enabled
: module === 'crowdsec'
? currentStatus.crowdsec.enabled
: currentStatus[module].enabled;
if (currentValue !== state[module]) {
await setSecurityModuleEnabled(request, module, state[module]);
}
}
}
/**
* Enable security modules temporarily with guaranteed cleanup.
*
* Returns a cleanup function that MUST be called in test.afterAll().
* The cleanup function restores the original state even if tests fail.
*
* @param request - Playwright APIRequestContext (authenticated)
* @param options - Which modules to enable
* @returns Cleanup function to restore original state
*
* @example
* ```typescript
* test.describe('ACL Tests', () => {
* let cleanup: () => Promise<void>;
*
* test.beforeAll(async ({ request }) => {
* cleanup = await withSecurityEnabled(request, { acl: true, cerberus: true });
* });
*
* test.afterAll(async () => {
* await cleanup();
* });
* });
* ```
*/
export async function withSecurityEnabled(
request: APIRequestContext,
options: SecurityModuleOptions
): Promise<() => Promise<void>> {
// Capture original state BEFORE making any changes
const originalState = await captureSecurityState(request);
// Enable Cerberus first (master toggle) if any security module is requested
const needsCerberus = options.acl || options.waf || options.rateLimit || options.crowdsec;
if ((needsCerberus || options.cerberus) && !originalState.cerberus) {
await setSecurityModuleEnabled(request, 'cerberus', true);
}
// Enable requested modules
if (options.acl) {
await setSecurityModuleEnabled(request, 'acl', true);
}
if (options.waf) {
await setSecurityModuleEnabled(request, 'waf', true);
}
if (options.rateLimit) {
await setSecurityModuleEnabled(request, 'rateLimit', true);
}
if (options.crowdsec) {
await setSecurityModuleEnabled(request, 'crowdsec', true);
}
// Return cleanup function that restores original state
return async () => {
try {
await restoreSecurityState(request, originalState);
} catch (error) {
// Log error but don't throw - cleanup should not fail tests
console.error('Failed to restore security state:', error);
// Try emergency disable of ACL to prevent deadlock
try {
await setSecurityModuleEnabled(request, 'acl', false);
} catch {
console.error('Emergency ACL disable also failed - manual intervention may be required');
}
}
};
}
/**
* Disable all security modules (emergency reset).
* Use this in global-setup.ts or when tests need a clean slate.
*
* @param request - Playwright APIRequestContext (authenticated)
*/
export async function disableAllSecurityModules(
request: APIRequestContext
): Promise<void> {
const modules: (keyof SecurityModuleOptions)[] = ['acl', 'waf', 'rateLimit', 'crowdsec'];
for (const module of modules) {
try {
await setSecurityModuleEnabled(request, module, false);
} catch (error) {
console.warn(`Failed to disable ${module}:`, error);
}
}
}
/**
* Check if ACL is currently blocking requests.
* Useful for debugging test failures.
*
* @param request - Playwright APIRequestContext
* @returns True if ACL is enabled and blocking
*/
export async function isAclBlocking(request: APIRequestContext): Promise<boolean> {
try {
const status = await getSecurityStatus(request);
return status.acl.enabled && status.cerberus.enabled;
} catch {
// If we can't get status, ACL might be blocking
return true;
}
}

View File

@@ -1,595 +0,0 @@
/**
* 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 '@bgotink/playwright-coverage';
import type { Page, Locator, Response } from '@playwright/test';
/**
* Click an element and wait for an API response atomically.
* Prevents race condition where response completes before wait starts.
* @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;
const locator =
typeof clickTarget === 'string' ? page.locator(clickTarget) : clickTarget;
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;
}
/**
* 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;
// Build selectors prioritizing our custom toast system which uses data-testid
// This avoids matching generic [role="alert"] elements like security notices
let selector: string;
if (type) {
// Type-specific toast: match data-testid exactly
selector = `[data-testid="toast-${type}"]`;
} else {
// Any toast: match our custom toast container or react-hot-toast
// Avoid matching static [role="alert"] elements by being more specific
selector = '[data-testid^="toast-"]:not([data-testid="toast-container"])';
}
// Use .first() to handle cases where multiple toasts are visible (e.g., after rapid toggles)
// The first matching toast is typically the most recent one we care about
const toast = page.locator(selector).first();
await expect(toast).toContainText(text, { 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> {
const { status, timeout = 30000 } = 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;
// Wait for any loading indicator to disappear
const loader = page.locator(
'[role="progressbar"], [aria-busy="true"], .loading-spinner, .loading, .spinner, [data-loading="true"]'
);
await expect(loader).toHaveCount(0, { timeout });
}
/**
* 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
const dialogModal = page.locator('[role="dialog"], .modal');
const slideOutPanel = page.locator('h2, h3').filter({ hasText: titleText });
// Wait for either the dialog modal or the slide-out panel heading to be visible
try {
await expect(dialogModal.or(slideOutPanel)).toBeVisible({ timeout });
} catch {
// If neither is found, throw a more helpful error
throw new Error(
`waitForModal: Could not find modal dialog or slide-out panel matching "${titleText}"`
);
}
// If dialog modal is visible, verify its title
if (await dialogModal.isVisible()) {
if (titleText) {
const titleLocator = dialogModal.locator(
'[role="heading"], .modal-title, .dialog-title, h1, h2, h3'
);
await expect(titleLocator).toContainText(titleText);
}
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 retryAction
*/
export interface RetryOptions {
/** Maximum number of attempts (default: 5) */
maxAttempts?: number;
/** Delay between attempts in ms (default: 1000) */
interval?: number;
/** Maximum total time in ms (default: 30000) */
timeout?: number;
}
/**
* Retry an action until it succeeds or timeout
* @param action - Async function to retry
* @param options - Configuration options
* @returns Result of the successful action
*/
export async function retryAction<T>(
action: () => Promise<T>,
options: RetryOptions = {}
): Promise<T> {
const { maxAttempts = 5, interval = 1000, timeout = 30000 } = options;
const startTime = Date.now();
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (Date.now() - startTime > timeout) {
throw new Error(`Retry timeout after ${timeout}ms`);
}
try {
return await action();
} catch (error) {
lastError = error as Error;
if (attempt < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, interval));
}
}
}
throw lastError || new Error('Retry failed after max attempts');
}
/**
* 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 reloadAttempted = false;
// 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;
}
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
});
// Try to find the resource using the search pattern
const resourceLocator = page.getByText(searchPattern);
const isVisible = await resourceLocator.first().isVisible().catch(() => false);
if (isVisible) {
return; // Resource found
}
// If not found and we haven't reloaded yet, try reloading
if (reloadIfNotFound && !reloadAttempted) {
reloadAttempted = true;
await page.reload();
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
});
}