chore: git cache cleanup
This commit is contained in:
828
tests/utils/TestDataManager.ts
Normal file
828
tests/utils/TestDataManager.ts
Normal file
@@ -0,0 +1,828 @@
|
||||
/**
|
||||
* 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, type APIResponse, request as playwrightRequest } from '@playwright/test';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
const SQLITE_FULL_PATTERN = {
|
||||
fullText: 'database or disk is full',
|
||||
sqliteCode: 'sqlite_full',
|
||||
errno13: '(13)',
|
||||
} as const;
|
||||
|
||||
let sqliteInfraFailureMessage: string | null = null;
|
||||
|
||||
function isSqliteFullFailure(message: string): boolean {
|
||||
const normalized = message.toLowerCase();
|
||||
const hasDbFullText = normalized.includes(SQLITE_FULL_PATTERN.fullText);
|
||||
const hasSqliteCode = normalized.includes(SQLITE_FULL_PATTERN.sqliteCode);
|
||||
const hasErrno13InSqliteContext =
|
||||
normalized.includes(SQLITE_FULL_PATTERN.errno13) && normalized.includes('sqlite');
|
||||
return hasDbFullText || hasSqliteCode || hasErrno13InSqliteContext;
|
||||
}
|
||||
|
||||
function buildSqliteFullInfrastructureError(context: string, details: string): Error {
|
||||
const error = new Error(
|
||||
`[INFRASTRUCTURE][SQLITE_FULL] ${context}\n` +
|
||||
`Detected SQLite storage exhaustion while running Playwright test setup.\n` +
|
||||
`Root cause indicators matched: \"database or disk is full\" | \"SQLITE_FULL\" | \"(13)\" in SQLite context.\n` +
|
||||
`Action required:\n` +
|
||||
`1. Free disk space on the test runner and ensure the SQLite volume is writable.\n` +
|
||||
`2. Rebuild/restart the E2E test container to reset state.\n` +
|
||||
`3. Re-run the failed shard after infrastructure recovery.\n` +
|
||||
`Original error: ${details}`
|
||||
);
|
||||
error.name = 'InfrastructureSQLiteFullError';
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
private baseURLPromise: Promise<string> | null = null;
|
||||
private authBearerToken: string | null;
|
||||
|
||||
/**
|
||||
* Creates a new TestDataManager instance
|
||||
* @param request - Playwright API request context
|
||||
* @param testName - Optional test name for namespace generation
|
||||
*/
|
||||
constructor(request: APIRequestContext, testName?: string, authBearerToken?: string) {
|
||||
this.request = request;
|
||||
this.authBearerToken = authBearerToken ?? null;
|
||||
// Create unique namespace per test to avoid conflicts
|
||||
this.namespace = testName
|
||||
? `test-${this.sanitize(testName)}-${Date.now()}`
|
||||
: `test-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
private buildRequestHeaders(
|
||||
extra: Record<string, string> = {}
|
||||
): Record<string, string> | undefined {
|
||||
const headers = {
|
||||
...extra,
|
||||
};
|
||||
|
||||
if (this.authBearerToken) {
|
||||
headers.Authorization = `Bearer ${this.authBearerToken}`;
|
||||
}
|
||||
|
||||
return Object.keys(headers).length > 0 ? headers : undefined;
|
||||
}
|
||||
|
||||
private async getBaseURL(): Promise<string> {
|
||||
if (this.baseURLPromise) {
|
||||
return await this.baseURLPromise;
|
||||
}
|
||||
|
||||
this.baseURLPromise = (async () => {
|
||||
const envBaseURL = process.env.PLAYWRIGHT_BASE_URL;
|
||||
if (envBaseURL) {
|
||||
try {
|
||||
return new URL(envBaseURL).origin;
|
||||
} catch {
|
||||
return envBaseURL;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.request.get('/api/v1/health');
|
||||
return new URL(response.url()).origin;
|
||||
} catch {
|
||||
// Default matches playwright.config.js non-coverage baseURL
|
||||
return 'http://127.0.0.1:8080';
|
||||
}
|
||||
})();
|
||||
|
||||
return await this.baseURLPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
private async postWithRetry(
|
||||
url: string,
|
||||
data: Record<string, unknown>,
|
||||
options: {
|
||||
maxAttempts?: number;
|
||||
baseDelayMs?: number;
|
||||
retryStatuses?: number[];
|
||||
} = {}
|
||||
): Promise<APIResponse> {
|
||||
const maxAttempts = options.maxAttempts ?? 4;
|
||||
const baseDelayMs = options.baseDelayMs ?? 300;
|
||||
const retryStatuses = options.retryStatuses ?? [429];
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
const response = await this.request.post(url, {
|
||||
data,
|
||||
headers: this.buildRequestHeaders(),
|
||||
});
|
||||
if (!retryStatuses.includes(response.status()) || attempt === maxAttempts) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const retryAfterHeader = response.headers()['retry-after'];
|
||||
const retryAfterSeconds = retryAfterHeader ? Number(retryAfterHeader) : Number.NaN;
|
||||
const backoffMs = Number.isFinite(retryAfterSeconds)
|
||||
? retryAfterSeconds * 1000
|
||||
: Math.round(baseDelayMs * Math.pow(2, attempt - 1));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, backoffMs));
|
||||
}
|
||||
|
||||
return this.request.post(url, {
|
||||
data,
|
||||
headers: this.buildRequestHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
private async deleteWithRetry(
|
||||
url: string,
|
||||
options: {
|
||||
maxAttempts?: number;
|
||||
baseDelayMs?: number;
|
||||
retryStatuses?: number[];
|
||||
} = {}
|
||||
): Promise<APIResponse> {
|
||||
const maxAttempts = options.maxAttempts ?? 4;
|
||||
const baseDelayMs = options.baseDelayMs ?? 300;
|
||||
const retryStatuses = options.retryStatuses ?? [429];
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
const response = await this.request.delete(url, {
|
||||
headers: this.buildRequestHeaders(),
|
||||
});
|
||||
if (!retryStatuses.includes(response.status()) || attempt === maxAttempts) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const retryAfterHeader = response.headers()['retry-after'];
|
||||
const retryAfterSeconds = retryAfterHeader ? Number(retryAfterHeader) : Number.NaN;
|
||||
const backoffMs = Number.isFinite(retryAfterSeconds)
|
||||
? retryAfterSeconds * 1000
|
||||
: Math.round(baseDelayMs * Math.pow(2, attempt - 1));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, backoffMs));
|
||||
}
|
||||
|
||||
return this.request.delete(url, {
|
||||
headers: this.buildRequestHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
try {
|
||||
// Add explicit timeout with descriptive error for debugging
|
||||
const response = await this.request.post('/api/v1/proxy-hosts', {
|
||||
data: payload,
|
||||
timeout: 30000, // 30s timeout
|
||||
headers: this.buildRequestHeaders(),
|
||||
});
|
||||
|
||||
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 };
|
||||
} catch (error) {
|
||||
// Provide descriptive error for timeout debugging
|
||||
if (error instanceof Error && error.message.includes('timeout')) {
|
||||
throw new Error(
|
||||
`Timeout creating proxy host for ${namespacedDomain} after 30s. ` +
|
||||
`This may indicate API bottleneck or feature flag polling overhead.`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.postWithRetry('/api/v1/access-lists', payload, {
|
||||
maxAttempts: 4,
|
||||
baseDelayMs: 300,
|
||||
retryStatuses: [429],
|
||||
});
|
||||
|
||||
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,
|
||||
headers: this.buildRequestHeaders(),
|
||||
});
|
||||
|
||||
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,
|
||||
headers: this.buildRequestHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Failed to create DNS provider: ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// DNS provider IDs must be numeric (backend uses strconv.ParseUint)
|
||||
const id = result.id;
|
||||
if (id !== undefined && typeof id !== 'number') {
|
||||
console.warn(`DNS provider returned non-numeric ID: ${id} (type: ${typeof id}), using as-is`);
|
||||
}
|
||||
const resourceId = String(id ?? result.uuid);
|
||||
|
||||
this.resources.push({
|
||||
id: resourceId,
|
||||
type: 'dns-provider',
|
||||
namespace: this.namespace,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
return { id: resourceId, 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,
|
||||
options: { useNamespace?: boolean } = {}
|
||||
): Promise<UserResult> {
|
||||
if (sqliteInfraFailureMessage) {
|
||||
throw new Error(sqliteInfraFailureMessage);
|
||||
}
|
||||
|
||||
const useNamespace = options.useNamespace !== false;
|
||||
const namespacedEmail = useNamespace ? `${this.namespace}+${data.email}` : data.email;
|
||||
const namespaced = {
|
||||
name: data.name,
|
||||
email: namespacedEmail,
|
||||
password: data.password,
|
||||
role: data.role,
|
||||
};
|
||||
|
||||
let response: APIResponse;
|
||||
try {
|
||||
response = await this.postWithRetry('/api/v1/users', namespaced, {
|
||||
maxAttempts: 4,
|
||||
baseDelayMs: 300,
|
||||
retryStatuses: [429],
|
||||
});
|
||||
} catch (error) {
|
||||
const rawMessage = error instanceof Error ? error.message : String(error);
|
||||
if (isSqliteFullFailure(rawMessage)) {
|
||||
const infraError = buildSqliteFullInfrastructureError(
|
||||
'Failed to create user in TestDataManager.createUser()',
|
||||
rawMessage
|
||||
);
|
||||
sqliteInfraFailureMessage = infraError.message;
|
||||
throw infraError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!response.ok()) {
|
||||
const responseText = await response.text();
|
||||
if (isSqliteFullFailure(responseText)) {
|
||||
const infraError = buildSqliteFullInfrastructureError(
|
||||
'Failed to create user in TestDataManager.createUser()',
|
||||
responseText
|
||||
);
|
||||
sqliteInfraFailureMessage = infraError.message;
|
||||
throw infraError;
|
||||
}
|
||||
throw new Error(`Failed to create user: ${responseText}`);
|
||||
}
|
||||
|
||||
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.
|
||||
//
|
||||
// IMPORTANT: Do NOT log in using the manager's request context.
|
||||
// The request context is expected to remain admin-authenticated so later
|
||||
// operations (and automatic cleanup) can delete resources regardless of
|
||||
// the created user's role.
|
||||
const loginContext = await playwrightRequest.newContext({
|
||||
baseURL: await this.getBaseURL(),
|
||||
extraHTTPHeaders: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const loginResponse = await loginContext.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 };
|
||||
} finally {
|
||||
await loginContext.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.deleteWithRetry(endpoint, {
|
||||
maxAttempts: 4,
|
||||
baseDelayMs: 300,
|
||||
retryStatuses: [429],
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that ACL and rate limiting are disabled before proceeding with ACL-dependent operations.
|
||||
* Fails fast with actionable error if security is still enabled.
|
||||
* Use this before tests that create/modify resources (proxy hosts, certificates, etc.)
|
||||
* to prevent 403 errors from security modules.
|
||||
*
|
||||
* @throws Error if ACL or rate limiting is enabled
|
||||
*/
|
||||
async assertSecurityDisabled(): Promise<void> {
|
||||
try {
|
||||
const response = await this.request.get('/api/v1/security/config', { timeout: 3000 });
|
||||
if (!response.ok()) {
|
||||
// Endpoint might not exist or requires different auth - skip check
|
||||
return;
|
||||
}
|
||||
|
||||
const config = await response.json();
|
||||
const aclEnabled = config.acl?.enabled === true;
|
||||
const rateLimitEnabled = config.rateLimit?.enabled === true;
|
||||
|
||||
if (aclEnabled || rateLimitEnabled) {
|
||||
throw new Error(
|
||||
`\n❌ SECURITY MODULES ARE ENABLED - OPERATION WILL FAIL\n` +
|
||||
` ACL: ${aclEnabled}, Rate Limiting: ${rateLimitEnabled}\n` +
|
||||
` Cannot proceed with resource creation.\n` +
|
||||
` Check: global-setup.ts emergency reset completed successfully\n`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// Re-throw if it's our security error
|
||||
if (error instanceof Error && error.message.includes('SECURITY MODULES ARE ENABLED')) {
|
||||
throw error;
|
||||
}
|
||||
// Otherwise, skip check (endpoint might not exist in this environment)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
620
tests/utils/api-helpers.ts
Normal file
620
tests/utils/api-helpers.ts
Normal file
@@ -0,0 +1,620 @@
|
||||
/**
|
||||
* 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';
|
||||
import { readFileSync } from 'fs';
|
||||
import { STORAGE_STATE } from '../constants';
|
||||
|
||||
/**
|
||||
* Read auth token from storage state and return Authorization headers.
|
||||
* Use this for page.request calls that need Bearer token auth.
|
||||
*/
|
||||
export function getStorageStateAuthHeaders(): Record<string, string> {
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(STORAGE_STATE, 'utf-8'));
|
||||
for (const origin of state.origins ?? []) {
|
||||
for (const entry of origin.localStorage ?? []) {
|
||||
if (entry.name === 'charon_auth_token' && entry.value) {
|
||||
return { Authorization: `Bearer ${entry.value}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const cookie of state.cookies ?? []) {
|
||||
if (cookie.name === 'auth_token' && cookie.value) {
|
||||
return { Authorization: `Bearer ${cookie.value}` };
|
||||
}
|
||||
}
|
||||
} catch { /* no-op */ }
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
207
tests/utils/archive-helpers.ts
Normal file
207
tests/utils/archive-helpers.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import * as tar from 'tar';
|
||||
import * as path from 'path';
|
||||
import { createGzip } from 'zlib';
|
||||
import { createWriteStream, createReadStream } from 'fs';
|
||||
import { pipeline } from 'stream/promises';
|
||||
|
||||
export interface ArchiveOptions {
|
||||
format: 'tar.gz' | 'zip';
|
||||
compression?: 'high' | 'normal' | 'none';
|
||||
files: Record<string, string>; // filename -> content
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tar.gz archive with specified files
|
||||
* @param files - Object mapping filenames to their content
|
||||
* @param outputPath - Absolute path where the archive should be created
|
||||
* @returns Absolute path to the created archive
|
||||
*/
|
||||
export async function createTarGz(
|
||||
files: Record<string, string>,
|
||||
outputPath: string
|
||||
): Promise<string> {
|
||||
// Ensure parent directory exists
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
|
||||
// Create temporary directory for files
|
||||
const tempDir = path.join(path.dirname(outputPath), `.temp-${Date.now()}`);
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
|
||||
try {
|
||||
// Write all files to temp directory
|
||||
for (const [filename, content] of Object.entries(files)) {
|
||||
const filePath = path.join(tempDir, filename);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, content, 'utf-8');
|
||||
}
|
||||
|
||||
// Create tar.gz archive
|
||||
await tar.create(
|
||||
{
|
||||
gzip: true,
|
||||
file: outputPath,
|
||||
cwd: tempDir,
|
||||
},
|
||||
Object.keys(files)
|
||||
);
|
||||
|
||||
return outputPath;
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a zip bomb (highly compressed file) for testing compression ratio detection
|
||||
* @param outputPath - Absolute path where the archive should be created
|
||||
* @param compressionRatio - Target compression ratio (default: 150x)
|
||||
* @returns Absolute path to the created archive
|
||||
*/
|
||||
export async function createZipBomb(
|
||||
outputPath: string,
|
||||
compressionRatio: number = 150
|
||||
): Promise<string> {
|
||||
// Ensure parent directory exists
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
|
||||
// Create temporary directory
|
||||
const tempDir = path.join(path.dirname(outputPath), `.temp-zipbomb-${Date.now()}`);
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
|
||||
try {
|
||||
// Create a highly compressible file (10MB of zeros)
|
||||
// This will compress to a very small size
|
||||
const uncompressedSize = 10 * 1024 * 1024; // 10MB
|
||||
const compressibleData = Buffer.alloc(uncompressedSize, 0);
|
||||
|
||||
const tempFilePath = path.join(tempDir, 'config.yaml');
|
||||
|
||||
// Add valid YAML header to make it look legitimate
|
||||
const yamlHeader = Buffer.from(`api:
|
||||
server:
|
||||
listen_uri: 0.0.0.0:8080
|
||||
# Padding data below to create compression ratio anomaly
|
||||
# `, 'utf-8');
|
||||
|
||||
await fs.writeFile(tempFilePath, Buffer.concat([yamlHeader, compressibleData]));
|
||||
|
||||
// Create tar.gz archive with maximum compression
|
||||
await tar.create(
|
||||
{
|
||||
gzip: {
|
||||
level: 9, // Maximum compression
|
||||
},
|
||||
file: outputPath,
|
||||
cwd: tempDir,
|
||||
},
|
||||
['config.yaml']
|
||||
);
|
||||
|
||||
return outputPath;
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a corrupted archive file for testing error handling
|
||||
* @param outputPath - Absolute path where the corrupted archive should be created
|
||||
* @returns Absolute path to the created corrupted archive
|
||||
*/
|
||||
export async function createCorruptedArchive(
|
||||
outputPath: string
|
||||
): Promise<string> {
|
||||
// Ensure parent directory exists
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
|
||||
// Create a file that starts with gzip magic bytes but has corrupted data
|
||||
const gzipMagicBytes = Buffer.from([0x1f, 0x8b]); // gzip signature
|
||||
const corruptedData = Buffer.from('this is not valid gzip data after the magic bytes');
|
||||
|
||||
const corruptedArchive = Buffer.concat([gzipMagicBytes, corruptedData]);
|
||||
|
||||
await fs.writeFile(outputPath, corruptedArchive);
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ZIP file (unsupported format) for testing format validation
|
||||
* @param files - Object mapping filenames to their content
|
||||
* @param outputPath - Absolute path where the ZIP should be created
|
||||
* @returns Absolute path to the created ZIP file
|
||||
*/
|
||||
export async function createZip(
|
||||
files: Record<string, string>,
|
||||
outputPath: string
|
||||
): Promise<string> {
|
||||
// Ensure parent directory exists
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
|
||||
// Create a minimal ZIP file with magic bytes
|
||||
// PK\x03\x04 is ZIP magic number
|
||||
const zipMagicBytes = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
|
||||
|
||||
// For testing, just create a file with ZIP signature
|
||||
// Real ZIP creation would require jszip or archiver library
|
||||
await fs.writeFile(outputPath, zipMagicBytes);
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an oversized archive for testing size limits
|
||||
* @param outputPath - Absolute path where the archive should be created
|
||||
* @param sizeMB - Size in megabytes (default: 51MB to exceed 50MB limit)
|
||||
* @returns Absolute path to the created archive
|
||||
*/
|
||||
export async function createOversizedArchive(
|
||||
outputPath: string,
|
||||
sizeMB: number = 51
|
||||
): Promise<string> {
|
||||
// Ensure parent directory exists
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
|
||||
// Create temporary directory
|
||||
const tempDir = path.join(path.dirname(outputPath), `.temp-oversized-${Date.now()}`);
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
|
||||
try {
|
||||
// Create a large file (use random data so it doesn't compress well)
|
||||
const sizeBytes = sizeMB * 1024 * 1024;
|
||||
const chunkSize = 1024 * 1024; // 1MB chunks
|
||||
const tempFilePath = path.join(tempDir, 'large-config.yaml');
|
||||
|
||||
// Write in chunks to avoid memory issues
|
||||
const writeStream = createWriteStream(tempFilePath);
|
||||
|
||||
for (let i = 0; i < Math.ceil(sizeBytes / chunkSize); i++) {
|
||||
const remainingBytes = Math.min(chunkSize, sizeBytes - (i * chunkSize));
|
||||
// Use random data to prevent compression
|
||||
const chunk = Buffer.from(
|
||||
Array.from({ length: remainingBytes }, () => Math.floor(Math.random() * 256))
|
||||
);
|
||||
writeStream.write(chunk);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => writeStream.end(resolve));
|
||||
|
||||
// Create tar.gz archive
|
||||
await tar.create(
|
||||
{
|
||||
gzip: true,
|
||||
file: outputPath,
|
||||
cwd: tempDir,
|
||||
},
|
||||
['large-config.yaml']
|
||||
);
|
||||
|
||||
return outputPath;
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
447
tests/utils/debug-logger.ts
Normal file
447
tests/utils/debug-logger.ts
Normal file
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* Debug Logger Utility for Playwright E2E Tests
|
||||
*
|
||||
* Provides structured logging for test execution with:
|
||||
* - Color-coded console output for local runs
|
||||
* - Structured JSON output for CI parsing
|
||||
* - Automatic duration tracking
|
||||
* - Sensitive data sanitization (auth tokens, headers)
|
||||
* - Integration with Playwright HTML report
|
||||
*
|
||||
* Usage:
|
||||
* const logger = new DebugLogger('test-name');
|
||||
* logger.step('User login', async () => {
|
||||
* await page.click('[role="button"]');
|
||||
* });
|
||||
* logger.assertion('Button is visible', visible);
|
||||
* logger.error('Network failed', error);
|
||||
*/
|
||||
|
||||
import { test } from '@playwright/test';
|
||||
|
||||
export interface DebugLoggerOptions {
|
||||
testName?: string;
|
||||
browser?: string;
|
||||
shard?: string;
|
||||
file?: string;
|
||||
}
|
||||
|
||||
export interface NetworkLogEntry {
|
||||
method: string;
|
||||
url: string;
|
||||
status?: number;
|
||||
elapsedMs: number;
|
||||
requestHeaders?: Record<string, string>;
|
||||
responseContentType?: string;
|
||||
responseBodySize?: number;
|
||||
error?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface LocatorLogEntry {
|
||||
selector: string;
|
||||
action: string;
|
||||
found: boolean;
|
||||
elapsedMs: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// ANSI color codes for console output
|
||||
const COLORS = {
|
||||
reset: '\x1b[0m',
|
||||
dim: '\x1b[2m',
|
||||
bold: '\x1b[1m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
magenta: '\x1b[35m',
|
||||
cyan: '\x1b[36m',
|
||||
};
|
||||
|
||||
export class DebugLogger {
|
||||
private testName: string;
|
||||
private browser: string;
|
||||
private shard: string;
|
||||
private file: string;
|
||||
private isCI: boolean;
|
||||
private logs: string[] = [];
|
||||
private networkLogs: NetworkLogEntry[] = [];
|
||||
private locatorLogs: LocatorLogEntry[] = [];
|
||||
private startTime: number;
|
||||
private stepStack: string[] = [];
|
||||
|
||||
constructor(options: DebugLoggerOptions = {}) {
|
||||
this.testName = options.testName || 'unknown';
|
||||
this.browser = options.browser || 'chromium';
|
||||
this.shard = options.shard || 'unknown';
|
||||
this.file = options.file || 'unknown';
|
||||
this.isCI = !!process.env.CI;
|
||||
this.startTime = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a test step with automatic duration tracking
|
||||
*/
|
||||
step(name: string, duration?: number): void {
|
||||
const indentation = ' '.repeat(this.stepStack.length);
|
||||
const prefix = `${indentation}├─`;
|
||||
const durationStr = duration ? ` (${duration}ms)` : '';
|
||||
|
||||
const message = `${prefix} ${name}${durationStr}`;
|
||||
this.logMessage(message, 'step');
|
||||
|
||||
// Report to Playwright's test.step system
|
||||
test.step(name, async () => {
|
||||
// Step already logged
|
||||
}).catch(() => {
|
||||
// Ignore if not in test context
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log network activity (requests/responses)
|
||||
*/
|
||||
network(entry: Partial<NetworkLogEntry>): void {
|
||||
const fullEntry: NetworkLogEntry = {
|
||||
method: entry.method || 'UNKNOWN',
|
||||
url: this.sanitizeURL(entry.url || ''),
|
||||
status: entry.status,
|
||||
elapsedMs: entry.elapsedMs || 0,
|
||||
error: entry.error,
|
||||
timestamp: new Date().toISOString(),
|
||||
requestHeaders: this.sanitizeHeaders(entry.requestHeaders),
|
||||
responseContentType: entry.responseContentType,
|
||||
responseBodySize: entry.responseBodySize,
|
||||
};
|
||||
|
||||
this.networkLogs.push(fullEntry);
|
||||
|
||||
const statusIcon = this.getStatusIcon(fullEntry.status);
|
||||
const statusStr = fullEntry.status ? `[${fullEntry.status}]` : '[no-status]';
|
||||
const message = ` ${statusIcon} ${fullEntry.method} ${this.truncateURL(fullEntry.url)} ${statusStr} ${fullEntry.elapsedMs}ms`;
|
||||
|
||||
this.logMessage(message, 'network');
|
||||
}
|
||||
|
||||
/**
|
||||
* Log page state information
|
||||
*/
|
||||
pageState(label: string, state: Record<string, any>): void {
|
||||
const sanitized = this.sanitizeObject(state);
|
||||
const message = ` 📄 Page State: ${label}`;
|
||||
this.logMessage(message, 'page-state');
|
||||
|
||||
if (this.isCI) {
|
||||
// In CI, log structured format
|
||||
this.logs.push(JSON.stringify({
|
||||
type: 'page-state',
|
||||
label,
|
||||
state: sanitized,
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log locator activity
|
||||
*/
|
||||
locator(selector: string, action: string, found: boolean, elapsedMs: number): void {
|
||||
const entry: LocatorLogEntry = {
|
||||
selector,
|
||||
action,
|
||||
found,
|
||||
elapsedMs,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.locatorLogs.push(entry);
|
||||
|
||||
const icon = found ? '✓' : '✗';
|
||||
const message = ` ${icon} ${action} "${selector}" ${elapsedMs}ms`;
|
||||
this.logMessage(message, found ? 'locator-found' : 'locator-missing');
|
||||
}
|
||||
|
||||
/**
|
||||
* Log assertion result
|
||||
*/
|
||||
assertion(condition: string, passed: boolean, actual?: any, expected?: any): void {
|
||||
const icon = passed ? '✓' : '✗';
|
||||
const color = passed ? COLORS.green : COLORS.red;
|
||||
const baseMessage = ` ${icon} Assert: ${condition}`;
|
||||
|
||||
if (actual !== undefined && expected !== undefined) {
|
||||
const actualStr = this.formatValue(actual);
|
||||
const expectedStr = this.formatValue(expected);
|
||||
const message = `${baseMessage} | expected: ${expectedStr}, actual: ${actualStr}`;
|
||||
this.logMessage(message, passed ? 'assertion-pass' : 'assertion-fail');
|
||||
} else {
|
||||
this.logMessage(baseMessage, passed ? 'assertion-pass' : 'assertion-fail');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error with context
|
||||
*/
|
||||
error(context: string, error: Error | string, recoveryAttempts?: number): void {
|
||||
const errorMessage = typeof error === 'string' ? error : error.message;
|
||||
const errorStack = typeof error === 'string' ? '' : error.stack;
|
||||
|
||||
const message = ` ❌ ERROR: ${context} - ${errorMessage}`;
|
||||
this.logMessage(message, 'error');
|
||||
|
||||
if (recoveryAttempts) {
|
||||
const recoveryMsg = ` 🔄 Recovery: ${recoveryAttempts} attempts remaining`;
|
||||
this.logMessage(recoveryMsg, 'recovery');
|
||||
}
|
||||
|
||||
if (this.isCI && errorStack) {
|
||||
this.logs.push(JSON.stringify({
|
||||
type: 'error',
|
||||
context,
|
||||
message: errorMessage,
|
||||
stack: errorStack,
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get test duration in milliseconds
|
||||
*/
|
||||
getDuration(): number {
|
||||
return Date.now() - this.startTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all log entries as structured JSON
|
||||
*/
|
||||
getStructuredLogs(): any {
|
||||
return {
|
||||
test: {
|
||||
name: this.testName,
|
||||
browser: this.browser,
|
||||
shard: this.shard,
|
||||
file: this.file,
|
||||
durationMs: this.getDuration(),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
network: this.networkLogs,
|
||||
locators: this.locatorLogs,
|
||||
rawLogs: this.logs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export network logs as CSV for analysis
|
||||
*/
|
||||
getNetworkCSV(): string {
|
||||
const headers = ['Timestamp', 'Method', 'URL', 'Status', 'Duration (ms)', 'Content-Type', 'Body Size', 'Error'];
|
||||
const rows = this.networkLogs.map(entry => [
|
||||
entry.timestamp,
|
||||
entry.method,
|
||||
entry.url,
|
||||
entry.status || '',
|
||||
entry.elapsedMs,
|
||||
entry.responseContentType || '',
|
||||
entry.responseBodySize || '',
|
||||
entry.error || '',
|
||||
]);
|
||||
|
||||
return [headers, ...rows].map(row => row.map(cell => `"${cell}"`).join(',')).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary of slow operations
|
||||
*/
|
||||
getSlowOperations(threshold: number = 1000): { type: string; name: string; duration: number }[] {
|
||||
// Note: We'd need to track operations with names in step() for this to be fully useful
|
||||
// For now, return slow network requests
|
||||
return this.networkLogs
|
||||
.filter(entry => entry.elapsedMs > threshold)
|
||||
.map(entry => ({
|
||||
type: 'network',
|
||||
name: `${entry.method} ${entry.url}`,
|
||||
duration: entry.elapsedMs,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Print all logs to console with colors
|
||||
*/
|
||||
printSummary(): void {
|
||||
const duration = this.getDuration();
|
||||
const durationStr = this.formatDuration(duration);
|
||||
|
||||
const summary = `
|
||||
${COLORS.cyan}📊 Test Summary${COLORS.reset}
|
||||
${COLORS.dim}${'─'.repeat(60)}${COLORS.reset}
|
||||
Test: ${this.testName}
|
||||
Browser: ${this.browser}
|
||||
Shard: ${this.shard}
|
||||
Duration: ${durationStr}
|
||||
Network Reqs: ${this.networkLogs.length}
|
||||
Locator Calls: ${this.locatorLogs.length}
|
||||
${COLORS.dim}${'─'.repeat(60)}${COLORS.reset}`;
|
||||
|
||||
console.log(summary);
|
||||
|
||||
// Show slowest operations
|
||||
const slowOps = this.getSlowOperations(500);
|
||||
if (slowOps.length > 0) {
|
||||
console.log(`${COLORS.yellow}⚠️ Slow Operations (>500ms):${COLORS.reset}`);
|
||||
slowOps.forEach(op => {
|
||||
console.log(` ${op.type.padEnd(10)} ${op.name.substring(0, 40)} ${op.duration}ms`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Private helper methods
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
private logMessage(message: string, type: string): void {
|
||||
if (this.isCI) {
|
||||
// In CI, store as structured JSON
|
||||
this.logs.push(JSON.stringify({
|
||||
type,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
} else {
|
||||
// Locally, output with colors
|
||||
const colorCode = this.getColorForType(type);
|
||||
console.log(`${colorCode}${message}${COLORS.reset}`);
|
||||
}
|
||||
}
|
||||
|
||||
private getColorForType(type: string): string {
|
||||
const colorMap: Record<string, string> = {
|
||||
step: COLORS.blue,
|
||||
network: COLORS.cyan,
|
||||
'page-state': COLORS.magenta,
|
||||
'locator-found': COLORS.green,
|
||||
'locator-missing': COLORS.yellow,
|
||||
'assertion-pass': COLORS.green,
|
||||
'assertion-fail': COLORS.red,
|
||||
error: COLORS.red,
|
||||
recovery: COLORS.yellow,
|
||||
};
|
||||
return colorMap[type] || COLORS.reset;
|
||||
}
|
||||
|
||||
private getStatusIcon(status?: number): string {
|
||||
if (!status) return '❓';
|
||||
if (status >= 200 && status < 300) return '✅';
|
||||
if (status >= 300 && status < 400) return '➡️';
|
||||
if (status >= 400 && status < 500) return '⚠️';
|
||||
return '❌';
|
||||
}
|
||||
|
||||
private sanitizeURL(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
// Remove sensitive query params
|
||||
const sensitiveParams = ['token', 'key', 'secret', 'password', 'auth'];
|
||||
sensitiveParams.forEach(param => {
|
||||
parsed.searchParams.delete(param);
|
||||
});
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeHeaders(headers?: Record<string, string>): Record<string, string> | undefined {
|
||||
if (!headers) return undefined;
|
||||
|
||||
const sanitized = { ...headers };
|
||||
const sensitiveHeaders = [
|
||||
'authorization',
|
||||
'cookie',
|
||||
'x-api-key',
|
||||
'x-emergency-token',
|
||||
'x-auth-token',
|
||||
];
|
||||
|
||||
sensitiveHeaders.forEach(header => {
|
||||
Object.keys(sanitized).forEach(key => {
|
||||
if (key.toLowerCase() === header) {
|
||||
sanitized[key] = '[REDACTED]';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private sanitizeObject(obj: any): any {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(item => this.sanitizeObject(item));
|
||||
}
|
||||
|
||||
const sanitized: any = {};
|
||||
const sensitiveKeys = ['password', 'token', 'secret', 'key', 'auth'];
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (sensitiveKeys.some(sk => key.toLowerCase().includes(sk))) {
|
||||
sanitized[key] = '[REDACTED]';
|
||||
} else if (typeof value === 'object') {
|
||||
sanitized[key] = this.sanitizeObject(value);
|
||||
} else {
|
||||
sanitized[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private truncateURL(url: string, maxLength: number = 50): string {
|
||||
if (url.length > maxLength) {
|
||||
return url.substring(0, maxLength - 3) + '...';
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
private formatValue(value: any): string {
|
||||
if (typeof value === 'string') {
|
||||
return `"${value}"`;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'true' : 'false';
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value.toString();
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value, null, 2).substring(0, 100);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
private formatDuration(ms: number): string {
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`;
|
||||
}
|
||||
const seconds = (ms / 1000).toFixed(2);
|
||||
return `${seconds}s`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a logger for the current test context
|
||||
*/
|
||||
export function createLogger(filename: string): DebugLogger {
|
||||
const testInfo = test.info?.();
|
||||
|
||||
return new DebugLogger({
|
||||
testName: testInfo?.title || 'unknown',
|
||||
browser: testInfo?.project?.name || 'chromium',
|
||||
shard: testInfo?.parallelIndex?.toString() || '0',
|
||||
file: filename,
|
||||
});
|
||||
}
|
||||
289
tests/utils/diagnostic-helpers.ts
Normal file
289
tests/utils/diagnostic-helpers.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { Page, ConsoleMessage, Request } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Diagnostic Helpers for E2E Test Debugging
|
||||
*
|
||||
* These helpers enable comprehensive browser console logging and state capture
|
||||
* to diagnose test interruptions and failures. Use during Phase 1 investigation
|
||||
* to identify root causes of browser context closures.
|
||||
*
|
||||
* @see docs/reports/phase1_diagnostics.md
|
||||
*/
|
||||
|
||||
/**
|
||||
* Enable comprehensive browser console logging for diagnostic purposes
|
||||
* Captures console logs, page errors, request failures, and unhandled rejections
|
||||
*
|
||||
* @param page - Playwright Page instance
|
||||
* @param options - Optional configuration for logging behavior
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* test.beforeEach(async ({ page }) => {
|
||||
* enableDiagnosticLogging(page);
|
||||
* // ... test setup
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function enableDiagnosticLogging(
|
||||
page: Page,
|
||||
options: {
|
||||
captureConsole?: boolean;
|
||||
captureErrors?: boolean;
|
||||
captureRequests?: boolean;
|
||||
captureDialogs?: boolean;
|
||||
} = {}
|
||||
): void {
|
||||
const {
|
||||
captureConsole = true,
|
||||
captureErrors = true,
|
||||
captureRequests = true,
|
||||
captureDialogs = true,
|
||||
} = options;
|
||||
|
||||
// Console messages (all levels)
|
||||
if (captureConsole) {
|
||||
page.on('console', (msg: ConsoleMessage) => {
|
||||
const type = msg.type().toUpperCase();
|
||||
const text = msg.text();
|
||||
const location = msg.location();
|
||||
|
||||
// Special formatting for errors and warnings
|
||||
if (type === 'ERROR' || type === 'WARNING') {
|
||||
console.error(`[BROWSER ${type}] ${text}`);
|
||||
} else {
|
||||
console.log(`[BROWSER ${type}] ${text}`);
|
||||
}
|
||||
|
||||
if (location.url) {
|
||||
console.log(
|
||||
` Location: ${location.url}:${location.lineNumber}:${location.columnNumber}`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Page errors (JavaScript exceptions)
|
||||
if (captureErrors) {
|
||||
page.on('pageerror', (error: Error) => {
|
||||
console.error('═══════════════════════════════════════════');
|
||||
console.error('PAGE ERROR DETECTED');
|
||||
console.error('═══════════════════════════════════════════');
|
||||
console.error('Message:', error.message);
|
||||
console.error('Stack:', error.stack);
|
||||
console.error('Timestamp:', new Date().toISOString());
|
||||
console.error('═══════════════════════════════════════════');
|
||||
});
|
||||
}
|
||||
|
||||
// Request failures (network errors)
|
||||
if (captureRequests) {
|
||||
page.on('requestfailed', (request: Request) => {
|
||||
const failure = request.failure();
|
||||
console.error('─────────────────────────────────────────');
|
||||
console.error('REQUEST FAILED');
|
||||
console.error('─────────────────────────────────────────');
|
||||
console.error('URL:', request.url());
|
||||
console.error('Method:', request.method());
|
||||
console.error('Error:', failure?.errorText || 'Unknown');
|
||||
console.error('Timestamp:', new Date().toISOString());
|
||||
console.error('─────────────────────────────────────────');
|
||||
});
|
||||
}
|
||||
|
||||
// Unhandled promise rejections
|
||||
if (captureErrors) {
|
||||
page.on('console', (msg: ConsoleMessage) => {
|
||||
if (msg.type() === 'error' && msg.text().includes('Unhandled')) {
|
||||
console.error('╔═══════════════════════════════════════════╗');
|
||||
console.error('║ UNHANDLED PROMISE REJECTION DETECTED ║');
|
||||
console.error('╚═══════════════════════════════════════════╝');
|
||||
console.error(msg.text());
|
||||
console.error('Timestamp:', new Date().toISOString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Dialog events (if supported)
|
||||
if (captureDialogs) {
|
||||
page.on('dialog', async (dialog) => {
|
||||
console.log(`[DIALOG] Type: ${dialog.type()}, Message: ${dialog.message()}`);
|
||||
console.log(`[DIALOG] Timestamp: ${new Date().toISOString()}`);
|
||||
// Auto-dismiss to prevent blocking
|
||||
await dialog.dismiss();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture page state snapshot for debugging
|
||||
* Logs current URL, title, and HTML content length
|
||||
*
|
||||
* @param page - Playwright Page instance
|
||||
* @param label - Descriptive label for this snapshot
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await capturePageState(page, 'Before dialog open');
|
||||
* // ... perform action
|
||||
* await capturePageState(page, 'After dialog close');
|
||||
* ```
|
||||
*/
|
||||
export async function capturePageState(page: Page, label: string): Promise<void> {
|
||||
const url = page.url();
|
||||
const title = await page.title();
|
||||
const html = await page.content();
|
||||
|
||||
console.log(`\n========== PAGE STATE: ${label} ==========`);
|
||||
console.log(`URL: ${url}`);
|
||||
console.log(`Title: ${title}`);
|
||||
console.log(`HTML Length: ${html.length} characters`);
|
||||
console.log(`Timestamp: ${new Date().toISOString()}`);
|
||||
console.log(`===========================================\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track dialog lifecycle events for resource leak detection
|
||||
* Logs when dialogs open and close to identify cleanup issues
|
||||
*
|
||||
* @param page - Playwright Page instance
|
||||
* @param dialogSelector - Selector for the dialog element
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* test('dialog test', async ({ page }) => {
|
||||
* const tracker = trackDialogLifecycle(page, '[role="dialog"]');
|
||||
*
|
||||
* await openDialog(page);
|
||||
* await closeDialog(page);
|
||||
*
|
||||
* tracker.stop();
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function trackDialogLifecycle(
|
||||
page: Page,
|
||||
dialogSelector: string = '[role="dialog"]'
|
||||
): { stop: () => void } {
|
||||
let dialogCount = 0;
|
||||
let isRunning = true;
|
||||
|
||||
const checkDialog = async () => {
|
||||
if (!isRunning) return;
|
||||
|
||||
const dialogCount = await page.locator(dialogSelector).count();
|
||||
|
||||
if (dialogCount > 0) {
|
||||
console.log(`[DIALOG LIFECYCLE] ${dialogCount} dialog(s) detected on page`);
|
||||
console.log(`[DIALOG LIFECYCLE] Timestamp: ${new Date().toISOString()}`);
|
||||
}
|
||||
|
||||
setTimeout(() => checkDialog(), 1000);
|
||||
};
|
||||
|
||||
// Start monitoring
|
||||
checkDialog();
|
||||
|
||||
return {
|
||||
stop: () => {
|
||||
isRunning = false;
|
||||
console.log('[DIALOG LIFECYCLE] Tracking stopped');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitor browser context health during test execution
|
||||
* Detects when browser context is closed unexpectedly
|
||||
*
|
||||
* @param page - Playwright Page instance
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* test.beforeEach(async ({ page }) => {
|
||||
* monitorBrowserContext(page);
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function monitorBrowserContext(page: Page): void {
|
||||
const context = page.context();
|
||||
const browser = context.browser();
|
||||
|
||||
context.on('close', () => {
|
||||
console.error('╔═══════════════════════════════════════════╗');
|
||||
console.error('║ BROWSER CONTEXT CLOSED UNEXPECTEDLY ║');
|
||||
console.error('╚═══════════════════════════════════════════╝');
|
||||
console.error('Timestamp:', new Date().toISOString());
|
||||
console.error('This may indicate a resource leak or crash.');
|
||||
});
|
||||
|
||||
if (browser) {
|
||||
browser.on('disconnected', () => {
|
||||
console.error('╔═══════════════════════════════════════════╗');
|
||||
console.error('║ BROWSER DISCONNECTED UNEXPECTEDLY ║');
|
||||
console.error('╚═══════════════════════════════════════════╝');
|
||||
console.error('Timestamp:', new Date().toISOString());
|
||||
});
|
||||
}
|
||||
|
||||
page.on('close', () => {
|
||||
console.warn('[PAGE CLOSED]', new Date().toISOString());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Performance monitoring helper
|
||||
* Tracks test execution time and identifies slow operations
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* test('my test', async ({ page }) => {
|
||||
* const perf = startPerformanceMonitoring('My Test');
|
||||
*
|
||||
* perf.mark('Dialog open start');
|
||||
* await openDialog(page);
|
||||
* perf.mark('Dialog open end');
|
||||
*
|
||||
* perf.measure('Dialog open', 'Dialog open start', 'Dialog open end');
|
||||
* perf.report();
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function startPerformanceMonitoring(testName: string) {
|
||||
const startTime = performance.now();
|
||||
const marks: Map<string, number> = new Map();
|
||||
const measures: Array<{ name: string; duration: number }> = [];
|
||||
|
||||
return {
|
||||
mark(name: string): void {
|
||||
marks.set(name, performance.now());
|
||||
console.log(`[PERF MARK] ${name} at ${marks.get(name)! - startTime}ms`);
|
||||
},
|
||||
|
||||
measure(name: string, startMark: string, endMark: string): void {
|
||||
const start = marks.get(startMark);
|
||||
const end = marks.get(endMark);
|
||||
|
||||
if (start !== undefined && end !== undefined) {
|
||||
const duration = end - start;
|
||||
measures.push({ name, duration });
|
||||
console.log(`[PERF MEASURE] ${name}: ${duration.toFixed(2)}ms`);
|
||||
} else {
|
||||
console.warn(`[PERF WARN] Missing marks for measure: ${name}`);
|
||||
}
|
||||
},
|
||||
|
||||
report(): void {
|
||||
const totalTime = performance.now() - startTime;
|
||||
|
||||
console.log('\n========== PERFORMANCE REPORT ==========');
|
||||
console.log(`Test: ${testName}`);
|
||||
console.log(`Total Duration: ${totalTime.toFixed(2)}ms`);
|
||||
console.log('\nMeasurements:');
|
||||
measures.forEach(({ name, duration }) => {
|
||||
console.log(` ${name}: ${duration.toFixed(2)}ms`);
|
||||
});
|
||||
console.log('=========================================\n');
|
||||
},
|
||||
};
|
||||
}
|
||||
421
tests/utils/health-check.ts
Normal file
421
tests/utils/health-check.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
635
tests/utils/phase5-helpers.ts
Normal file
635
tests/utils/phase5-helpers.ts
Normal file
@@ -0,0 +1,635 @@
|
||||
/**
|
||||
* 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;
|
||||
305
tests/utils/security-helpers.ts
Normal file
305
tests/utils/security-helpers.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* 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 maxRetries = 5;
|
||||
const retryDelayMs = 1000;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
||||
const response = await request.get('/api/v1/security/status');
|
||||
|
||||
if (response.ok()) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
if (response.status() !== 429 || attempt === maxRetries) {
|
||||
throw new Error(
|
||||
`Failed to get security status: ${response.status()} ${await response.text()}`
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
||||
}
|
||||
|
||||
throw new Error('Failed to get security status after retries');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 maxRetries = 5;
|
||||
const retryDelayMs = 1000;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
||||
const response = await request.post('/api/v1/settings', {
|
||||
data: { key, value },
|
||||
});
|
||||
|
||||
if (response.ok()) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (response.status() !== 429 || attempt === maxRetries) {
|
||||
throw new Error(
|
||||
`Failed to set ${module} to ${enabled}: ${response.status()} ${await response.text()}`
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
197
tests/utils/test-steps.ts
Normal file
197
tests/utils/test-steps.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Test Step Logging Helpers
|
||||
*
|
||||
* Wrapper around test.step() that automatically logs step execution
|
||||
* with duration tracking, error handling, and integration with DebugLogger.
|
||||
*
|
||||
* Usage:
|
||||
* import { testStep } from './test-steps';
|
||||
* await testStep('Navigate to home page', async () => {
|
||||
* await page.goto('/');
|
||||
* });
|
||||
*/
|
||||
|
||||
import { test, Page, expect } from '@playwright/test';
|
||||
import { DebugLogger } from './debug-logger';
|
||||
|
||||
export interface TestStepOptions {
|
||||
timeout?: number;
|
||||
retries?: number;
|
||||
soft?: boolean;
|
||||
logger?: DebugLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around test.step() with automatic logging and metrics
|
||||
*/
|
||||
export async function testStep<T>(
|
||||
name: string,
|
||||
fn: () => Promise<T>,
|
||||
options: TestStepOptions = {}
|
||||
): Promise<T> {
|
||||
const startTime = performance.now();
|
||||
let duration = 0;
|
||||
|
||||
try {
|
||||
const result = await test.step(name, fn, {
|
||||
timeout: options.timeout,
|
||||
box: false,
|
||||
});
|
||||
|
||||
duration = performance.now() - startTime;
|
||||
|
||||
if (options.logger) {
|
||||
options.logger.step(name, Math.round(duration));
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
duration = performance.now() - startTime;
|
||||
|
||||
if (options.logger) {
|
||||
options.logger.error(name, error as Error, options.retries);
|
||||
}
|
||||
|
||||
if (options.soft) {
|
||||
// In soft assertion mode, log but don't throw
|
||||
console.warn(`⚠️ Soft failure in step "${name}": ${error}`);
|
||||
return undefined as any;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Page interaction helper with automatic logging
|
||||
*/
|
||||
export class LoggedPage {
|
||||
private logger: DebugLogger;
|
||||
private page: Page;
|
||||
|
||||
constructor(page: Page, logger: DebugLogger) {
|
||||
this.page = page;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
async click(selector: string): Promise<void> {
|
||||
return testStep(`Click: ${selector}`, async () => {
|
||||
const locator = this.page.locator(selector);
|
||||
const isVisible = await locator.isVisible().catch(() => false);
|
||||
this.logger.locator(selector, 'click', isVisible, 0);
|
||||
await locator.click();
|
||||
}, { logger: this.logger });
|
||||
}
|
||||
|
||||
async fill(selector: string, text: string): Promise<void> {
|
||||
return testStep(`Fill: ${selector}`, async () => {
|
||||
const locator = this.page.locator(selector);
|
||||
const isVisible = await locator.isVisible().catch(() => false);
|
||||
this.logger.locator(selector, 'fill', isVisible, 0);
|
||||
await locator.fill(text);
|
||||
}, { logger: this.logger });
|
||||
}
|
||||
|
||||
async goto(url: string): Promise<void> {
|
||||
return testStep(`Navigate to: ${url}`, async () => {
|
||||
await this.page.goto(url);
|
||||
}, { logger: this.logger });
|
||||
}
|
||||
|
||||
async waitForNavigation(fn: () => Promise<void>): Promise<void> {
|
||||
return testStep('Wait for navigation', async () => {
|
||||
await Promise.all([
|
||||
this.page.waitForNavigation(),
|
||||
fn(),
|
||||
]);
|
||||
}, { logger: this.logger });
|
||||
}
|
||||
|
||||
async screenshot(name: string): Promise<Buffer> {
|
||||
return testStep(`Screenshot: ${name}`, async () => {
|
||||
return this.page.screenshot({ fullPage: true });
|
||||
}, { logger: this.logger });
|
||||
}
|
||||
|
||||
getBaseLogger(): DebugLogger {
|
||||
return this.logger;
|
||||
}
|
||||
|
||||
getPage(): Page {
|
||||
return this.page;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assertion helper with automatic logging
|
||||
*/
|
||||
export async function testAssert(
|
||||
condition: string,
|
||||
assertion: () => Promise<void>,
|
||||
logger?: DebugLogger
|
||||
): Promise<void> {
|
||||
try {
|
||||
await assertion();
|
||||
logger?.assertion(condition, true);
|
||||
} catch (error) {
|
||||
logger?.assertion(condition, false);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a logged page wrapper for a test
|
||||
*/
|
||||
export function createLoggedPage(page: Page, logger: DebugLogger): LoggedPage {
|
||||
return new LoggedPage(page, logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a test step with retry logic and logging
|
||||
*/
|
||||
export async function testStepWithRetry<T>(
|
||||
name: string,
|
||||
fn: () => Promise<T>,
|
||||
maxRetries: number = 2,
|
||||
options: TestStepOptions = {}
|
||||
): Promise<T> {
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await testStep(
|
||||
attempt === 1 ? name : `${name} (Retry ${attempt - 1}/${maxRetries - 1})`,
|
||||
fn,
|
||||
options
|
||||
);
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
const backoff = Math.pow(2, attempt - 1) * 100; // Exponential backoff
|
||||
await new Promise(resolve => setTimeout(resolve, backoff));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Failed after ${maxRetries} attempts: ${lastError?.message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure and log the duration of an async operation
|
||||
*/
|
||||
export async function measureStep<T>(
|
||||
name: string,
|
||||
fn: () => Promise<T>,
|
||||
logger?: DebugLogger
|
||||
): Promise<{ result: T; duration: number }> {
|
||||
const startTime = performance.now();
|
||||
const result = await fn();
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
if (logger) {
|
||||
logger.step(name, Math.round(duration));
|
||||
}
|
||||
|
||||
return { result, duration };
|
||||
}
|
||||
415
tests/utils/ui-helpers.ts
Normal file
415
tests/utils/ui-helpers.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* UI Helpers - Shared utilities for common UI interactions
|
||||
*
|
||||
* These helpers provide reusable, robust locator strategies for common UI patterns
|
||||
* to reduce duplication and prevent flaky tests.
|
||||
*/
|
||||
|
||||
import { Page, Locator, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Options for toast helper
|
||||
*/
|
||||
export interface ToastHelperOptions {
|
||||
/** Maximum time to wait for toast (default: 5000ms) */
|
||||
timeout?: number;
|
||||
/** Toast type to match (success, error, info, warning) */
|
||||
type?: 'success' | 'error' | 'info' | 'warning';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a toast locator with proper role-based selection and short retries.
|
||||
* Uses data-testid for our custom toast system to avoid strict-mode violations.
|
||||
*
|
||||
* react-hot-toast uses:
|
||||
* - role="status" for success/info toasts
|
||||
* - role="alert" for error toasts
|
||||
*
|
||||
* @param page - Playwright Page instance
|
||||
* @param text - Text or RegExp to match in toast (optional for type-only match)
|
||||
* @param options - Configuration options
|
||||
* @returns Locator for the toast
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const toast = getToastLocator(page, /success/i, { type: 'success' });
|
||||
* await expect(toast).toBeVisible({ timeout: 5000 });
|
||||
* ```
|
||||
*/
|
||||
export function getToastLocator(
|
||||
page: Page,
|
||||
text?: string | RegExp,
|
||||
options: ToastHelperOptions = {}
|
||||
): Locator {
|
||||
const { type } = options;
|
||||
|
||||
// Build selector with fallbacks for reliability
|
||||
// react-hot-toast: role="status" for success/info, role="alert" for errors
|
||||
let baseLocator: Locator;
|
||||
|
||||
if (type === 'error') {
|
||||
// Error toasts use role="alert"
|
||||
baseLocator = page.locator(`[data-testid="toast-${type}"]`)
|
||||
.or(page.getByRole('alert'));
|
||||
} else if (type === 'success' || type === 'info') {
|
||||
// Success/info toasts use role="status"
|
||||
baseLocator = page.locator(`[data-testid="toast-${type}"]`)
|
||||
.or(page.getByRole('status'));
|
||||
} else if (type === 'warning') {
|
||||
// Warning toasts - check both roles as fallback
|
||||
baseLocator = page.locator(`[data-testid="toast-${type}"]`)
|
||||
.or(page.getByRole('status'))
|
||||
.or(page.getByRole('alert'));
|
||||
} else {
|
||||
// Any toast: match our custom toast container with fallbacks for both roles
|
||||
baseLocator = page.locator('[data-testid^="toast-"]')
|
||||
.or(page.getByRole('status'))
|
||||
.or(page.getByRole('alert'));
|
||||
}
|
||||
|
||||
// Filter by text if provided
|
||||
if (text) {
|
||||
return baseLocator.filter({ hasText: text }).first();
|
||||
}
|
||||
|
||||
return baseLocator.first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a toast to appear with specific text and type.
|
||||
* Wrapper around getToastLocator with built-in wait.
|
||||
*
|
||||
* @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: ToastHelperOptions = {}
|
||||
): Promise<void> {
|
||||
const { timeout = 5000 } = options;
|
||||
const toast = getToastLocator(page, text, options);
|
||||
await expect(toast).toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for row-scoped button locator
|
||||
*/
|
||||
export interface RowScopedButtonOptions {
|
||||
/** Maximum time to wait for button (default: 5000ms) */
|
||||
timeout?: number;
|
||||
/** Button role (default: 'button') */
|
||||
role?: 'button' | 'link';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a button locator scoped to a specific table row, avoiding strict-mode violations.
|
||||
* Use this when multiple rows have buttons with the same name (e.g., "Invite", "Resend").
|
||||
*
|
||||
* @param page - Playwright Page instance
|
||||
* @param rowIdentifier - Text to identify the row (e.g., email, name)
|
||||
* @param buttonName - Button name/label or accessible name pattern
|
||||
* @param options - Configuration options
|
||||
* @returns Locator for the button within the row
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Find "Invite" button in row containing "user@example.com"
|
||||
* const inviteBtn = getRowScopedButton(page, 'user@example.com', /invite/i);
|
||||
* await inviteBtn.click();
|
||||
* ```
|
||||
*/
|
||||
export function getRowScopedButton(
|
||||
page: Page,
|
||||
rowIdentifier: string | RegExp,
|
||||
buttonName: string | RegExp,
|
||||
options: RowScopedButtonOptions = {}
|
||||
): Locator {
|
||||
const { role = 'button' } = options;
|
||||
|
||||
// Find the row containing the identifier
|
||||
const row = page.getByRole('row').filter({ hasText: rowIdentifier });
|
||||
|
||||
// Find the button within that row
|
||||
return row.getByRole(role, { name: buttonName });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an action button in a table row by icon class (e.g., lucide-mail for resend).
|
||||
* Use when buttons don't have proper accessible names.
|
||||
*
|
||||
* @param page - Playwright Page instance
|
||||
* @param rowIdentifier - Text to identify the row
|
||||
* @param iconClass - Icon class to match (e.g., 'lucide-mail', 'lucide-trash-2')
|
||||
* @returns Locator for the button
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Find resend button (mail icon) in row containing "user@example.com"
|
||||
* const resendBtn = getRowScopedIconButton(page, 'user@example.com', 'lucide-mail');
|
||||
* await resendBtn.click();
|
||||
* ```
|
||||
*/
|
||||
export function getRowScopedIconButton(
|
||||
page: Page,
|
||||
rowIdentifier: string | RegExp,
|
||||
iconClass: string
|
||||
): Locator {
|
||||
const row = page.getByRole('row').filter({ hasText: rowIdentifier });
|
||||
return row.locator(`button:has(svg.${iconClass})`).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a certificate form validation message (email field).
|
||||
* Targets the visible validation message with proper role/text.
|
||||
*
|
||||
* @param page - Playwright Page instance
|
||||
* @param messagePattern - Pattern to match in validation message
|
||||
* @param options - Configuration options
|
||||
* @returns Locator for the validation message
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const validationMsg = getCertificateValidationMessage(page, /valid.*email/i);
|
||||
* await expect(validationMsg).toBeVisible();
|
||||
* ```
|
||||
*/
|
||||
export function getCertificateValidationMessage(
|
||||
page: Page,
|
||||
messagePattern: string | RegExp
|
||||
): Locator {
|
||||
// Look for validation message in common locations:
|
||||
// 1. Adjacent to input with aria-describedby
|
||||
// 2. Role="alert" or "status" for live region
|
||||
// 3. Common validation message containers
|
||||
return page
|
||||
.locator('[role="alert"], [role="status"], .text-red-500, [class*="error"]')
|
||||
.filter({ hasText: messagePattern })
|
||||
.first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh a list/table and wait for it to stabilize.
|
||||
* Use after creating resources via API or UI to ensure list reflects changes.
|
||||
*
|
||||
* @param page - Playwright Page instance
|
||||
* @param options - Configuration options
|
||||
*/
|
||||
export async function refreshListAndWait(
|
||||
page: Page,
|
||||
options: { timeout?: number } = {}
|
||||
): Promise<void> {
|
||||
const { timeout = 5000 } = options;
|
||||
|
||||
// Reload the page
|
||||
await page.reload();
|
||||
|
||||
// Wait for list to be visible (supports table, grid, or card layouts)
|
||||
// Try table first, then grid, then card container
|
||||
let listElement = page.getByRole('table');
|
||||
let isVisible = await listElement.isVisible({ timeout: 1000 }).catch(() => false);
|
||||
|
||||
if (!isVisible) {
|
||||
// Fallback to grid layout (e.g., DNS providers in grid)
|
||||
listElement = page.locator('.grid > div, [data-testid="list-container"]');
|
||||
isVisible = await listElement.first().isVisible({ timeout: 1000 }).catch(() => false);
|
||||
}
|
||||
|
||||
// If still not visible, wait for the page to stabilize with any content
|
||||
if (!isVisible) {
|
||||
await page.waitForLoadState('networkidle', { timeout });
|
||||
}
|
||||
|
||||
// Wait for any loading indicators to clear
|
||||
const loader = page.locator('[role="progressbar"], [aria-busy="true"], .loading-spinner');
|
||||
await expect(loader).toHaveCount(0, { timeout: 3000 }).catch(() => {
|
||||
// Ignore if no loader exists
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for switch helper functions
|
||||
*/
|
||||
export interface SwitchOptions {
|
||||
/** Timeout for waiting operations (default: 5000ms) */
|
||||
timeout?: number;
|
||||
/** Padding to add above element when scrolling (default: 100px for sticky header) */
|
||||
scrollPadding?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a Switch/Toggle component reliably across all browsers.
|
||||
*
|
||||
* The Switch component uses a hidden input with a styled sibling div.
|
||||
* This helper clicks the parent <label> to trigger the toggle.
|
||||
*
|
||||
* ✅ FIX P0: Wait for ConfigReloadOverlay to disappear before clicking
|
||||
* The overlay intercepts pointer events during Caddy config reloads.
|
||||
*
|
||||
* @param locator - Locator for the switch (e.g., page.getByRole('switch'))
|
||||
* @param options - Configuration options
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // By role with name
|
||||
* await clickSwitch(page.getByRole('switch', { name: /cerberus/i }));
|
||||
*
|
||||
* // By test ID
|
||||
* await clickSwitch(page.getByTestId('toggle-acl'));
|
||||
*
|
||||
* // By label
|
||||
* await clickSwitch(page.getByLabel(/enabled/i));
|
||||
* ```
|
||||
*/
|
||||
export async function clickSwitch(
|
||||
locator: Locator,
|
||||
options: SwitchOptions = {}
|
||||
): Promise<void> {
|
||||
const { scrollPadding = 100, timeout = 5000 } = options;
|
||||
|
||||
// ✅ FIX P0: Wait for config reload overlay to disappear
|
||||
// The ConfigReloadOverlay component (z-50) intercepts pointer events
|
||||
// during Caddy config reloads, blocking all interactions
|
||||
const page = locator.page();
|
||||
const overlay = page.locator('[data-testid="config-reload-overlay"]');
|
||||
await overlay.waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {
|
||||
// Overlay not present or already hidden - continue
|
||||
});
|
||||
|
||||
// Wait for the switch to be visible
|
||||
await expect(locator).toBeVisible({ timeout });
|
||||
|
||||
// Get the parent label element
|
||||
// Switch structure: <label><input sr-only /><div /></label>
|
||||
const labelElement = locator.locator('xpath=ancestor::label').first();
|
||||
|
||||
// Scroll with padding to clear sticky header
|
||||
await labelElement.evaluate((el, padding) => {
|
||||
el.scrollIntoView({ block: 'center' });
|
||||
// Additional scroll if near top
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.top < padding) {
|
||||
window.scrollBy(0, -(padding - rect.top));
|
||||
}
|
||||
}, scrollPadding);
|
||||
|
||||
// Click the label (which triggers the input)
|
||||
await labelElement.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a Switch/Toggle component's checked state.
|
||||
*
|
||||
* @param locator - Locator for the switch
|
||||
* @param expected - Expected checked state (true/false)
|
||||
* @param options - Configuration options
|
||||
*/
|
||||
export async function expectSwitchState(
|
||||
locator: Locator,
|
||||
expected: boolean,
|
||||
options: SwitchOptions = {}
|
||||
): Promise<void> {
|
||||
const { timeout = 5000 } = options;
|
||||
|
||||
if (expected) {
|
||||
await expect(locator).toBeChecked({ timeout });
|
||||
} else {
|
||||
await expect(locator).not.toBeChecked({ timeout });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a Switch/Toggle component and verify the state changed.
|
||||
* Returns the new checked state.
|
||||
*
|
||||
* @param locator - Locator for the switch
|
||||
* @param options - Configuration options
|
||||
* @returns The new checked state after toggle
|
||||
*/
|
||||
export async function toggleSwitch(
|
||||
locator: Locator,
|
||||
options: SwitchOptions = {}
|
||||
): Promise<boolean> {
|
||||
const { timeout = 5000 } = options;
|
||||
|
||||
// Get current state
|
||||
const wasChecked = await locator.isChecked();
|
||||
|
||||
// Click to toggle
|
||||
await clickSwitch(locator, options);
|
||||
|
||||
// Verify state changed and return new state
|
||||
const newState = !wasChecked;
|
||||
await expectSwitchState(locator, newState, { timeout });
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for form field helper
|
||||
*/
|
||||
export interface FormFieldOptions {
|
||||
/** Placeholder text to use as fallback */
|
||||
placeholder?: string | RegExp;
|
||||
/** Field ID to use as fallback */
|
||||
fieldId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get form field with cross-browser label matching.
|
||||
* Tries multiple strategies: label, placeholder, id, aria-label.
|
||||
*
|
||||
* ✅ FIX 2.2: Cross-browser label matching for Firefox/WebKit compatibility
|
||||
* Implements fallback chain to handle browser differences in label association.
|
||||
*
|
||||
* @param page - Playwright Page instance
|
||||
* @param labelPattern - Text or RegExp to match label
|
||||
* @param options - Configuration options with fallback strategies
|
||||
* @returns Locator for the form field
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Basic usage with label only
|
||||
* const nameInput = getFormFieldByLabel(page, /name/i);
|
||||
*
|
||||
* // With fallbacks for robustness
|
||||
* const scriptField = getFormFieldByLabel(
|
||||
* page,
|
||||
* /script.*path/i,
|
||||
* {
|
||||
* placeholder: /dns-challenge\.sh/i,
|
||||
* fieldId: 'field-script_path'
|
||||
* }
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function getFormFieldByLabel(
|
||||
page: Page,
|
||||
labelPattern: string | RegExp,
|
||||
options: FormFieldOptions = {}
|
||||
): Locator {
|
||||
const baseLocator = page.getByLabel(labelPattern);
|
||||
|
||||
// Build fallback chain
|
||||
let locator = baseLocator;
|
||||
|
||||
if (options.placeholder) {
|
||||
locator = locator.or(page.getByPlaceholder(options.placeholder));
|
||||
}
|
||||
|
||||
if (options.fieldId) {
|
||||
locator = locator.or(page.locator(`#${options.fieldId}`));
|
||||
}
|
||||
|
||||
// Fallback: role + label text nearby
|
||||
if (typeof labelPattern === 'string') {
|
||||
locator = locator.or(
|
||||
page.getByRole('textbox').filter({
|
||||
has: page.locator(`label:has-text("${labelPattern}")`),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return locator;
|
||||
}
|
||||
490
tests/utils/wait-helpers.spec.ts
Normal file
490
tests/utils/wait-helpers.spec.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* Unit tests for wait-helpers.ts - Semantic Wait Helpers
|
||||
*
|
||||
* These tests verify the behavior of deterministic wait utilities
|
||||
* that replace arbitrary `page.waitForTimeout()` calls.
|
||||
*/
|
||||
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import {
|
||||
waitForDialog,
|
||||
waitForFormFields,
|
||||
waitForDebounce,
|
||||
waitForConfigReload,
|
||||
waitForNavigation,
|
||||
} from './wait-helpers';
|
||||
|
||||
test.describe('wait-helpers - Semantic Wait Functions', () => {
|
||||
test.describe('waitForDialog', () => {
|
||||
test('should wait for dialog to be visible and interactive', async ({ page }) => {
|
||||
// Create a test page with dialog
|
||||
await page.setContent(`
|
||||
<button id="open-dialog">Open Dialog</button>
|
||||
<div role="dialog" id="test-dialog" style="display: none;">
|
||||
<h2>Test Dialog</h2>
|
||||
<button id="close-dialog">Close</button>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('open-dialog').onclick = () => {
|
||||
setTimeout(() => {
|
||||
document.getElementById('test-dialog').style.display = 'block';
|
||||
}, 100);
|
||||
};
|
||||
</script>
|
||||
`);
|
||||
|
||||
await test.step('Open dialog and wait for it to be interactive', async () => {
|
||||
await page.click('#open-dialog');
|
||||
const dialog = await waitForDialog(page);
|
||||
|
||||
// Verify dialog is visible and interactive
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole('heading')).toHaveText('Test Dialog');
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle dialog with aria-busy attribute', async ({ page }) => {
|
||||
// Create a dialog that starts busy then becomes interactive
|
||||
await page.setContent(`
|
||||
<button id="open-dialog">Open Dialog</button>
|
||||
<div role="dialog" id="test-dialog" style="display: none;" aria-busy="true">
|
||||
<h2>Loading Dialog</h2>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('open-dialog').onclick = () => {
|
||||
const dialog = document.getElementById('test-dialog');
|
||||
dialog.style.display = 'block';
|
||||
// Simulate loading complete after 200ms
|
||||
setTimeout(() => {
|
||||
dialog.removeAttribute('aria-busy');
|
||||
}, 200);
|
||||
};
|
||||
</script>
|
||||
`);
|
||||
|
||||
await page.click('#open-dialog');
|
||||
const dialog = await waitForDialog(page);
|
||||
|
||||
// Verify dialog is no longer busy
|
||||
await expect(dialog).not.toHaveAttribute('aria-busy', 'true');
|
||||
});
|
||||
|
||||
test('should handle alertdialog role', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<button id="open-alert">Open Alert</button>
|
||||
<div role="alertdialog" id="alert-dialog" style="display: none;">
|
||||
<h2>Alert Dialog</h2>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('open-alert').onclick = () => {
|
||||
document.getElementById('alert-dialog').style.display = 'block';
|
||||
};
|
||||
</script>
|
||||
`);
|
||||
|
||||
await page.click('#open-alert');
|
||||
const dialog = await waitForDialog(page, { role: 'alertdialog' });
|
||||
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toHaveAttribute('role', 'alertdialog');
|
||||
});
|
||||
|
||||
test('should timeout if dialog never appears', async ({ page }) => {
|
||||
await page.setContent(`<div>No dialog here</div>`);
|
||||
|
||||
await expect(
|
||||
waitForDialog(page, { timeout: 1000 })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('waitForFormFields', () => {
|
||||
test('should wait for dynamically loaded form fields', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<select id="form-type">
|
||||
<option value="basic">Basic</option>
|
||||
<option value="advanced">Advanced</option>
|
||||
</select>
|
||||
<div id="dynamic-fields"></div>
|
||||
<script>
|
||||
document.getElementById('form-type').onchange = (e) => {
|
||||
const container = document.getElementById('dynamic-fields');
|
||||
if (e.target.value === 'advanced') {
|
||||
setTimeout(() => {
|
||||
container.innerHTML = '<input type="text" name="advanced-field" id="advanced-field" />';
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
`);
|
||||
|
||||
await test.step('Select form type and wait for fields', async () => {
|
||||
await page.selectOption('#form-type', 'advanced');
|
||||
await waitForFormFields(page, '#advanced-field');
|
||||
|
||||
const field = page.locator('#advanced-field');
|
||||
await expect(field).toBeVisible();
|
||||
await expect(field).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should wait for field to be enabled', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<button id="enable-field">Enable Field</button>
|
||||
<input type="text" id="test-field" disabled />
|
||||
<script>
|
||||
document.getElementById('enable-field').onclick = () => {
|
||||
setTimeout(() => {
|
||||
document.getElementById('test-field').disabled = false;
|
||||
}, 100);
|
||||
};
|
||||
</script>
|
||||
`);
|
||||
|
||||
await page.click('#enable-field');
|
||||
await waitForFormFields(page, '#test-field', { shouldBeEnabled: true, timeout: 2000 });
|
||||
|
||||
const field = page.locator('#test-field');
|
||||
await expect(field).toBeEnabled({ timeout: 2000 });
|
||||
});
|
||||
|
||||
test('should handle disabled fields when shouldBeEnabled is false', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<input type="text" id="disabled-field" disabled />
|
||||
`);
|
||||
|
||||
// Should not throw even though field is disabled
|
||||
await waitForFormFields(page, '#disabled-field', { shouldBeEnabled: false });
|
||||
|
||||
const field = page.locator('#disabled-field');
|
||||
await expect(field).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('waitForDebounce', () => {
|
||||
test('should wait for network idle after input', async ({ page }) => {
|
||||
// Create a page with a search that triggers API call
|
||||
await page.route('**/api/search*', async (route) => {
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
await route.fulfill({ json: { results: [] } });
|
||||
});
|
||||
|
||||
await page.setContent(`
|
||||
<input type="text" id="search-input" />
|
||||
<script>
|
||||
let timeout;
|
||||
document.getElementById('search-input').oninput = (e) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
fetch('/api/search?q=' + e.target.value);
|
||||
}, 300);
|
||||
};
|
||||
</script>
|
||||
`);
|
||||
|
||||
await test.step('Type and wait for debounce to settle', async () => {
|
||||
await page.fill('#search-input', 'test query');
|
||||
await waitForDebounce(page);
|
||||
|
||||
// Network should be idle and API called
|
||||
// Verify by checking if input is still interactive
|
||||
const input = page.locator('#search-input');
|
||||
await expect(input).toHaveValue('test query');
|
||||
});
|
||||
});
|
||||
|
||||
test('should wait for loading indicator', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<input type="text" id="search-input" />
|
||||
<div class="search-loading" style="display: none;">Searching...</div>
|
||||
<script>
|
||||
let timeout;
|
||||
document.getElementById('search-input').oninput = (e) => {
|
||||
const loader = document.querySelector('.search-loading');
|
||||
clearTimeout(timeout);
|
||||
loader.style.display = 'block';
|
||||
timeout = setTimeout(() => {
|
||||
loader.style.display = 'none';
|
||||
}, 200);
|
||||
};
|
||||
</script>
|
||||
`);
|
||||
|
||||
await page.fill('#search-input', 'test');
|
||||
await waitForDebounce(page, { indicatorSelector: '.search-loading' });
|
||||
|
||||
const loader = page.locator('.search-loading');
|
||||
await expect(loader).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('waitForConfigReload', () => {
|
||||
test('should wait for config reload overlay to disappear', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<button id="save-settings">Save</button>
|
||||
<div role="status" data-testid="config-reload-overlay" style="display: none;">
|
||||
Reloading configuration...
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('save-settings').onclick = () => {
|
||||
const overlay = document.querySelector('[data-testid="config-reload-overlay"]');
|
||||
overlay.style.display = 'block';
|
||||
setTimeout(() => {
|
||||
overlay.style.display = 'none';
|
||||
}, 500);
|
||||
};
|
||||
</script>
|
||||
`);
|
||||
|
||||
await test.step('Save settings and wait for reload', async () => {
|
||||
await page.click('#save-settings');
|
||||
await waitForConfigReload(page);
|
||||
|
||||
const overlay = page.locator('[data-testid="config-reload-overlay"]');
|
||||
await expect(overlay).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle instant reload (no overlay)', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<button id="save-settings">Save</button>
|
||||
<div>Settings saved</div>
|
||||
`);
|
||||
|
||||
// Should not throw even if overlay never appears
|
||||
await page.click('#save-settings');
|
||||
await waitForConfigReload(page);
|
||||
});
|
||||
|
||||
test('should wait for DOM to be interactive after reload', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<button id="save-settings">Save</button>
|
||||
<div role="status" style="display: none;">Reloading...</div>
|
||||
<script>
|
||||
document.getElementById('save-settings').onclick = () => {
|
||||
const overlay = document.querySelector('[role="status"]');
|
||||
overlay.style.display = 'block';
|
||||
setTimeout(() => {
|
||||
overlay.style.display = 'none';
|
||||
}, 300);
|
||||
};
|
||||
</script>
|
||||
`);
|
||||
|
||||
await page.click('#save-settings');
|
||||
await waitForConfigReload(page);
|
||||
|
||||
// Page should be interactive
|
||||
const button = page.locator('#save-settings');
|
||||
await expect(button).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('waitForNavigation', () => {
|
||||
test('should wait for URL change with string match', async ({ page }) => {
|
||||
await page.route('**/test-page', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/html',
|
||||
body: '<h1>New Page</h1>',
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('about:blank');
|
||||
await page.setContent(`
|
||||
<a href="http://127.0.0.1:8080/test-page" id="nav-link">Navigate</a>
|
||||
`);
|
||||
|
||||
const link = page.locator('#nav-link');
|
||||
await link.click();
|
||||
|
||||
// Wait for navigation to complete
|
||||
await waitForNavigation(page, /\/test-page$/);
|
||||
|
||||
// Verify new page loaded
|
||||
await expect(page.locator('h1')).toHaveText('New Page');
|
||||
});
|
||||
|
||||
test('should wait for URL change with RegExp match', async ({ page }) => {
|
||||
await page.goto('about:blank');
|
||||
|
||||
// Navigate to a data URL
|
||||
await page.goto('data:text/html,<div id="content">Test Page</div>');
|
||||
|
||||
await waitForNavigation(page, /data:text\/html/);
|
||||
|
||||
const content = page.locator('#content');
|
||||
await expect(content).toHaveText('Test Page');
|
||||
});
|
||||
|
||||
test('should wait for specified load state', async ({ page }) => {
|
||||
await page.goto('about:blank');
|
||||
|
||||
// Navigate with domcontentloaded state
|
||||
const navigationPromise = page.goto('data:text/html,<h1>Page</h1>');
|
||||
|
||||
await waitForNavigation(page, /data:text\/html/, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
await navigationPromise;
|
||||
await expect(page.locator('h1')).toHaveText('Page');
|
||||
});
|
||||
|
||||
test('should timeout if navigation never completes', async ({ page }) => {
|
||||
await page.goto('about:blank');
|
||||
|
||||
await expect(
|
||||
waitForNavigation(page, /never-matching-url/, { timeout: 1000 })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Integration tests - Multiple wait helpers', () => {
|
||||
test('should handle dialog with form fields and debounced search', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<button id="open-dialog">Open Dialog</button>
|
||||
<div role="dialog" id="test-dialog" style="display: none;">
|
||||
<h2>Search Dialog</h2>
|
||||
<input type="text" id="search" />
|
||||
<div class="search-loading" style="display: none;">Loading...</div>
|
||||
<div id="results"></div>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('open-dialog').onclick = () => {
|
||||
const dialog = document.getElementById('test-dialog');
|
||||
dialog.style.display = 'block';
|
||||
};
|
||||
|
||||
let timeout;
|
||||
document.getElementById('search').oninput = (e) => {
|
||||
const loader = document.querySelector('.search-loading');
|
||||
const results = document.getElementById('results');
|
||||
clearTimeout(timeout);
|
||||
loader.style.display = 'block';
|
||||
timeout = setTimeout(() => {
|
||||
loader.style.display = 'none';
|
||||
results.textContent = 'Results for: ' + e.target.value;
|
||||
}, 200);
|
||||
};
|
||||
</script>
|
||||
`);
|
||||
|
||||
await test.step('Open dialog', async () => {
|
||||
await page.click('#open-dialog');
|
||||
const dialog = await waitForDialog(page);
|
||||
await expect(dialog).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Wait for search field', async () => {
|
||||
await waitForFormFields(page, '#search');
|
||||
const searchField = page.locator('#search');
|
||||
await expect(searchField).toBeEnabled();
|
||||
});
|
||||
|
||||
await test.step('Search with debounce', async () => {
|
||||
await page.fill('#search', 'test query');
|
||||
await waitForDebounce(page, { indicatorSelector: '.search-loading' });
|
||||
|
||||
const results = page.locator('#results');
|
||||
await expect(results).toHaveText('Results for: test query');
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle form submission with config reload', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<form id="settings-form">
|
||||
<input type="text" id="setting-name" />
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
<div role="status" data-testid="config-reload-overlay" style="display: none;">
|
||||
Reloading configuration...
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('settings-form').onsubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const overlay = document.querySelector('[data-testid="config-reload-overlay"]');
|
||||
overlay.style.display = 'block';
|
||||
setTimeout(() => {
|
||||
overlay.style.display = 'none';
|
||||
}, 300);
|
||||
};
|
||||
</script>
|
||||
`);
|
||||
|
||||
await test.step('Wait for form field and fill', async () => {
|
||||
await waitForFormFields(page, '#setting-name');
|
||||
await page.fill('#setting-name', 'test value');
|
||||
});
|
||||
|
||||
await test.step('Submit and wait for config reload', async () => {
|
||||
await page.click('button[type="submit"]');
|
||||
await waitForConfigReload(page);
|
||||
|
||||
const overlay = page.locator('[data-testid="config-reload-overlay"]');
|
||||
await expect(overlay).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Error handling and edge cases', () => {
|
||||
test('waitForDialog should handle multiple dialogs', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<div role="dialog" class="dialog-1">Dialog 1</div>
|
||||
<div role="dialog" class="dialog-2" style="display: none;">Dialog 2</div>
|
||||
`);
|
||||
|
||||
// Should find the first visible dialog
|
||||
const dialog = await waitForDialog(page);
|
||||
await expect(dialog).toHaveClass(/dialog-1/);
|
||||
});
|
||||
|
||||
test('waitForFormFields should handle detached elements', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<button id="add-field">Add Field</button>
|
||||
<div id="container"></div>
|
||||
<script>
|
||||
document.getElementById('add-field').onclick = () => {
|
||||
const container = document.getElementById('container');
|
||||
container.innerHTML = '';
|
||||
setTimeout(() => {
|
||||
container.innerHTML = '<input type="text" id="new-field" />';
|
||||
}, 100);
|
||||
};
|
||||
</script>
|
||||
`);
|
||||
|
||||
await page.click('#add-field');
|
||||
await waitForFormFields(page, '#new-field');
|
||||
|
||||
const field = page.locator('#new-field');
|
||||
await expect(field).toBeAttached();
|
||||
});
|
||||
|
||||
test('waitForDebounce should handle rapid consecutive inputs', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<input type="text" id="rapid-input" />
|
||||
<div class="loading" style="display: none;">Loading...</div>
|
||||
<script>
|
||||
let timeout;
|
||||
document.getElementById('rapid-input').oninput = () => {
|
||||
const loader = document.querySelector('.loading');
|
||||
clearTimeout(timeout);
|
||||
loader.style.display = 'block';
|
||||
timeout = setTimeout(() => {
|
||||
loader.style.display = 'none';
|
||||
}, 200);
|
||||
};
|
||||
</script>
|
||||
`);
|
||||
|
||||
// Rapid typing simulation
|
||||
await page.fill('#rapid-input', 'a');
|
||||
await page.fill('#rapid-input', 'ab');
|
||||
await page.fill('#rapid-input', 'abc');
|
||||
|
||||
await waitForDebounce(page, { indicatorSelector: '.loading' });
|
||||
|
||||
const loader = page.locator('.loading');
|
||||
await expect(loader).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
1286
tests/utils/wait-helpers.ts
Normal file
1286
tests/utils/wait-helpers.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user