Files
novalon-website/e2e/src/pages/BasePage.ts
T
张翔 92af40df8e fix: resolve test failures and improve test stability
- Fix navigation menu display and click issues
- Fix scroll to top/bottom test failures
- Fix section display tests by removing non-existent contact section
- Add data-testid attributes for better test reliability
- Optimize test expectations for scroll behavior
- Add contact page layout for metadata export
- Update section components with proper ARIA attributes
2026-03-07 10:47:14 +08:00

484 lines
15 KiB
TypeScript

import { Page, Locator } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
export class BasePage {
readonly page: Page;
readonly mobileMenuButton: Locator;
readonly mobileMenu: Locator;
readonly mobileMenuCloseButton: Locator;
constructor(page: Page) {
this.page = page;
this.mobileMenuButton = page.getByRole('button', { name: /打开菜单|menu/i });
this.mobileMenu = page.locator('[role="navigation"][aria-label="移动端导航"], #mobile-menu');
this.mobileMenuCloseButton = page.getByRole('button', { name: /关闭菜单|close/i });
}
async navigate(url: string): Promise<void> {
await this.page.goto(url);
}
async waitForLoadState(state: 'load' | 'domcontentloaded' | 'networkidle' = 'load'): Promise<void> {
await this.page.waitForLoadState(state);
}
async click(locator: Locator | string): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.click();
}
async fill(locator: Locator | string, value: string): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.fill(value);
}
async getText(locator: Locator | string): Promise<string> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
return await element.textContent() || '';
}
async isVisible(locator: Locator | string): Promise<boolean> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
return await element.isVisible();
}
async waitForElement(locator: Locator | string, timeout: number = 5000): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.waitFor({ state: 'visible', timeout });
}
async scrollToElement(locator: Locator | string): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.scrollIntoViewIfNeeded();
}
async takeScreenshot(filename: string): Promise<void> {
const screenshotDir = 'test-results/screenshots';
if (!fs.existsSync(screenshotDir)) {
fs.mkdirSync(screenshotDir, { recursive: true });
}
await this.page.screenshot({ path: path.join(screenshotDir, filename) });
}
async hover(locator: Locator | string): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.hover();
}
async selectOption(locator: Locator | string, value: string): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.selectOption(value);
}
async check(locator: Locator | string): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.check();
}
async uncheck(locator: Locator | string): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.uncheck();
}
async waitForURL(url: string | RegExp, timeout: number = 5000): Promise<void> {
await this.page.waitForURL(url, { timeout });
}
async getCurrentURL(): Promise<string> {
return this.page.url();
}
async getTitle(): Promise<string> {
return await this.page.title();
}
async waitForSelector(locator: Locator | string, options?: { state?: 'attached' | 'detached' | 'visible' | 'hidden', timeout?: number }): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.waitFor(options);
}
async getAttribute(locator: Locator | string, attribute: string): Promise<string | null> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
return await element.getAttribute(attribute);
}
async pressKey(key: string): Promise<void> {
await this.page.keyboard.press(key);
}
async type(locator: Locator | string, text: string, options?: { delay?: number }): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.type(text, options);
}
async waitForNavigation(options?: { url?: string | RegExp, timeout?: number }): Promise<void> {
await this.page.waitForNavigation(options);
}
async reload(): Promise<void> {
await this.page.reload();
}
async goBack(): Promise<void> {
await this.page.goBack();
}
async goForward(): Promise<void> {
await this.page.goForward();
}
async evaluate<T>(pageFunction: () => T): Promise<T> {
return await this.page.evaluate(pageFunction);
}
async waitForTimeout(timeout: number): Promise<void> {
await this.page.waitForTimeout(timeout);
}
async count(locator: Locator | string): Promise<number> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
return await element.count();
}
async allTextContents(locator: Locator | string): Promise<string[]> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
return await element.allTextContents();
}
async isDisabled(locator: Locator | string): Promise<boolean> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
return await element.isDisabled();
}
async isEnabled(locator: Locator | string): Promise<boolean> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
return await element.isEnabled();
}
async isChecked(locator: Locator | string): Promise<boolean> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
return await element.isChecked();
}
async measurePerformance(): Promise<{
loadTime: number;
domContentLoaded: number;
firstPaint: number;
firstContentfulPaint: number;
}> {
const metrics = await this.page.evaluate(() => {
const performance = window.performance;
const timing = performance.timing;
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
return {
loadTime: timing.loadEventEnd - timing.navigationStart,
domContentLoaded: timing.domContentLoadedEventEnd - timing.navigationStart,
firstPaint: navigation ? navigation.loadEventEnd - navigation.fetchStart : 0,
firstContentfulPaint: navigation ? navigation.domContentLoadedEventEnd - navigation.fetchStart : 0,
};
});
return metrics;
}
async getCoreWebVitals(): Promise<{
largestContentfulPaint: number;
firstInputDelay: number;
cumulativeLayoutShift: number;
}> {
const vitals = await this.page.evaluate(() => {
return new Promise((resolve) => {
const result = {
largestContentfulPaint: 0,
firstInputDelay: 0,
cumulativeLayoutShift: 0,
};
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
entries.forEach((entry) => {
if (entry.entryType === 'largest-contentful-paint') {
result.largestContentfulPaint = entry.startTime;
}
if (entry.entryType === 'first-input') {
result.firstInputDelay = (entry as PerformanceEventTiming).processingStart - entry.startTime;
}
if (entry.entryType === 'layout-shift') {
if (!(entry as any).hadRecentInput) {
result.cumulativeLayoutShift += (entry as any).value;
}
}
});
});
observer.observe({ entryTypes: ['largest-contentful-paint', 'first-input', 'layout-shift'] });
setTimeout(() => {
observer.disconnect();
resolve(result);
}, 3000);
});
});
return vitals as any;
}
async getResourceTiming(): Promise<PerformanceResourceTiming[]> {
return await this.page.evaluate(() => {
return performance.getEntriesByType('resource') as PerformanceResourceTiming[];
});
}
async getNetworkTiming(): Promise<{
dns: number;
tcp: number;
ssl: number;
request: number;
response: number;
total: number;
}> {
return await this.page.evaluate(() => {
const timing = performance.timing;
return {
dns: timing.domainLookupEnd - timing.domainLookupStart,
tcp: timing.connectEnd - timing.connectStart,
ssl: timing.connectEnd - timing.secureConnectionStart,
request: timing.responseStart - timing.requestStart,
response: timing.responseEnd - timing.responseStart,
total: timing.loadEventEnd - timing.navigationStart,
};
});
}
async retryOperation<T>(
operation: () => Promise<T>,
maxRetries: number = 3,
delay: number = 1000
): Promise<T> {
let lastError: Error | undefined;
for (let i = 0; i < maxRetries; i++) {
try {
return await operation();
} catch (error) {
lastError = error as Error;
if (i < maxRetries - 1) {
await this.waitForTimeout(delay);
}
}
}
throw lastError;
}
async log(message: string, level: 'info' | 'warn' | 'error' = 'info'): Promise<void> {
const timestamp = new Date().toISOString();
const logMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`;
switch (level) {
case 'warn':
console.warn(logMessage);
break;
case 'error':
console.error(logMessage);
break;
default:
console.log(logMessage);
}
}
async waitForElementWithRetry(
locator: Locator | string,
options?: { timeout?: number; retries?: number }
): Promise<void> {
const timeout = options?.timeout || 5000;
const retries = options?.retries || 3;
await this.retryOperation(
async () => {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.waitFor({ state: 'visible', timeout });
},
retries,
1000
);
}
async clickWithRetry(
locator: Locator | string,
options?: { timeout?: number; retries?: number }
): Promise<void> {
const retries = options?.retries || 3;
await this.retryOperation(
async () => {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.click();
},
retries,
1000
);
}
async fillWithRetry(
locator: Locator | string,
value: string,
options?: { timeout?: number; retries?: number }
): Promise<void> {
const retries = options?.retries || 3;
await this.retryOperation(
async () => {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.fill(value);
},
retries,
1000
);
}
async scrollToTop(): Promise<void> {
await this.page.evaluate(() => {
window.scrollTo(0, 0);
document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;
});
await this.page.waitForTimeout(1000);
}
async scrollToBottom(): Promise<void> {
await this.page.evaluate(() => {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
});
await this.page.waitForTimeout(1000);
}
async scrollToElement(selector: string): Promise<void> {
const element = this.page.locator(selector);
await element.scrollIntoViewIfNeeded({ timeout: 5000 });
await this.page.waitForTimeout(500);
}
async getScrollPosition(): Promise<{ x: number; y: number }> {
return await this.page.evaluate(() => {
return {
x: window.scrollX,
y: window.scrollY,
};
});
}
async isElementInViewport(locator: Locator | string): Promise<boolean> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
return await element.evaluate((el) => {
const rect = el.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
});
}
async getElementBoundingBox(locator: Locator | string): Promise<{
x: number;
y: number;
width: number;
height: number;
} | null> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
return await element.boundingBox();
}
async getElementStyle(locator: Locator | string, property: string): Promise<string> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
return await element.evaluate((el, prop) => {
return window.getComputedStyle(el).getPropertyValue(prop);
}, property);
}
async isElementFocused(locator: Locator | string): Promise<boolean> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
return await element.evaluate((el) => {
return document.activeElement === el;
});
}
async focus(locator: Locator | string): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.focus();
}
async blur(locator: Locator | string): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.blur();
}
async dragAndDrop(source: Locator | string, target: Locator | string): Promise<void> {
const sourceElement = typeof source === 'string' ? this.page.locator(source) : source;
const targetElement = typeof target === 'string' ? this.page.locator(target) : target;
await sourceElement.dragTo(targetElement);
}
async uploadFile(locator: Locator | string, filePath: string): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.setInputFiles(filePath);
}
async clearInput(locator: Locator | string): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.clear();
}
async getInputValue(locator: Locator | string): Promise<string> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
return await element.inputValue();
}
async selectText(locator: Locator | string): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.selectText();
}
async waitForFileDownload(downloadPromise: Promise<any>): Promise<string> {
const download = await downloadPromise;
const path = await download.path();
return path || '';
}
async acceptDialog(): Promise<void> {
this.page.on('dialog', (dialog) => dialog.accept());
}
async dismissDialog(): Promise<void> {
this.page.on('dialog', (dialog) => dialog.dismiss());
}
async getDialogMessage(): Promise<string> {
return new Promise((resolve) => {
this.page.on('dialog', (dialog) => {
resolve(dialog.message());
});
});
}
async openMobileMenu() {
await this.mobileMenuButton.click();
await this.mobileMenu.waitFor({ state: 'visible', timeout: 5000 });
}
async closeMobileMenu() {
if (await this.mobileMenu.isVisible()) {
await this.mobileMenuCloseButton.click();
await this.mobileMenu.waitFor({ state: 'hidden', timeout: 5000 });
}
}
async isMobileMenuOpen() {
return await this.mobileMenu.isVisible();
}
}