2026-02-14 02:20:19 +00:00

411 lines
10 KiB
TypeScript

/**
* Playwright Test Helpers & Fixtures
*
* This module provides reusable test utilities for deterministic testing
* including:
* - Test user type definitions and helper functions
* - `useE2EUser()` compatibility fixture for legacy tests
* - applyDeterministicPage() to mock time and ensure consistent test runs
* - Test data generators for properties, leases, and messages
* - Helper functions for common assertions (expectSuccess, expectError, etc.)
*/
import { type Page, test as base } from "@playwright/test";
// Extend Window interface for E2E testing
declare global {
interface Window {
__E2E_SKIP_ENCRYPTION__?: boolean;
}
// eslint-disable-next-line no-var
var __E2E_SKIP_ENCRYPTION__: boolean | undefined;
}
/** Test user types used by helper utilities. */
export type E2EUserType =
| "landlord"
| "landlord-2"
| "landlord-onboarding"
| "tenant"
| "admin";
/** Legacy user ids retained for compatibility with existing test data. */
export const E2E_USER_IDS: Record<E2EUserType, string> = {
landlord: "e2e-landlord-1",
"landlord-2": "e2e-landlord-2",
"landlord-onboarding": "e2e-landlord-setup-1",
tenant: "e2e-tenant-1",
admin: "e2e-admin-1",
} as const;
/** Base time for deterministic tests. */
export const BASE_TIME = new Date("2024-01-15T12:00:00.000Z").getTime();
/** Creates test headers for authenticated requests. */
export function e2eHeaders(userType: E2EUserType): Record<string, string> {
void userType;
return {
"Content-Type": "application/json",
};
}
/**
* Extends Playwright test with E2E fixtures
*/
export const test = base.extend<{
e2eUser: (userType: E2EUserType) => void;
}>({
/**
* Compatibility fixture for legacy browser tests.
*/
e2eUser: async ({ page }, use) => {
// This is a placeholder - actual user setting is done via useE2EUser()
// deno-lint-ignore react-rules-of-hooks
await use(async (userType: E2EUserType) => {
await useE2EUser(page, userType);
});
},
});
/**
* Sets compatibility page flags used by legacy tests.
*
* @param page - Playwright page object
* @param userType - Type of test user (landlord, tenant, etc.)
*/
export async function useE2EUser(
page: Page,
userType: E2EUserType,
): Promise<void> {
void userType;
// Set the skip-encryption flag in localStorage for readable test data
await page.addInitScript(() => {
globalThis.__E2E_SKIP_ENCRYPTION__ = true;
localStorage.setItem("e2e-skip-encryption", "true");
});
}
/**
* Applies deterministic behavior to the page
* - Stubs Date.now() to return BASE_TIME
* - Stubs Math.random() for consistent sequences
* - Disables CSS animations and transitions
* - Sets timezone to UTC
*
* @param page - Playwright page object
*/
export async function applyDeterministicPage(page: Page): Promise<void> {
const css = `
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
}
`;
await page.addInitScript(`
// Stub Date.now() to return deterministic base time
const BASE_TIME = ${BASE_TIME};
const originalDateNow = Date.now;
Date.now = function() {
return BASE_TIME;
};
// Stub new Date() to use deterministic time
const originalDate = globalThis.Date;
globalThis.Date = function(...args) {
if (args.length === 0) {
return new originalDate(BASE_TIME);
}
return new originalDate(...args);
};
Object.assign(globalThis.Date, originalDate);
// Stub Math.random() for consistent sequences
let randomSeed = 12345;
const originalRandom = Math.random;
Math.random = function() {
randomSeed = (randomSeed * 1103515245 + 12345) & 0x7fffffff;
return randomSeed / 0x7fffffff;
};
// Disable CSS animations and transitions for consistent screenshots
const style = document.createElement('style');
style.textContent = ${JSON.stringify(css)};
document.head.appendChild(style);
`);
}
/**
* Test data generators for creating realistic test data
*/
/**
* Generates a unique test property
*/
export function generateTestProperty(
overrides?: Partial<{
title: string;
address_1: string;
city: string;
state: string;
zip: string;
bedrooms: number;
bathrooms: number;
monthly_rent: number;
}>,
) {
const timestamp = Date.now();
return {
title: `Test Property ${timestamp}`,
description: "Automated test property",
address_1: `${timestamp} Test Street`,
address_2: "",
city: "Test City",
state: "TS",
zip: "00000",
country: "USA",
bedrooms: 2,
bathrooms: 1,
square_footage: 1000,
year_built: 2020,
property_type: "apartment",
monthly_rent: 1500,
security_deposit: 1500,
lease_duration_months: 12,
amenities: "Test amenities",
...overrides,
};
}
/**
* Generates a unique test lease
*/
export function generateTestLease(
overrides?: Partial<{
property_id: string;
start_date: number;
end_date: number;
rent: number;
}>,
) {
const timestamp = Date.now();
return {
property_id: "test-property-id",
start_date: timestamp,
end_date: timestamp + 365 * 24 * 60 * 60 * 1000,
rent: 1500,
security_deposit: 1500,
lease_status: "draft",
...overrides,
};
}
/**
* Generates a unique test message
*/
export function generateTestMessage(
overrides?: Partial<{
subject: string;
content: string;
message_type: string;
}>,
) {
const timestamp = Date.now();
return {
lease_id: "test-lease-id",
message_type: "general",
subject: `Test Message ${timestamp}`,
content: "This is an automated test message",
...overrides,
};
}
/**
* Generates a unique test maintenance request
*/
export function generateTestMaintenanceRequest(
overrides?: Partial<{
request_type: string;
description: string;
priority: string;
}>,
) {
const timestamp = Date.now();
return {
property_id: "test-property-id",
request_type: "plumbing",
description: `Test maintenance request ${timestamp}`,
priority: "medium",
...overrides,
};
}
/**
* Helper functions for common assertions
*/
/**
* Expects an API response to be successful
*/
export function expectSuccess(response: {
ok: boolean;
status: number;
}): void {
if (!response.ok) {
throw new Error(
`Expected successful response, got status ${response.status}`,
);
}
}
/**
* Expects an API response to have a specific status
*/
export function expectStatus(
response: { status: number },
expectedStatus: number,
): void {
if (response.status !== expectedStatus) {
throw new Error(
`Expected status ${expectedStatus}, got ${response.status}`,
);
}
}
/**
* Expects an element to be visible on the page
*/
export async function expectVisible(
page: Page,
selector: string,
): Promise<void> {
const element = page.locator(selector);
const isVisible = await element.isVisible().catch(() => false);
if (!isVisible) {
throw new Error(`Expected element "${selector}" to be visible`);
}
}
/**
* Expects an element to contain specific text
*/
export async function expectText(
page: Page,
selector: string,
text: string,
): Promise<void> {
const element = page.locator(selector);
const elementText = await element.textContent();
if (!elementText?.includes(text)) {
throw new Error(
`Expected element "${selector}" to contain "${text}", got "${elementText}"`,
);
}
}
/**
* Expects no console errors in the page
*/
export async function expectNoConsoleErrors(page: Page): Promise<void> {
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
errors.push(msg.text());
}
});
// Small delay to collect any errors
await page.waitForTimeout(100);
if (errors.length > 0) {
throw new Error(
`Expected no console errors, but found: ${errors.join(", ")}`,
);
}
}
/**
* Navigation helpers for common routes
*/
export const ROUTES = {
home: "/",
landlordDashboard: "/landlord/dashboard",
tenantDashboard: "/tenant/dashboard",
landlordProperties: "/landlord/properties",
landlordLeases: "/landlord/leases",
landlordMessages: "/landlord/messages",
landlordMaintenance: "/landlord/maintenance",
landlordApplications: "/landlord/applications",
tenantLeases: "/tenant/leases",
tenantMessages: "/tenant/messages",
tenantMaintenance: "/tenant/maintenance",
onboardingLandlord: "/onboarding/landlord",
onboardingTenant: "/onboarding/tenant",
authLogin: "/auth/login",
authLogout: "/auth/logout",
} as const;
/**
* Waits for page to load completely
*/
export async function waitForPageLoad(page: Page): Promise<void> {
await page.waitForLoadState("networkidle");
await page.waitForLoadState("domcontentloaded");
}
/**
* Navigates to a URL with test user context.
*
* @param page - Playwright page object
* @param url - URL to navigate to
* @param userType - Type of test user (landlord, tenant, etc.)
*/
export async function gotoWithE2EUser(
page: Page,
url: string,
userType: E2EUserType,
): Promise<void> {
// deno-lint-ignore react-rules-of-hooks
await useE2EUser(page, userType);
await page.goto(url);
await waitForPageLoad(page);
}
/**
* Navigates to a URL with E2E user context using page.addInitScript
* This is an alternative method that sets headers before page context is created
*
* @param page - Playwright page object
* @param url - URL to navigate to
* @param userType - Type of E2E user (landlord, tenant, etc.)
*/
export async function gotoWithE2EHeaders(
page: Page,
url: string,
userType: E2EUserType,
): Promise<void> {
void userType;
await page.addInitScript(() => {
globalThis.__E2E_SKIP_ENCRYPTION__ = true;
localStorage.setItem("e2e-skip-encryption", "true");
});
await page.goto(url);
await page.waitForTimeout(500);
}
/**
* Takes a screenshot with automatic naming
*/
export async function takeScreenshot(
page: Page,
testName: string,
action?: string,
): Promise<void> {
const suffix = action ? `-${action}` : "";
const filename = `${testName}${suffix}.png`;
await page.screenshot({ path: `screenshots/${filename}`, fullPage: true });
}