252 lines
7.0 KiB
TypeScript
252 lines
7.0 KiB
TypeScript
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<void> {
|
|
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<string> {
|
|
return new URL(this.page.url()).pathname;
|
|
}
|
|
|
|
/**
|
|
* Wait for URL to match pattern
|
|
*/
|
|
async waitForUrl(urlPattern: string | RegExp, timeout: number = 30000): Promise<void> {
|
|
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<boolean> {
|
|
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<void> {
|
|
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<void> {
|
|
await this.page.getByRole(role as any, options).first().click();
|
|
}
|
|
|
|
/**
|
|
* Click element by test id
|
|
*/
|
|
async clickByTestId(testId: string): Promise<void> {
|
|
await this.page.getByTestId(testId).first().click();
|
|
}
|
|
|
|
/**
|
|
* Fill text input
|
|
*/
|
|
async fillInput(selector: string, value: string): Promise<void> {
|
|
await this.page.locator(selector).first().fill(value);
|
|
}
|
|
|
|
/**
|
|
* Get element text content
|
|
*/
|
|
async getElementText(selector: string): Promise<string> {
|
|
const text = await this.page.locator(selector).first().textContent();
|
|
return text || '';
|
|
}
|
|
|
|
/**
|
|
* Check if element is visible
|
|
*/
|
|
async isElementVisible(selector: string, timeout: number = 5000): Promise<boolean> {
|
|
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<void> {
|
|
await expect(this.page.locator(selector).first()).toContainText(text);
|
|
}
|
|
|
|
/**
|
|
* Dismiss alert via keyboard
|
|
*/
|
|
async dismissAlert(): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<boolean>,
|
|
timeout: number = this.defaultPollTimeout,
|
|
pollInterval: number = this.defaultPollInterval,
|
|
): Promise<void> {
|
|
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<void> {
|
|
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`,
|
|
);
|
|
}
|
|
}
|