/** * 视觉测试通用辅助 * * 提供视口切换、主题切换、页面就绪等待以及动态元素遮罩能力, * 用于消除视觉快照中的误报。 */ import type { Locator, Page } from "@playwright/test" import { VIEWPORTS, type ThemeName, type ViewportSize } from "../visual.config" /** * 设置视口尺寸 * @param page Playwright Page 实例 * @param size 视口标识 desktop | tablet | mobile */ export async function setViewport(page: Page, size: ViewportSize): Promise { const viewport = VIEWPORTS[size] await page.setViewportSize({ width: viewport.width, height: viewport.height }) } /** * 设置主题 * * 项目使用 next-themes(attribute="class"),通过在 上切换 class 实现。 * 这里直接操作 localStorage 与 DOM,避免依赖主题切换 UI。 * * @param page Playwright Page 实例 * @param theme 主题 light | dark */ export async function setTheme(page: Page, theme: ThemeName): Promise { // next-themes 默认将主题持久化在 localStorage 的 "theme" key await page.addInitScript((themeValue) => { try { window.localStorage.setItem("theme", themeValue) } catch { // 忽略 localStorage 不可用的情况 } }, theme) // 若页面已加载,同步切换 DOM class const htmlClass = await page.evaluate(() => document.documentElement.className) const nextClass = theme === "dark" ? `${htmlClass} dark` : htmlClass.replace(/\bdark\b/g, "").trim() await page.evaluate((cls) => { document.documentElement.className = cls }, nextClass) } /** * 等待页面就绪 * * 等待 networkidle、字体加载以及主内容渲染完成, * 确保快照稳定。 */ export async function waitForPageReady(page: Page): Promise { await page.waitForLoadState("networkidle") // 等待字体加载完成,避免文字位移 await page.evaluate(() => document.fonts.ready) // 给 React hydration 一点缓冲 await page.waitForTimeout(300) } /** * 默认需要遮罩的动态元素选择器 * - 时间戳 * - 用户头像/用户名 * - 实时数据 */ const DEFAULT_DYNAMIC_SELECTORS = [ "[data-testid='timestamp']", "[data-testid='current-time']", "[data-testid='user-avatar']", "[data-testid='user-name']", "time", "[data-visual-dynamic]", ] /** * 遮罩动态元素 * * 将指定选择器匹配的元素用纯色块覆盖, * 避免时间戳、用户名等动态内容导致快照误报。 * * @param page Playwright Page 实例 * @param selectors 额外需要遮罩的选择器(会与默认列表合并) */ export async function maskDynamicElements(page: Page, selectors: string[] = []): Promise { const allSelectors = [...new Set([...DEFAULT_DYNAMIC_SELECTORS, ...selectors])] const masks: Locator[] = [] for (const selector of allSelectors) { const locator = page.locator(selector) const count = await locator.count() if (count > 0) { masks.push(locator) } } return masks } /** * 生成 toHaveScreenshot 的 mask 选项 * * 配合 maskDynamicElements 使用,将动态元素从对比中遮罩。 */ export function buildMaskOption(masks: Locator[]) { return masks.length > 0 ? { mask: masks } : {} }