/** * 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 = { 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 { 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 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 { 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 { 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 { 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 { 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: "/tenant/onboarding", authLogin: "/auth/login", authLogout: "/auth/logout", } as const; /** * Waits for page to load completely */ export async function waitForPageLoad(page: Page): Promise { 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 { // 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 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 { const suffix = action ? `-${action}` : ""; const filename = `${testName}${suffix}.png`; await page.screenshot({ path: `screenshots/${filename}`, fullPage: true }); }