411 lines
10 KiB
TypeScript
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 });
|
|
}
|