import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { expect, type Locator, type Page, type Request } from "@playwright/test"; import { currentPage, recordCoverageChunk } from "./utils/fixtures"; const DATA_ATTRIBUTE = "data-wd-key"; const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures"); const isMac = process.platform === "darwin"; export function testIdSelector(testId: string): string { return `[${DATA_ATTRIBUTE}="${testId}"]`; } export function readFixture(name: string): any { const contents = fs.readFileSync(path.join(FIXTURES_DIR, name), "utf-8"); return JSON.parse(contents); } /** Retries `assertion` until it stops throwing, mirroring Cypress' retry-ability. */ export async function retry( assertion: () => Promise | void, timeout = 10000, interval = 100 ): Promise { const start = Date.now(); let lastError: unknown; for (;;) { try { await assertion(); return; } catch (error) { lastError = error; if (Date.now() - start > timeout) throw lastError; await new Promise((resolve) => setTimeout(resolve, interval)); } } } /** * A lazily-evaluated value (e.g. the style in localStorage). Assertions on a * Query re-read the value until they pass, mirroring Cypress' retry-ability. */ export class Query { readonly __query = true as const; constructor(private readonly getter: () => Promise) {} get(): Promise { return this.getter(); } then(mapper: (value: T) => U | Promise): Query { return new Query(async () => mapper(await this.getter())); } } function isQuery(target: unknown): target is Query { return typeof target === "object" && target !== null && (target as Query).__query === true; } function isLocator(target: unknown): target is Locator { return ( typeof target === "object" && target !== null && typeof (target as Locator).count === "function" && typeof (target as Locator).boundingBox === "function" ); } /** Asserts that every top-level key in `expected` deep-equals its counterpart in `actual`. */ export function assertDeepNestedInclude(actual: any, expected: Record): void { for (const key of Object.keys(expected)) { expect(actual?.[key], `property "${key}"`).toEqual(expected[key]); } } /** * Fluent, auto-retrying assertions over a Playwright Locator or a lazily * evaluated value/Query. This is the generic base that the maputnik-specific * assertable extends. */ export class Assertable { constructor(protected readonly target: T, protected readonly page?: Page) {} private locator(): Locator { if (!isLocator(this.target)) throw new Error("Expected a Locator target for this assertion"); return this.target; } protected async assertValue(assertion: (value: any) => void): Promise { const target = this.target; if (isQuery(target)) { await retry(async () => assertion(await target.get())); } else { assertion(await (target as any)); } } // Element assertions (auto-retrying via Playwright web-first assertions). shouldBeVisible = () => expect(this.locator().first()).toBeVisible(); // Some testids resolve to many elements that are always rendered but hidden // (e.g. per-field documentation panels); "not visible" means none is visible. shouldNotBeVisible = () => expect(this.locator().filter({ visible: true })).toHaveCount(0); shouldExist = async () => { if (isLocator(this.target)) { await expect(this.locator().first()).toBeAttached(); } else { await this.assertValue((value) => expect(value).toBeTruthy()); } }; shouldNotExist = () => expect(this.locator()).toHaveCount(0); shouldBeFocused = () => expect(this.locator().first()).toBeFocused(); shouldNotBeFocused = () => expect(this.locator().first()).not.toBeFocused(); shouldHaveValue = (value: string) => expect(this.locator().first()).toHaveValue(value); shouldContainText = async (text: string) => { const locator = this.locator(); // Prefer the visible element when a testid resolves to several (only the // open documentation panel is visible; the rest are hidden in the DOM). const target = (await locator.count()) > 1 ? locator.filter({ visible: true }).first() : locator.first(); await expect(target).toContainText(text); }; shouldHaveText = (text: string) => expect(this.locator().first()).toHaveText(text); shouldHaveLength = (length: number) => expect(this.locator()).toHaveCount(length); shouldHaveCss = (property: string, value: string) => expect(this.locator().first()).toHaveCSS(property, value); // Value assertions (auto-retrying for Query targets). shouldEqual = (value: any) => this.assertValue((actual) => expect(actual).toBe(value)); shouldInclude = (value: any) => this.assertValue((actual) => { if (typeof value === "object" && value !== null) { expect(actual).toMatchObject(value); } else { expect(String(actual)).toContain(String(value)); } }); shouldDeepNestedInclude = (value: Record) => this.assertValue((actual) => assertDeepNestedInclude(actual, value)); } /** * Translates a Cypress-style key sequence (e.g. "{meta}z", "{esc}", "0.") into * Playwright keyboard actions on the currently focused element. */ export async function typeSequence(page: Page, text: string): Promise { const tokens = text.match(/\{[^}]+\}|[^{]+/g) ?? []; const modifierMap: Record = { meta: "Meta", ctrl: "Control", shift: "Shift", alt: "Alt" }; const namedKeys: Record = { esc: "Escape", enter: "Enter", backspace: "Backspace", del: "Delete", tab: "Tab", home: "Home", }; for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; if (!token.startsWith("{") || !token.endsWith("}")) { await page.keyboard.type(token); continue; } const name = token.slice(1, -1).toLowerCase(); if (name === "selectall") { await page.keyboard.press(isMac ? "Meta+a" : "Control+a"); } else if (namedKeys[name]) { await page.keyboard.press(namedKeys[name]); } else if (modifierMap[name]) { const modifiers = [modifierMap[name]]; let j = i + 1; while (j < tokens.length && /^\{(meta|ctrl|shift|alt)\}$/i.test(tokens[j])) { modifiers.push(modifierMap[tokens[j].slice(1, -1).toLowerCase()]); j++; } const key = tokens[j] ?? ""; await page.keyboard.press([...modifiers, key].join("+")); i = j; } } } async function centerOf(locator: Locator): Promise<{ x: number; y: number }> { const box = await locator.boundingBox(); if (!box) throw new Error("Element has no bounding box"); return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; } /** * A thin, framework-level wrapper over Playwright — the equivalent of the * `CypressHelper` the suite used before, with the gaps it left (real-events * style drag/drop, file uploads, key sequences, …) filled in here since we own * this helper. Domain concepts live in `MaputnikDriver`, which builds on it. */ export class PlaywrightHelper { private readonly recordedRequests = new Map(); /** The page for the currently running test (resolved lazily, like Cypress' `cy`). */ private get page(): Page { return currentPage(); } private testId(testId: string): Locator { return this.page.locator(testIdSelector(testId)); } public given = { intercept: async (pattern: RegExp, alias: string, _method = "GET") => { this.recordedRequests.set(alias, []); await this.page.route(pattern, (route) => { this.recordedRequests.get(alias)!.push(route.request()); route.continue(); }); }, interceptAndMockResponse: async (options: { method?: string; url: string | RegExp; response: unknown | { fixture: string }; alias?: string; }) => { const { url, response, alias } = options; if (alias) this.recordedRequests.set(alias, []); await this.page.route(url, (route) => { if (alias) this.recordedRequests.get(alias)!.push(route.request()); const body = response && typeof response === "object" && "fixture" in (response as any) ? readFixture((response as { fixture: string }).fixture) : response; route.fulfill({ json: body }); }); }, }; public when = { visit: async (url: string) => { // Snapshot coverage before navigating, since a full page load resets it. await recordCoverageChunk(this.page); await this.page.goto(url); }, wait: (ms: number) => this.page.waitForTimeout(ms), tab: () => this.page.keyboard.press("Tab"), typeKeys: (keys: string) => typeSequence(this.page, keys), click: async (testId: string, index = 0) => { // Documentation buttons are wrapped in a