92af40df8e
- 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
484 lines
15 KiB
TypeScript
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();
|
|
}
|
|
}
|