From b112c6a2bfd74c5105b9e05406a4c8793e5775b7 Mon Sep 17 00:00:00 2001 From: HarelM Date: Thu, 9 Jul 2026 14:40:06 +0300 Subject: [PATCH] Split dirver and helper --- e2e/maputnik-driver.ts | 446 +++++---------------------------------- e2e/playwright-helper.ts | 384 +++++++++++++++++++++++++++++++++ 2 files changed, 438 insertions(+), 392 deletions(-) create mode 100644 e2e/playwright-helper.ts diff --git a/e2e/maputnik-driver.ts b/e2e/maputnik-driver.ts index c08460c9..9233c3be 100644 --- a/e2e/maputnik-driver.ts +++ b/e2e/maputnik-driver.ts @@ -1,25 +1,19 @@ -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"; +import { expect, type Page } from "@playwright/test"; +import { currentPage } from "./utils/fixtures"; +import { + Assertable, + PlaywrightHelper, + Query, + assertDeepNestedInclude, + readFixture, + retry, + typeSequence, +} from "./playwright-helper"; import { ModalDriver } from "./modal-driver"; const baseUrl = "http://localhost:8888/"; -const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures"); -const DATA_ATTRIBUTE = "data-wd-key"; - const isMac = process.platform === "darwin"; -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); -} - /** Reads the maputnik style currently persisted in localStorage. */ function styleFromLocalStorage(page: Page): Promise { return page.evaluate(() => { @@ -31,117 +25,7 @@ function styleFromLocalStorage(page: Page): Promise { }); } -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 __maputnikQuery = 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).__maputnikQuery === 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`. */ -function assertDeepNestedInclude(actual: any, expected: Record): void { - for (const key of Object.keys(expected)) { - expect(actual?.[key], `property "${key}"`).toEqual(expected[key]); - } -} - -export class MaputnikAssertable { - constructor(private readonly target: T, private readonly page?: Page) {} - - private locator(): Locator { - if (!isLocator(this.target)) throw new Error("Expected a Locator target for this assertion"); - return this.target; - } - - private 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)); - +export class MaputnikAssertable extends Assertable { /** * Asserts that the object under test (a fixture / response body) contains every * top-level property of the style currently stored in localStorage. @@ -157,103 +41,25 @@ export class MaputnikAssertable { } /** - * Translates a Cypress-style key sequence (e.g. "{meta}z", "{esc}", "0.") into - * Playwright keyboard actions on the currently focused element. + * The maputnik-specific driver. It builds on the generic {@link PlaywrightHelper} + * — spreading its `given`/`when`/`get` primitives and adding domain concepts + * (loading a style, the add-layer modal, the JSON editor, …). */ -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", - }; - - 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 }; -} - export class MaputnikDriver { - private readonly recordedRequests = new Map(); + private readonly helper = new PlaywrightHelper(); private readonly modalDriver = new ModalDriver(this); - /** - * The page for the currently running test. Resolved lazily so a single driver - * instance can be created once per `describe` and reused across its tests. - */ + /** The page for the currently running test (resolved lazily, like Cypress' `cy`). */ private get page(): Page { return currentPage(); } - // ---- Element access ------------------------------------------------------ - - private testId(testId: string): Locator { - return this.page.locator(testIdSelector(testId)); - } - then = (target: T) => new MaputnikAssertable(target, this.page); // ---- given --------------------------------------------------------------- public given = { - fixture: (_name: string, _alias?: string) => { - // Fixtures are read directly from disk in Playwright, no registration needed. - }, - - 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 }); - }); - }, + ...this.helper.given, setupMockBackedResponses: async () => { const styleFixtures = [ @@ -268,16 +74,16 @@ export class MaputnikDriver { "example-style-with-zoom-5-and-center-50-50.json", ]; for (const fixture of styleFixtures) { - await this.given.interceptAndMockResponse({ + await this.helper.given.interceptAndMockResponse({ method: "GET", url: baseUrl + fixture, response: { fixture }, alias: fixture === "example-style.json" ? "example-style.json" : undefined, }); } - await this.given.interceptAndMockResponse({ method: "GET", url: /example\.local\//, response: [] }); - await this.given.interceptAndMockResponse({ method: "GET", url: /example\.com\//, response: [] }); - await this.given.interceptAndMockResponse({ + await this.helper.given.interceptAndMockResponse({ method: "GET", url: /example\.local\//, response: [] }); + await this.helper.given.interceptAndMockResponse({ method: "GET", url: /example\.com\//, response: [] }); + await this.helper.given.interceptAndMockResponse({ method: "GET", url: "https://www.glyph-server.com/*", response: ["Font 1", "Font 2", "Font 3"], @@ -288,97 +94,10 @@ export class MaputnikDriver { // ---- when ---------------------------------------------------------------- public when = { + ...this.helper.when, + modal: this.modalDriver.when, - visit: async (url: string) => { - await recordCoverageChunk(this.page); - const target = url.startsWith("http") ? url : new URL(url, baseUrl).toString(); - await this.page.goto(target); - }, - - 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