import { Page, expect, Locator } from '@playwright/test'; /** * Base Page Object * All page objects inherit from this to ensure consistent locator strategy * and navigation patterns */ export class BasePage { protected page: Page; protected baseUrl: string; protected defaultPollInterval = 500; // Poll every 500ms protected defaultPollTimeout = 30000; // Poll for up to 30s constructor(page: Page, baseUrl: string = process.env.SUT_URL || 'http://localhost') { this.page = page; this.baseUrl = baseUrl; } /** * Navigate to a specific path */ async goto(path: string): Promise { const url = `${this.baseUrl}${path}`; await this.page.goto(url, { waitUntil: 'domcontentloaded' }); // Don't wait for networkidle - gives time for async data loading // Instead wait a bit for React to mount and initial renders await this.page.waitForTimeout(500); } /** * Get current URL path */ async getUrlPath(): Promise { return new URL(this.page.url()).pathname; } /** * Wait for URL to match pattern */ async waitForUrl(urlPattern: string | RegExp, timeout: number = 30000): Promise { await this.page.waitForURL(urlPattern, { timeout }); } /** * Check if page is loaded by verifying load event * Uses 'load' instead of 'networkidle' to avoid timeouts with async data loading */ async isLoaded(): Promise { try { // Wait for 'load' event which fires after initial page and resources are loaded // This is faster than 'networkidle' and more reliable with continuously-loading apps await this.page.waitForLoadState('load', { timeout: 5000 }); return true; } catch { // If load event doesn't fire, assume page is loaded since goto() already waited for domcontentloaded return true; } } /** * Wait for element with auto-waiting */ async waitForElement(selector: string, timeout: number = 10000): Promise { await this.page.locator(selector).first().waitFor({ state: 'visible', timeout }); } /** * Click element by role (preferred method) */ async clickByRole( role: string, options?: { name?: string | RegExp; exact?: boolean }, ): Promise { await this.page.getByRole(role as any, options).first().click(); } /** * Click element by test id */ async clickByTestId(testId: string): Promise { await this.page.getByTestId(testId).first().click(); } /** * Fill text input */ async fillInput(selector: string, value: string): Promise { await this.page.locator(selector).first().fill(value); } /** * Get element text content */ async getElementText(selector: string): Promise { const text = await this.page.locator(selector).first().textContent(); return text || ''; } /** * Check if element is visible */ async isElementVisible(selector: string, timeout: number = 5000): Promise { try { await this.page.locator(selector).first().isVisible(); return true; } catch { return false; } } /** * Wait and verify element text */ async expectElementToHaveText(selector: string, text: string | RegExp): Promise { await expect(this.page.locator(selector).first()).toContainText(text); } /** * Dismiss alert via keyboard */ async dismissAlert(): Promise { try { await this.page.keyboard.press('Escape'); } catch { // Alert may not be present } } /** * Actively poll for element visibility with retries * Useful for elements that appear after async operations */ async pollForElementVisible( locator: Locator | string, timeout: number = this.defaultPollTimeout, pollInterval: number = this.defaultPollInterval, ): Promise { const element = typeof locator === 'string' ? this.page.locator(locator) : locator; const startTime = Date.now(); while (Date.now() - startTime < timeout) { try { const isVisible = await element.isVisible().catch(() => false); if (isVisible) { return; } } catch { // Element doesn't exist yet, continue polling } await this.page.waitForTimeout(pollInterval); } throw new Error( `Element did not become visible within ${timeout}ms. Polling interval: ${pollInterval}ms`, ); } /** * Actively poll for element to contain text * Useful for dynamic content that updates after async operations */ async pollForElementText( locator: Locator | string, expectedText: string | RegExp, timeout: number = this.defaultPollTimeout, pollInterval: number = this.defaultPollInterval, ): Promise { const element = typeof locator === 'string' ? this.page.locator(locator) : locator; const startTime = Date.now(); while (Date.now() - startTime < timeout) { try { const text = await element.textContent().catch(() => ''); if (text) { if (typeof expectedText === 'string') { if (text.includes(expectedText)) { return; } } else { if (expectedText.test(text)) { return; } } } } catch { // Element doesn't exist yet, continue polling } await this.page.waitForTimeout(pollInterval); } throw new Error( `Element text "${expectedText}" not found within ${timeout}ms. Polling interval: ${pollInterval}ms`, ); } /** * Actively poll for any condition * Generic polling helper for custom wait conditions */ async pollUntil( condition: () => Promise, timeout: number = this.defaultPollTimeout, pollInterval: number = this.defaultPollInterval, ): Promise { const startTime = Date.now(); while (Date.now() - startTime < timeout) { try { const result = await condition(); if (result) { return; } } catch { // Condition threw error, continue polling } await this.page.waitForTimeout(pollInterval); } throw new Error(`Condition not met within ${timeout}ms. Polling interval: ${pollInterval}ms`); } /** * Wait for elements count to match expected count * Useful for waiting for lists to populate */ async pollForElementCount( locator: Locator | string, expectedCount: number, timeout: number = this.defaultPollTimeout, pollInterval: number = this.defaultPollInterval, ): Promise { const element = typeof locator === 'string' ? this.page.locator(locator) : locator; const startTime = Date.now(); while (Date.now() - startTime < timeout) { try { const count = await element.count(); if (count === expectedCount) { return; } } catch { // Locator error, continue polling } await this.page.waitForTimeout(pollInterval); } throw new Error( `Expected element count ${expectedCount} not reached within ${timeout}ms`, ); } }