import type { Page, Locator } from "@playwright/test" import { test as base, expect as pwExpect } from "@playwright/test" export { expect, type Page, API_BASE, clickSpaLink } from "../e2e/fixtures" import { expect } from "../e2e/fixtures" type VisualFixtures = {} export const test = base.extend({ /** * Override page fixture: BrowserSync domcontentloaded workaround. */ page: async ({ page }, use) => { const origGoto = page.goto.bind(page) const origReload = page.reload.bind(page) page.goto = ((url: string, opts?: any) => origGoto(url, { waitUntil: "domcontentloaded", ...opts })) as typeof page.goto page.reload = ((opts?: any) => origReload({ waitUntil: "domcontentloaded", ...opts })) as typeof page.reload await use(page) }, }) /** * Wait for the SPA to be fully rendered and stable for visual comparison. * Waits for skeleton loaders to disappear and CSS to settle. */ export async function waitForVisualReady(page: Page, opts?: { timeout?: number }): Promise { const timeout = opts?.timeout ?? 15000 await page.waitForLoadState("domcontentloaded") await expect(page.locator("#appContainer")).not.toBeEmpty({ timeout }) try { await page.waitForFunction(() => document.querySelectorAll(".animate-pulse").length === 0, { timeout: 10000 }) } catch { // Skeleton loaders may not exist on every page } await page.waitForTimeout(800) } /** * Navigate to a route with visual readiness wait. */ export async function navigateToRoute(page: Page, routePath: string): Promise { const url = page.url() const match = url.match(/\/([a-z]{2})(\/|$)/) const lang = match?.[1] || "de" const fullPath = routePath === "/" ? `/${lang}` : `/${lang}${routePath}` await page.goto(fullPath, { waitUntil: "domcontentloaded" }) await waitForVisualReady(page) } /** * Hide dynamic content that would cause screenshot flakiness: * - BrowserSync overlay * - All animations and transitions * - Blinking cursor */ export async function hideDynamicContent(page: Page): Promise { await page.addStyleTag({ content: ` #__bs_notify__, #__bs_notify__:before, #__bs_notify__:after { display: none !important; } *, *::before, *::after { animation-duration: 0s !important; animation-delay: 0s !important; transition-duration: 0s !important; transition-delay: 0s !important; } * { caret-color: transparent !important; } `, }) } /** * Returns locators for non-deterministic elements that should be masked * in screenshots (e.g. cart badges, timestamps). * Customize this for your project. */ export function getDynamicMasks(page: Page): Locator[] { return [ // Add project-specific dynamic element selectors here: // page.locator('[data-testid="cart-badge"]'), ] } /** * Prepare the page for a screenshot: hide dynamic content and scroll to top. */ export async function prepareForScreenshot(page: Page): Promise { await hideDynamicContent(page) await page.evaluate(() => window.scrollTo(0, 0)) await page.waitForTimeout(300) } /** * Take a screenshot and compare against baseline. */ export async function expectScreenshot( page: Page, name: string, opts?: { fullPage?: boolean mask?: Locator[] maxDiffPixelRatio?: number } ): Promise { const masks = [...getDynamicMasks(page), ...(opts?.mask ?? [])] await pwExpect(page).toHaveScreenshot(name, { fullPage: opts?.fullPage ?? false, mask: masks, maxDiffPixelRatio: opts?.maxDiffPixelRatio ?? 0.02, }) }