From c3dd2537376f6b8655ca9bfc2940994acc7cef26 Mon Sep 17 00:00:00 2001 From: HarelM Date: Wed, 8 Jul 2026 12:42:32 +0300 Subject: [PATCH] test: migrate e2e to Playwright and component test to Vitest browser mode Replace Cypress with Playwright for the end-to-end suite and drop @shellygo/cypress-test-utils in favour of a hand-written MaputnikDriver page object that keeps the fluent then(...).shouldX() assertion style (now async). The InputAutocomplete component test moves to Vitest browser mode using the Playwright provider. - e2e/maputnik-driver.ts: driver + MaputnikAssertable over Playwright - e2e/{fixtures,coverage,global-setup,global-teardown}.ts: test fixture, istanbul coverage collection, and nyc report generation - playwright.config.ts / vitest.config.ts - Code coverage preserved: dev server is istanbul-instrumented, per-test window.__coverage__ is merged via nyc into coverage/coverage-final.json - CI: Cypress jobs replaced with Playwright; docker e2e runs against the container via E2E_NO_WEBSERVER - Remove Cypress deps, config and support files; update docs and .nycrc Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 45 +- .gitignore | 6 +- .nycrc.json | 9 +- AGENTS.md | 12 +- CHANGELOG.md | 1 + README.md | 41 +- cypress/e2e/maputnik-cypress-helper.ts | 56 - cypress/support/commands.ts | 37 - cypress/support/component-index.html | 12 - cypress/support/component.ts | 37 - cypress/support/e2e.ts | 22 - e2e/accessibility.spec.ts | 53 +- e2e/code-editor.spec.ts | 23 +- e2e/coverage.ts | 30 + e2e/fixtures.ts | 27 + e2e/global-setup.ts | 12 + e2e/global-teardown.ts | 23 + e2e/history.spec.ts | 124 +- e2e/i18n.spec.ts | 36 +- e2e/keyboard.spec.ts | 79 +- e2e/layer-editor.spec.ts | 304 +- e2e/layers-list.spec.ts | 597 ++-- e2e/map.spec.ts | 84 +- e2e/maputnik-driver.ts | 765 +++-- e2e/modal-driver.ts | 55 +- e2e/modals.spec.ts | 504 ++-- package-lock.json | 2616 ++--------------- package.json | 17 +- playwright.config.ts | 75 +- .../InputAutocomplete.browser.test.tsx | 32 +- tsconfig.json | 13 +- vite.config.ts | 3 +- vitest.config.ts | 37 + 33 files changed, 1908 insertions(+), 3879 deletions(-) delete mode 100644 cypress/e2e/maputnik-cypress-helper.ts delete mode 100644 cypress/support/commands.ts delete mode 100644 cypress/support/component-index.html delete mode 100644 cypress/support/component.ts delete mode 100644 cypress/support/e2e.ts create mode 100644 e2e/coverage.ts create mode 100644 e2e/fixtures.ts create mode 100644 e2e/global-setup.ts create mode 100644 e2e/global-teardown.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3044089b..83244161 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,6 +96,7 @@ jobs: with: node-version-file: '.nvmrc' - run: npm ci + - run: npx playwright install --with-deps chromium - run: npm run test-unit-ci - name: Upload coverage reports to Codecov uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 @@ -117,17 +118,21 @@ jobs: with: node-version-file: '.nvmrc' - run: npm ci - - name: Cypress run - uses: cypress-io/github-action@fa4a118725a8f001170d49631ea89e5d66fee626 # v7.4.1 - with: - build: npm run build - start: npm run start - browser: chrome + - run: npx playwright install --with-deps chromium + - name: Playwright run + run: npm run test-e2e - name: Upload coverage reports to Codecov uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: - files: ${{ github.workspace }}/.nyc_output/out.json + files: ${{ github.workspace }}/coverage/coverage-final.json verbose: true + - name: Upload Playwright report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 e2e-tests-docker: name: "E2E tests using chrome and docker" @@ -141,14 +146,26 @@ jobs: with: node-version-file: '.nvmrc' - run: npm ci - - name: Cypress run - uses: cypress-io/github-action@fa4a118725a8f001170d49631ea89e5d66fee626 # v7.4.1 - with: - build: docker build -t maputnik . - start: docker run --rm --network host maputnik --port=8888 - browser: chrome + - run: npx playwright install --with-deps chromium + - name: Build docker image + run: docker build -t maputnik . + - name: Start maputnik container + run: docker run -d --network host --name maputnik maputnik --port=8888 + - name: Wait for maputnik to be ready + run: | + for i in $(seq 1 60); do + if curl -sSf http://localhost:8888/ > /dev/null; then + echo "maputnik is up"; exit 0 + fi + sleep 1 + done + echo "maputnik did not start in time"; docker logs maputnik; exit 1 + - name: Playwright run + run: npm run test-e2e + env: + E2E_NO_WEBSERVER: "1" - name: Upload coverage reports to Codecov uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: - files: ${{ github.workspace }}/.nyc_output/out.json + files: ${{ github.workspace }}/coverage/coverage-final.json verbose: true diff --git a/.gitignore b/.gitignore index dba2935c..92d15c11 100644 --- a/.gitignore +++ b/.gitignore @@ -33,10 +33,14 @@ node_modules public /errorShots /old -/cypress/screenshots /dist/ /desktop/version.go +# Playwright +/test-results/ +/playwright-report/ +/playwright/.cache/ + # IDE .vscode/ .idea/ diff --git a/.nycrc.json b/.nycrc.json index dfb4729b..b068c670 100644 --- a/.nycrc.json +++ b/.nycrc.json @@ -4,12 +4,13 @@ "check-coverage": false, "include": ["src/**/*.ts", "src/**/*.tsx"], "exclude": [ - "cypress/**/*.*", + "e2e/**/*.*", "**/*.d.ts", - "**/*.cy.tsx", - "**/*.cy.ts", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.browser.test.tsx", "./coverage/**", - "./cypress/**", + "./e2e/**", "./dist/**", "node_modules" ], diff --git a/AGENTS.md b/AGENTS.md index 2f1f556c..501d2edb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,22 +24,22 @@ The project type checked and built with: npm run build ``` -To run the tests make sure that xvfb is installed: +Install the Playwright browser (first time only): ``` -apt install xvfb +npx playwright install --with-deps chromium ``` -Run the development server in the background with Vite: +Then run the end-to-end tests (Playwright starts the dev server automatically): ``` -nohup npm run start & +npm run test ``` -Then start the Cypress tests with: +Run the unit and component tests with Vitest: ``` -xvfb-run -a npm run test +npm run test-unit ``` ## Pull Requests diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a4813ae..5d7a6bff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## main ### โœจ Features and improvements +- Replace Cypress with Playwright for end-to-end tests and Vitest browser mode (Playwright provider) for component tests; drop the `@shellygo/cypress-test-utils` helper in favour of a `MaputnikDriver` page object - _...Add new stuff here..._ ### ๐Ÿž Bug fixes diff --git a/README.md b/README.md index a62383b7..ea69e7e9 100644 --- a/README.md +++ b/README.md @@ -79,26 +79,45 @@ npm run sort-styles ``` ## Tests -For E2E testing we use [Cypress](https://www.cypress.io/) - [Cypress](https://www.cypress.io/) doesn't start a server so you'll need to start one manually by running `npm run start`. +### End-to-end tests -Now open a terminal and run the following using *chrome*: +For E2E testing we use [Playwright](https://playwright.dev/). The tests live in the [`e2e`](/e2e) directory and drive the app through the `MaputnikDriver` page object. + +The first time you run the tests, install the browser: + +``` +npx playwright install chromium +``` + +Playwright automatically starts the dev server (`npm run start`) for you, so you can just run: ``` npm run test ``` -or *firefox*: -``` -npm run test -- --browser firefox -``` -See the following docs for more info: (Launching Browsers)[https://docs.cypress.io/guides/guides/launching-browsers] - -You can also see the tests as they run or select which suites to run by executing: +Some useful options: ``` -npm run cy:open +# see the tests run in a headed browser +npm run test -- --headed + +# run a single spec / filter by title +npm run test -- e2e/map.spec.ts +npm run test -- -g "zoom level" + +# open the interactive UI mode +npx playwright test --ui +``` + +Running the E2E tests also produces a code-coverage report in `coverage/` (collected via istanbul instrumentation of the dev server). + +### Unit & component tests + +Unit tests and component tests run with [Vitest](https://vitest.dev/); component tests (`*.browser.test.tsx`) use Vitest's browser mode with the Playwright provider. + +``` +npm run test-unit ``` ## Release process diff --git a/cypress/e2e/maputnik-cypress-helper.ts b/cypress/e2e/maputnik-cypress-helper.ts deleted file mode 100644 index 90467828..00000000 --- a/cypress/e2e/maputnik-cypress-helper.ts +++ /dev/null @@ -1,56 +0,0 @@ -/// -import { CypressHelper } from "@shellygo/cypress-test-utils"; -import "cypress-real-events/support"; - -export default class MaputnikCypressHelper { - private helper = new CypressHelper({ defaultDataAttribute: "data-wd-key" }); - - public given = { - ...this.helper.given, - }; - - public get = { - locationHash: (): Cypress.Chainable => cy.location("hash"), - ...this.helper.get, - }; - - public when = { - dragAndDropWithWait: (element: string, targetElement: string) => { - this.helper.get.elementByTestId(element).realMouseDown({ button: "left", position: "center" }); - this.helper.get.elementByTestId(element).realMouseMove(0, 10, { position: "center" }); - this.helper.get.elementByTestId(targetElement).realMouseMove(0, 0, { position: "center" }); - this.helper.when.wait(1); - this.helper.get.elementByTestId(targetElement).realMouseUp(); - }, - clickCenter: (element: string) => { - this.helper.get.elementByTestId(element).realMouseDown({ button: "left", position: "center" }); - this.helper.when.wait(200); - this.helper.get.elementByTestId(element).realMouseUp(); - }, - openFileByFixture: (fixture: string, buttonTestId: string, inputTestId: string) => { - cy.window().then((win) => { - const file = { - text: cy.stub().resolves(cy.fixture(fixture).then(JSON.stringify)), - }; - const fileHandle = { - getFile: cy.stub().resolves(file), - }; - if (!win.showOpenFilePicker) { - this.helper.get.elementByTestId(inputTestId).selectFile("cypress/fixtures/" + fixture, { force: true }); - } else { - cy.stub(win, "showOpenFilePicker").resolves([fileHandle]); - this.helper.get.elementByTestId(buttonTestId).click(); - } - }); - }, - dropFileByFixture: (fixture: string, dropzoneTestId: string) => { - this.helper.get.elementByTestId(dropzoneTestId).selectFile("cypress/fixtures/" + fixture, { - action: "drag-drop", - force: true, - }); - }, - ...this.helper.when, - }; - - public beforeAndAfter = this.helper.beforeAndAfter; -} diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts deleted file mode 100644 index 95857aea..00000000 --- a/cypress/support/commands.ts +++ /dev/null @@ -1,37 +0,0 @@ -/// -// *********************************************** -// This example commands.ts shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add('login', (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) -// -// declare global { -// namespace Cypress { -// interface Chainable { -// login(email: string, password: string): Chainable -// drag(subject: string, options?: Partial): Chainable -// dismiss(subject: string, options?: Partial): Chainable -// visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable -// } -// } -// } diff --git a/cypress/support/component-index.html b/cypress/support/component-index.html deleted file mode 100644 index 5f9622ae..00000000 --- a/cypress/support/component-index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - Components App - - -
- - diff --git a/cypress/support/component.ts b/cypress/support/component.ts deleted file mode 100644 index 1b940dc5..00000000 --- a/cypress/support/component.ts +++ /dev/null @@ -1,37 +0,0 @@ -// *********************************************************** -// This example support/component.ts is processed and -// loaded automatically before your test files. -// -// This is a great place to put global configuration and -// behavior that modifies Cypress. -// -// You can change the location of this file or turn off -// automatically serving support files with the -// 'supportFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/configuration -// *********************************************************** - -// Import commands.js using ES2015 syntax: -import "./commands"; - -import { mount } from "cypress/react"; - -// Augment the Cypress namespace to include type definitions for -// your custom command. -// Alternatively, can be defined in cypress/support/component.d.ts -// with a at the top of your spec. -declare global { - /* eslint-disable @typescript-eslint/no-namespace */ - namespace Cypress { - interface Chainable { - mount: typeof mount - } - } -} - -Cypress.Commands.add("mount", mount); - -// Example use: -// cy.mount() diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts deleted file mode 100644 index 1aa9aa2b..00000000 --- a/cypress/support/e2e.ts +++ /dev/null @@ -1,22 +0,0 @@ -// *********************************************************** -// This example support/e2e.ts is processed and -// loaded automatically before your test files. -// -// This is a great place to put global configuration and -// behavior that modifies Cypress. -// -// You can change the location of this file or turn off -// automatically serving support files with the -// 'supportFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/configuration -// *********************************************************** - -// Import commands.js using ES2015 syntax: -import "@cypress/code-coverage/support"; -import "cypress-plugin-tab"; -import "./commands"; - -// Alternatively you can use CommonJS syntax: -// require('./commands') diff --git a/e2e/accessibility.spec.ts b/e2e/accessibility.spec.ts index 8e2e7e61..7604af56 100644 --- a/e2e/accessibility.spec.ts +++ b/e2e/accessibility.spec.ts @@ -1,40 +1,45 @@ -import { MaputnikDriver } from "./maputnik-driver"; +import { test, setupMaputnik } from "./fixtures"; test.describe("accessibility", () => { - const { beforeAndAfter, get, when, then } = new MaputnikDriver(); - beforeAndAfter(); + setupMaputnik(); test.describe("skip links", () => { - beforeEach(() => { - when.setStyle("layer"); + test.beforeEach(async ({ driver }) => { + await driver.when.setStyle("layer"); }); - test("skip link to layer list", () => { + test("skip link to layer list", async ({ driver }) => { + const { get, when, then } = driver; const selector = "root:skip:layer-list"; - then(get.elementByTestId(selector)).shouldExist(); - when.tab(); - then(get.elementByTestId(selector)).shouldBeFocused(); - when.click(selector); - then(get.skipTargetLayerList()).shouldBeFocused(); + await then(get.elementByTestId(selector)).shouldExist(); + await when.tab(); + await then(get.elementByTestId(selector)).shouldBeFocused(); + await when.click(selector); + await then(get.skipTargetLayerList()).shouldBeFocused(); }); - test("skip link to layer editor", () => { + test("skip link to layer editor", async ({ driver }) => { + const { get, when, then } = driver; const selector = "root:skip:layer-editor"; - then(get.elementByTestId(selector)).shouldExist(); - then(get.elementByTestId("skip-target-layer-editor")).shouldExist(); - when.tab().tab(); - then(get.elementByTestId(selector)).shouldBeFocused(); - when.click(selector); - then(get.skipTargetLayerEditor()).shouldBeFocused(); + await then(get.elementByTestId(selector)).shouldExist(); + await then(get.elementByTestId("skip-target-layer-editor")).shouldExist(); + await when.tab(); + await when.tab(); + await then(get.elementByTestId(selector)).shouldBeFocused(); + await when.click(selector); + await then(get.skipTargetLayerEditor()).shouldBeFocused(); }); - test("skip link to map view", () => { + test("skip link to map view", async ({ driver }) => { + const { get, when, then } = driver; const selector = "root:skip:map-view"; - then(get.elementByTestId(selector)).shouldExist(); - when.tab().tab().tab(); - then(get.elementByTestId(selector)).shouldBeFocused(); - when.click(selector); - then(get.canvas()).shouldBeFocused(); + await then(get.elementByTestId(selector)).shouldExist(); + await when.tab(); + await when.tab(); + await when.tab(); + await then(get.elementByTestId(selector)).shouldBeFocused(); + await when.click(selector); + await then(get.canvas()).shouldBeFocused(); }); }); }); diff --git a/e2e/code-editor.spec.ts b/e2e/code-editor.spec.ts index abfb63c1..cf706858 100644 --- a/e2e/code-editor.spec.ts +++ b/e2e/code-editor.spec.ts @@ -1,18 +1,19 @@ -import { MaputnikDriver } from "./maputnik-driver"; +import { test, setupMaputnik } from "./fixtures"; test.describe("code editor", () => { - const { beforeAndAfter, when, get, then } = new MaputnikDriver(); - beforeAndAfter(); + setupMaputnik(); - test("open code editor", () => { - when.click("nav:code-editor"); - then(get.element(".maputnik-code-editor")).shouldExist(); + test("open code editor", async ({ driver }) => { + const { when, get, then } = driver; + await when.click("nav:code-editor"); + await then(get.element(".maputnik-code-editor")).shouldExist(); }); - test("closes code editor", () => { - when.click("nav:code-editor"); - then(get.element(".maputnik-code-editor")).shouldExist(); - when.click("nav:code-editor"); - then(get.element(".maputnik-code-editor")).shouldNotExist(); + test("closes code editor", async ({ driver }) => { + const { when, get, then } = driver; + await when.click("nav:code-editor"); + await then(get.element(".maputnik-code-editor")).shouldExist(); + await when.click("nav:code-editor"); + await then(get.element(".maputnik-code-editor")).shouldNotExist(); }); }); diff --git a/e2e/coverage.ts b/e2e/coverage.ts new file mode 100644 index 00000000..319009ee --- /dev/null +++ b/e2e/coverage.ts @@ -0,0 +1,30 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { Page } from "@playwright/test"; + +const OUTPUT_DIR = path.resolve(process.cwd(), ".nyc_output"); + +/** + * Reads the istanbul coverage object (injected by vite-plugin-istanbul) from the + * given page. Returns `null` when the page has not been instrumented. + */ +export async function readCoverage(page: Page): Promise { + try { + return await page.evaluate(() => (window as unknown as { __coverage__?: unknown }).__coverage__ ?? null); + } catch { + // Page might be navigating/closed. + return null; + } +} + +/** + * Persists a coverage chunk to `.nyc_output` so that `nyc report` can merge it. + * istanbul-lib-coverage (used by nyc) sums the hit counts across every file it + * finds, so writing one file per chunk is enough to accumulate coverage across + * navigations and tests. + */ +export function writeCoverage(coverage: unknown, id: string): void { + if (!coverage) return; + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + fs.writeFileSync(path.join(OUTPUT_DIR, `playwright-${id}.json`), JSON.stringify(coverage)); +} diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 00000000..94aa0ac0 --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,27 @@ +import { test as base, expect } from "@playwright/test"; +import { MaputnikDriver } from "./maputnik-driver"; + +/** + * Playwright test with a per-test `driver` (the maputnik page object) that also + * collects istanbul coverage once the test finishes. + */ +export const test = base.extend<{ driver: MaputnikDriver }>({ + driver: async ({ page }, use, testInfo) => { + const driver = new MaputnikDriver(page); + await use(driver); + await driver.flushCoverage(testInfo); + }, +}); + +export { expect }; + +/** + * Registers the shared `beforeEach` used by most specs: mock the style responses + * and load the default (geojson + raster) style. + */ +export function setupMaputnik(): void { + test.beforeEach(async ({ driver }) => { + await driver.given.setupMockBackedResponses(); + await driver.when.setStyle("both"); + }); +} diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 00000000..26d7e8d0 --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,12 @@ +import fs from "node:fs"; +import path from "node:path"; + +/** + * Clears the istanbul coverage output directory before the e2e run so stale + * coverage from previous runs is not merged into the report. + */ +export default function globalSetup(): void { + const dir = path.resolve(process.cwd(), ".nyc_output"); + fs.rmSync(dir, { recursive: true, force: true }); + fs.mkdirSync(dir, { recursive: true }); +} diff --git a/e2e/global-teardown.ts b/e2e/global-teardown.ts new file mode 100644 index 00000000..8ee7f95d --- /dev/null +++ b/e2e/global-teardown.ts @@ -0,0 +1,23 @@ +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; + +/** + * Merges the per-test istanbul coverage chunks collected in `.nyc_output` into a + * report (configured by `.nycrc.json`) once the whole e2e run has finished. + */ +export default function globalTeardown(): void { + const dir = path.resolve(process.cwd(), ".nyc_output"); + const hasCoverage = fs.existsSync(dir) && fs.readdirSync(dir).some((f) => f.endsWith(".json")); + if (!hasCoverage) { + console.warn("No coverage data collected; skipping coverage report."); + return; + } + try { + execFileSync("npx", ["nyc", "report"], { stdio: "inherit" }); + } catch (error) { + // Don't fail the whole run if the report can't be generated (e.g. when + // running against a container whose source paths differ from the host). + console.warn("Failed to generate coverage report:", error); + } +} diff --git a/e2e/history.spec.ts b/e2e/history.spec.ts index 5cefef01..8610aaea 100644 --- a/e2e/history.spec.ts +++ b/e2e/history.spec.ts @@ -1,124 +1,86 @@ -import { MaputnikDriver } from "./maputnik-driver"; +import { test, setupMaputnik } from "./fixtures"; test.describe("history", () => { - const { beforeAndAfter, when, get, then } = new MaputnikDriver(); - beforeAndAfter(); + setupMaputnik(); - let undoKeyCombo: string; - let redoKeyCombo: string; + const undoKeyCombo = process.platform === "darwin" ? "{meta}z" : "{ctrl}z"; + const redoKeyCombo = process.platform === "darwin" ? "{meta}{shift}z" : "{ctrl}y"; - before(() => { - const isMac = get.isMac(); - undoKeyCombo = isMac ? "{meta}z" : "{ctrl}z"; - redoKeyCombo = isMac ? "{meta}{shift}z" : "{ctrl}y"; - }); + test("undo/redo", async ({ driver }) => { + const { get, when, then } = driver; + await when.setStyle("geojson"); + await when.modal.open(); - test("undo/redo", () => { - when.setStyle("geojson"); - when.modal.open(); - - when.modal.fillLayers({ + await when.modal.fillLayers({ id: "step 1", type: "background", }); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: "step 1", - type: "background", - }, - ], + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id: "step 1", type: "background" }], }); - when.modal.open(); - when.modal.fillLayers({ + await when.modal.open(); + await when.modal.fillLayers({ id: "step 2", type: "background", }); - - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [ - { - id: "step 1", - type: "background", - }, - { - id: "step 2", - type: "background", - }, + { id: "step 1", type: "background" }, + { id: "step 2", type: "background" }, ], }); - when.typeKeys(undoKeyCombo); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: "step 1", - type: "background", - }, - ], + await when.typeKeys(undoKeyCombo); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id: "step 1", type: "background" }], }); - when.typeKeys(undoKeyCombo); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [] }); + await when.typeKeys(undoKeyCombo); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [] }); - when.typeKeys(redoKeyCombo); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: "step 1", - type: "background", - }, - ], + await when.typeKeys(redoKeyCombo); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id: "step 1", type: "background" }], }); - when.typeKeys(redoKeyCombo); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + await when.typeKeys(redoKeyCombo); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [ - { - id: "step 1", - type: "background", - }, - { - id: "step 2", - type: "background", - }, + { id: "step 1", type: "background" }, + { id: "step 2", type: "background" }, ], }); }); - test("should not redo after undo and value change", () => { - when.setStyle("geojson"); - when.modal.open(); - when.modal.fillLayers({ + test("should not redo after undo and value change", async ({ driver }) => { + const { get, when, then } = driver; + await when.setStyle("geojson"); + await when.modal.open(); + await when.modal.fillLayers({ id: "step 1", type: "background", }); - when.modal.open(); - when.modal.fillLayers({ + await when.modal.open(); + await when.modal.fillLayers({ id: "step 2", type: "background", }); - when.typeKeys(undoKeyCombo); - when.typeKeys(undoKeyCombo); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [] }); + await when.typeKeys(undoKeyCombo); + await when.typeKeys(undoKeyCombo); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [] }); - when.modal.open(); - when.modal.fillLayers({ + await when.modal.open(); + await when.modal.fillLayers({ id: "step 3", type: "background", }); - when.typeKeys(redoKeyCombo); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: "step 3", - type: "background", - }, - ], + await when.typeKeys(redoKeyCombo); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id: "step 3", type: "background" }], }); }); }); diff --git a/e2e/i18n.spec.ts b/e2e/i18n.spec.ts index db296ce7..61f150e8 100644 --- a/e2e/i18n.spec.ts +++ b/e2e/i18n.spec.ts @@ -1,35 +1,35 @@ -import { MaputnikDriver } from "./maputnik-driver"; +import { test, setupMaputnik } from "./fixtures"; test.describe("i18n", () => { - const { beforeAndAfter, get, when, then } = new MaputnikDriver(); - beforeAndAfter(); + setupMaputnik(); test.describe("language detector", () => { - test("English", () => { - const url = "?lng=en"; - when.visit(url); - then(get.elementByTestId("maputnik-lang-select")).shouldHaveValue("en"); + test("English", async ({ driver }) => { + const { get, when, then } = driver; + await when.visit("?lng=en"); + await then(get.elementByTestId("maputnik-lang-select")).shouldHaveValue("en"); }); - test("Japanese", () => { - const url = "?lng=ja"; - when.visit(url); - then(get.elementByTestId("maputnik-lang-select")).shouldHaveValue("ja"); + test("Japanese", async ({ driver }) => { + const { get, when, then } = driver; + await when.visit("?lng=ja"); + await then(get.elementByTestId("maputnik-lang-select")).shouldHaveValue("ja"); }); }); test.describe("language switcher", () => { - beforeEach(() => { - when.setStyle("layer"); + test.beforeEach(async ({ driver }) => { + await driver.when.setStyle("layer"); }); - test("the language switcher switches to Japanese", () => { + test("the language switcher switches to Japanese", async ({ driver }) => { + const { get, when, then } = driver; const selector = "maputnik-lang-select"; - then(get.elementByTestId(selector)).shouldExist(); - when.select(selector, "ja"); - then(get.elementByTestId(selector)).shouldHaveValue("ja"); + await then(get.elementByTestId(selector)).shouldExist(); + await when.select(selector, "ja"); + await then(get.elementByTestId(selector)).shouldHaveValue("ja"); - then(get.elementByTestId("nav:settings")).shouldHaveText("ใ‚นใ‚ฟใ‚คใƒซ่จญๅฎš"); + await then(get.elementByTestId("nav:settings")).shouldHaveText("ใ‚นใ‚ฟใ‚คใƒซ่จญๅฎš"); }); }); }); diff --git a/e2e/keyboard.spec.ts b/e2e/keyboard.spec.ts index 7292f30b..6c0928a7 100644 --- a/e2e/keyboard.spec.ts +++ b/e2e/keyboard.spec.ts @@ -1,60 +1,69 @@ -import { MaputnikDriver } from "./maputnik-driver"; +import { test, setupMaputnik } from "./fixtures"; test.describe("keyboard", () => { - const { beforeAndAfter, given, when, get, then } = new MaputnikDriver(); - beforeAndAfter(); + setupMaputnik(); + test.describe("shortcuts", () => { - beforeEach(() => { - given.setupMockBackedResponses(); - when.setStyle(""); + test.beforeEach(async ({ driver }) => { + await driver.given.setupMockBackedResponses(); + await driver.when.setStyle(""); }); - test("ESC should unfocus", () => { + test("ESC should unfocus", async ({ driver }) => { + const { get, when, then } = driver; const targetSelector = "maputnik-select"; - when.focus(targetSelector); - then(get.elementByTestId(targetSelector)).shouldBeFocused(); - when.typeKeys("{esc}"); - then(get.elementByTestId(targetSelector)).shouldNotBeFocused(); + await when.focus(targetSelector); + await then(get.elementByTestId(targetSelector)).shouldBeFocused(); + await when.typeKeys("{esc}"); + await then(get.elementByTestId(targetSelector)).shouldNotBeFocused(); }); - test("'?' should show shortcuts modal", () => { - when.typeKeys("?"); - then(get.elementByTestId("modal:shortcuts")).shouldBeVisible(); + test("'?' should show shortcuts modal", async ({ driver }) => { + const { get, when, then } = driver; + await when.typeKeys("?"); + await then(get.elementByTestId("modal:shortcuts")).shouldBeVisible(); }); - test("'o' should show open modal", () => { - when.typeKeys("o"); - then(get.elementByTestId("modal:open")).shouldBeVisible(); + test("'o' should show open modal", async ({ driver }) => { + const { get, when, then } = driver; + await when.typeKeys("o"); + await then(get.elementByTestId("modal:open")).shouldBeVisible(); }); - test("'e' should show export modal", () => { - when.typeKeys("e"); - then(get.elementByTestId("modal:export")).shouldBeVisible(); + test("'e' should show export modal", async ({ driver }) => { + const { get, when, then } = driver; + await when.typeKeys("e"); + await then(get.elementByTestId("modal:export")).shouldBeVisible(); }); - test("'d' should show sources modal", () => { - when.typeKeys("d"); - then(get.elementByTestId("modal:sources")).shouldBeVisible(); + test("'d' should show sources modal", async ({ driver }) => { + const { get, when, then } = driver; + await when.typeKeys("d"); + await then(get.elementByTestId("modal:sources")).shouldBeVisible(); }); - test("'s' should show settings modal", () => { - when.typeKeys("s"); - then(get.elementByTestId("modal:settings")).shouldBeVisible(); + test("'s' should show settings modal", async ({ driver }) => { + const { get, when, then } = driver; + await when.typeKeys("s"); + await then(get.elementByTestId("modal:settings")).shouldBeVisible(); }); - test("'i' should change map to inspect mode", () => { - when.typeKeys("i"); - then(get.inputValue("maputnik-select")).shouldEqual("inspect"); + test("'i' should change map to inspect mode", async ({ driver }) => { + const { get, when, then } = driver; + await when.typeKeys("i"); + await then(get.inputValue("maputnik-select")).shouldEqual("inspect"); }); - test("'m' should focus map", () => { - when.typeKeys("m"); - then(get.canvas()).shouldBeFocused(); + test("'m' should focus map", async ({ driver }) => { + const { get, when, then } = driver; + await when.typeKeys("m"); + await then(get.canvas()).shouldBeFocused(); }); - test("'!' should show debug modal", () => { - when.typeKeys("!"); - then(get.elementByTestId("modal:debug")).shouldBeVisible(); + test("'!' should show debug modal", async ({ driver }) => { + const { get, when, then } = driver; + await when.typeKeys("!"); + await then(get.elementByTestId("modal:debug")).shouldBeVisible(); }); }); }); diff --git a/e2e/layer-editor.spec.ts b/e2e/layer-editor.spec.ts index c75588de..8834e58e 100644 --- a/e2e/layer-editor.spec.ts +++ b/e2e/layer-editor.spec.ts @@ -1,96 +1,82 @@ -import { MaputnikDriver } from "./maputnik-driver"; import { v1 as uuid } from "uuid"; +import { test, setupMaputnik } from "./fixtures"; +import type { MaputnikDriver } from "./maputnik-driver"; test.describe("layer editor", () => { - const { beforeAndAfter, get, when, then } = new MaputnikDriver(); - beforeAndAfter(); - beforeEach(() => { - when.setStyle("both"); - when.modal.open(); + setupMaputnik(); + test.beforeEach(async ({ driver }) => { + await driver.when.setStyle("both"); + await driver.when.modal.open(); }); - function createBackground() { + async function createBackground(driver: MaputnikDriver) { + const { get, when, then } = driver; const id = uuid(); - when.selectWithin("add-layer.layer-type", "background"); - when.setValue("add-layer.layer-id.input", "background:" + id); + await when.selectWithin("add-layer.layer-type", "background"); + await when.setValue("add-layer.layer-id.input", "background:" + id); - when.click("add-layer"); + await when.click("add-layer"); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: "background:" + id, - type: "background", - }, - ], + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id: "background:" + id, type: "background" }], }); return id; } - test("expand/collapse"); - test("id", () => { - const bgId = createBackground(); + test.skip("expand/collapse", () => {}); - when.click("layer-list-item:background:" + bgId); + test("id", async ({ driver }) => { + const { get, when, then } = driver; + const bgId = await createBackground(driver); + + await when.click("layer-list-item:background:" + bgId); const id = uuid(); - when.setValue("layer-editor.layer-id.input", "foobar:" + id); - when.click("min-zoom"); + await when.setValue("layer-editor.layer-id.input", "foobar:" + id); + await when.click("min-zoom"); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: "foobar:" + id, - type: "background", - }, - ], + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id: "foobar:" + id, type: "background" }], }); }); test.describe("source", () => { - test("should show error when the source is invalid", () => { - when.modal.fillLayers({ + test("should show error when the source is invalid", async ({ driver }) => { + const { get, when, then } = driver; + await when.modal.fillLayers({ type: "circle", layer: "invalid", }); - then(get.element(".maputnik-input-block--error .maputnik-input-block-label")).shouldHaveCss("color", "rgb(207, 74, 74)"); + await then( + get.element(".maputnik-input-block--error .maputnik-input-block-label") + ).shouldHaveCss("color", "rgb(207, 74, 74)"); }); }); test.describe("min-zoom", () => { let bgId: string; - beforeEach(() => { - bgId = createBackground(); - when.click("layer-list-item:background:" + bgId); - when.setValue("min-zoom.input-text", "1"); - when.click("layer-editor.layer-id"); + test.beforeEach(async ({ driver }) => { + const { when } = driver; + bgId = await createBackground(driver); + await when.click("layer-list-item:background:" + bgId); + await when.setValue("min-zoom.input-text", "1"); + await when.click("layer-editor.layer-id"); }); - test("should update min-zoom in local storage", () => { - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: "background:" + bgId, - type: "background", - minzoom: 1, - }, - ], + test("should update min-zoom in local storage", async ({ driver }) => { + await driver.then(driver.get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id: "background:" + bgId, type: "background", minzoom: 1 }], }); }); - test("when clicking next layer should update style on local storage", () => { - when.type("min-zoom.input-text", "{backspace}"); - when.click("max-zoom.input-text"); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: "background:" + bgId, - type: "background", - minzoom: 1, - }, - ], + test("when clicking next layer should update style on local storage", async ({ driver }) => { + const { get, when, then } = driver; + await when.type("min-zoom.input-text", "{backspace}"); + await when.click("max-zoom.input-text"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id: "background:" + bgId, type: "background", minzoom: 1 }], }); }); }); @@ -98,22 +84,17 @@ test.describe("layer editor", () => { test.describe("max-zoom", () => { let bgId: string; - beforeEach(() => { - bgId = createBackground(); - when.click("layer-list-item:background:" + bgId); - when.setValue("max-zoom.input-text", "1"); - when.click("layer-editor.layer-id"); + test.beforeEach(async ({ driver }) => { + const { when } = driver; + bgId = await createBackground(driver); + await when.click("layer-list-item:background:" + bgId); + await when.setValue("max-zoom.input-text", "1"); + await when.click("layer-editor.layer-id"); }); - test("should update style in local storage", () => { - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: "background:" + bgId, - type: "background", - maxzoom: 1, - }, - ], + test("should update style in local storage", async ({ driver }) => { + await driver.then(driver.get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id: "background:" + bgId, type: "background", maxzoom: 1 }], }); }); }); @@ -122,41 +103,36 @@ test.describe("layer editor", () => { let bgId: string; const comment = "42"; - beforeEach(() => { - bgId = createBackground(); - when.click("layer-list-item:background:" + bgId); - when.setValue("layer-comment.input", comment); - when.click("layer-editor.layer-id"); + test.beforeEach(async ({ driver }) => { + const { when } = driver; + bgId = await createBackground(driver); + await when.click("layer-list-item:background:" + bgId); + await when.setValue("layer-comment.input", comment); + await when.click("layer-editor.layer-id"); }); - test("should update style in local storage", () => { - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + test("should update style in local storage", async ({ driver }) => { + await driver.then(driver.get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [ { id: "background:" + bgId, type: "background", - metadata: { - "maputnik:comment": comment, - }, + metadata: { "maputnik:comment": comment }, }, ], }); }); test.describe("when unsetting", () => { - beforeEach(() => { - when.clear("layer-comment.input"); - when.click("min-zoom.input-text"); + test.beforeEach(async ({ driver }) => { + const { when } = driver; + await when.clear("layer-comment.input"); + await when.click("min-zoom.input-text"); }); - test("should update style in local storage", () => { - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: "background:" + bgId, - type: "background", - }, - ], + test("should update style in local storage", async ({ driver }) => { + await driver.then(driver.get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id: "background:" + bgId, type: "background" }], }); }); }); @@ -164,131 +140,127 @@ test.describe("layer editor", () => { test.describe("color", () => { let bgId: string; - beforeEach(() => { - bgId = createBackground(); - when.click("layer-list-item:background:" + bgId); - when.click("spec-field:background-color"); + test.beforeEach(async ({ driver }) => { + const { when } = driver; + bgId = await createBackground(driver); + await when.click("layer-list-item:background:" + bgId); + await when.click("spec-field:background-color"); }); - test("should update style in local storage", () => { - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: "background:" + bgId, - type: "background", - }, - ], + test("should update style in local storage", async ({ driver }) => { + await driver.then(driver.get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id: "background:" + bgId, type: "background" }], }); }); }); test.describe("opacity", () => { let bgId: string; - beforeEach(() => { - bgId = createBackground(); - when.click("layer-list-item:background:" + bgId); - when.type("spec-field-input:background-opacity", "0."); + test.beforeEach(async ({ driver }) => { + const { when } = driver; + bgId = await createBackground(driver); + await when.click("layer-list-item:background:" + bgId); + await when.type("spec-field-input:background-opacity", "0."); }); - test("should keep '.' in the input field", () => { - then(get.elementByTestId("spec-field-input:background-opacity")).shouldHaveValue("0."); + test("should keep '.' in the input field", async ({ driver }) => { + await driver + .then(driver.get.elementByTestId("spec-field-input:background-opacity")) + .shouldHaveValue("0."); }); - test("should revert to a valid value when focus out", () => { - when.click("layer-list-item:background:" + bgId); - then(get.elementByTestId("spec-field-input:background-opacity")).shouldHaveValue("0"); + test("should revert to a valid value when focus out", async ({ driver }) => { + const { get, when, then } = driver; + await when.click("layer-list-item:background:" + bgId); + await then(get.elementByTestId("spec-field-input:background-opacity")).shouldHaveValue("0"); }); }); - - test.describe("filter", () => { - test("expand/collapse"); - test("compound filter"); + test.skip("expand/collapse", () => {}); + test.skip("compound filter", () => {}); }); test.describe("layout", () => { - test("text-font", () => { - when.setStyle("font"); - when.collapseGroupInLayerEditor(); - when.collapseGroupInLayerEditor(1); - when.collapseGroupInLayerEditor(2); - when.doWithin("spec-field:text-font", () => { - get.element(".maputnik-autocomplete input").first().click(); + test("text-font", async ({ driver }) => { + const { get, when, then } = driver; + await when.setStyle("font"); + await when.collapseGroupInLayerEditor(); + await when.collapseGroupInLayerEditor(1); + await when.collapseGroupInLayerEditor(2); + await when.doWithin("spec-field:text-font", async () => { + await get.element(".maputnik-autocomplete input").first().click(); }); - then(get.element(".maputnik-autocomplete-menu-item")).shouldBeVisible(); - then(get.element(".maputnik-autocomplete-menu-item")).shouldHaveLength(3); + await then(get.element(".maputnik-autocomplete-menu-item")).shouldBeVisible(); + await then(get.element(".maputnik-autocomplete-menu-item")).shouldHaveLength(3); }); }); test.describe("paint", () => { - test("expand/collapse"); - test("color"); - test("pattern"); - test("opacity"); + test.skip("expand/collapse", () => {}); + test.skip("color", () => {}); + test.skip("pattern", () => {}); + test.skip("opacity", () => {}); }); test.describe("json-editor", () => { - test("add", () => { - const id = when.modal.fillLayers({ + test("add", async ({ driver }) => { + const { get, when, then } = driver; + const id = await when.modal.fillLayers({ type: "circle", layer: "example", }); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: id, - type: "circle", - source: "example", - }, - ], + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "circle", source: "example" }], }); const sourceText = get.elementByText('"source"'); + await sourceText.click(); + await when.typeKeys('"'); - sourceText.click(); - sourceText.type("\""); - - then(get.element(".cm-lint-marker-error")).shouldExist(); + await then(get.element(".cm-lint-marker-error")).shouldExist(); }); + test.skip("expand/collapse", () => {}); + test.skip("modify", () => {}); - test("expand/collapse"); - test("modify"); + test("parse error", async ({ driver }) => { + const { get, when, then } = driver; + const bgId = await createBackground(driver); - test("parse error", () => { - const bgId = createBackground(); + await when.click("layer-list-item:background:" + bgId); + await when.collapseGroupInLayerEditor(); + await when.collapseGroupInLayerEditor(1); + await then(get.element(".cm-lint-marker-error")).shouldNotExist(); - when.click("layer-list-item:background:" + bgId); - when.collapseGroupInLayerEditor(); - when.collapseGroupInLayerEditor(1); - then(get.element(".cm-lint-marker-error")).shouldNotExist(); - - when.appendTextInJsonEditor( - "\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013 {" + await when.appendTextInJsonEditor( + "๎€“๎€“๎€“๎€“๎€“๎€“๎€“๎€“๎€“๎€“๎€“๎€“ {" ); - then(get.element(".cm-lint-marker-error")).shouldExist(); + await then(get.element(".cm-lint-marker-error")).shouldExist(); }); }); test.describe("sticky header", () => { - test("should keep layer header visible when scrolling properties", () => { - // Setup: Create a layer with many properties (e.g., symbol layer) - when.modal.fillLayers({ + test("should keep layer header visible when scrolling properties", async ({ driver }) => { + const { get, when, then } = driver; + // Setup: Create a layer with many properties (e.g. symbol layer) + await when.modal.fillLayers({ type: "symbol", layer: "example", }); - when.wait(500); + await when.wait(500); const header = get.elementByTestId("layer-editor.header"); - then(header).shouldBeVisible(); + await then(header).shouldBeVisible(); - get.element(".maputnik-scroll-container").scrollTo("bottom", { ensureScrollable: false }); - when.wait(200); + await get + .element(".maputnik-scroll-container") + .evaluate((el) => el.scrollTo(0, el.scrollHeight)); + await when.wait(200); - then(header).shouldBeVisible(); - then(get.elementByTestId("skip-target-layer-editor")).shouldBeVisible(); + await then(header).shouldBeVisible(); + await then(get.elementByTestId("skip-target-layer-editor")).shouldBeVisible(); }); }); }); diff --git a/e2e/layers-list.spec.ts b/e2e/layers-list.spec.ts index 0a1a845d..f4427b50 100644 --- a/e2e/layers-list.spec.ts +++ b/e2e/layers-list.spec.ts @@ -1,411 +1,278 @@ -import { MaputnikDriver } from "./maputnik-driver"; +import { test, setupMaputnik } from "./fixtures"; test.describe("layers list", () => { - const { beforeAndAfter, get, when, then } = new MaputnikDriver(); - beforeAndAfter(); - beforeEach(() => { - when.setStyle("both"); - when.modal.open(); + setupMaputnik(); + test.beforeEach(async ({ driver }) => { + await driver.when.setStyle("both"); + await driver.when.modal.open(); }); test.describe("ops", () => { let id: string; - beforeEach(() => { - id = when.modal.fillLayers({ - type: "background", - }); + test.beforeEach(async ({ driver }) => { + id = await driver.when.modal.fillLayers({ type: "background" }); }); - test("should update layers in local storage", () => { - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: id, - type: "background", - }, - ], + test("should update layers in local storage", async ({ driver }) => { + await driver.then(driver.get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "background" }], }); }); test.describe("when clicking delete", () => { - beforeEach(() => { - when.click("layer-list-item:" + id + ":delete"); + test.beforeEach(async ({ driver }) => { + await driver.when.click("layer-list-item:" + id + ":delete"); }); - test("should empty layers in local storage", () => { - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + test("should empty layers in local storage", async ({ driver }) => { + await driver.then(driver.get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [], }); }); }); test.describe("when clicking duplicate", () => { - beforeEach(() => { - when.click("layer-list-item:" + id + ":copy"); + test.beforeEach(async ({ driver }) => { + await driver.when.click("layer-list-item:" + id + ":copy"); }); - test("should add copy layer in local storage", () => { - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + test("should add copy layer in local storage", async ({ driver }) => { + await driver.then(driver.get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [ - { - id: id + "-copy", - type: "background", - }, - { - id: id, - type: "background", - }, + { id: id + "-copy", type: "background" }, + { id, type: "background" }, ], }); }); }); test.describe("when clicking hide", () => { - beforeEach(() => { - when.click("layer-list-item:" + id + ":toggle-visibility"); + test.beforeEach(async ({ driver }) => { + await driver.when.click("layer-list-item:" + id + ":toggle-visibility"); }); - test("should update visibility to none in local storage", () => { - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: id, - type: "background", - layout: { - visibility: "none", - }, - }, - ], + test("should update visibility to none in local storage", async ({ driver }) => { + await driver.then(driver.get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "background", layout: { visibility: "none" } }], }); }); test.describe("when clicking show", () => { - beforeEach(() => { - when.click("layer-list-item:" + id + ":toggle-visibility"); + test.beforeEach(async ({ driver }) => { + await driver.when.click("layer-list-item:" + id + ":toggle-visibility"); }); - test("should update visibility to visible in local storage", () => { - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: id, - type: "background", - layout: { - visibility: "visible", - }, - }, - ], + test("should update visibility to visible in local storage", async ({ driver }) => { + await driver.then(driver.get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "background", layout: { visibility: "visible" } }], }); }); }); test.describe("when selecting a layer", () => { let secondId: string; - beforeEach(() => { - when.modal.open(); - secondId = when.modal.fillLayers({ + test.beforeEach(async ({ driver }) => { + await driver.when.modal.open(); + secondId = await driver.when.modal.fillLayers({ id: "second-layer", type: "background", }); }); - test("should show the selected layer in the editor", () => { - when.realClick("layer-list-item:" + secondId); - then(get.elementByTestId("layer-editor.layer-id.input")).shouldHaveValue(secondId); - when.realClick("layer-list-item:" + id); - then(get.elementByTestId("layer-editor.layer-id.input")).shouldHaveValue(id); + test("should show the selected layer in the editor", async ({ driver }) => { + const { get, when, then } = driver; + await when.realClick("layer-list-item:" + secondId); + await then(get.elementByTestId("layer-editor.layer-id.input")).shouldHaveValue(secondId); + await when.realClick("layer-list-item:" + id); + await then(get.elementByTestId("layer-editor.layer-id.input")).shouldHaveValue(id); }); }); }); }); test.describe("background", () => { - test("add", () => { - const id = when.modal.fillLayers({ - type: "background", - }); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: id, - type: "background", - }, - ], + test("add", async ({ driver }) => { + const { get, when, then } = driver; + const id = await when.modal.fillLayers({ type: "background" }); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "background" }], }); }); - test.describe("modify", () => {}); + test.skip("modify", () => {}); }); test.describe("fill", () => { - test("add", () => { - const id = when.modal.fillLayers({ - type: "fill", - layer: "example", - }); - - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: id, - type: "fill", - source: "example", - }, - ], + test("add", async ({ driver }) => { + const { get, when, then } = driver; + const id = await when.modal.fillLayers({ type: "fill", layer: "example" }); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "fill", source: "example" }], }); }); // TODO: Change source - test("change source"); + test.skip("change source", () => {}); }); test.describe("line", () => { - test("add", () => { - const id = when.modal.fillLayers({ - type: "line", - layer: "example", - }); - - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: id, - type: "line", - source: "example", - }, - ], + test("add", async ({ driver }) => { + const { get, when, then } = driver; + const id = await when.modal.fillLayers({ type: "line", layer: "example" }); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "line", source: "example" }], }); }); - test("groups", () => { - when.modal.open(); - const id1 = when.modal.fillLayers({ - id: "aa", - type: "line", - layer: "example", - }); + test("groups", async ({ driver }) => { + const { get, when, then } = driver; + await when.modal.open(); + const id1 = await when.modal.fillLayers({ id: "aa", type: "line", layer: "example" }); - when.modal.open(); - const id2 = when.modal.fillLayers({ - id: "aa-2", - type: "line", - layer: "example", - }); + await when.modal.open(); + const id2 = await when.modal.fillLayers({ id: "aa-2", type: "line", layer: "example" }); - when.modal.open(); - const id3 = when.modal.fillLayers({ - id: "b", - type: "line", - layer: "example", - }); + await when.modal.open(); + const id3 = await when.modal.fillLayers({ id: "b", type: "line", layer: "example" }); - then(get.elementByTestId("layer-list-item:" + id1)).shouldBeVisible(); - then(get.elementByTestId("layer-list-item:" + id2)).shouldNotBeVisible(); - then(get.elementByTestId("layer-list-item:" + id3)).shouldBeVisible(); - when.click("layer-list-group:aa-0"); - then(get.elementByTestId("layer-list-item:" + id1)).shouldBeVisible(); - then(get.elementByTestId("layer-list-item:" + id2)).shouldBeVisible(); - then(get.elementByTestId("layer-list-item:" + id3)).shouldBeVisible(); - when.click("layer-list-item:" + id2); - when.click("skip-target-layer-editor"); - when.click("menu-move-layer-down"); - then(get.elementByTestId("layer-list-group:aa-0")).shouldNotExist(); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + await then(get.elementByTestId("layer-list-item:" + id1)).shouldBeVisible(); + await then(get.elementByTestId("layer-list-item:" + id2)).shouldNotBeVisible(); + await then(get.elementByTestId("layer-list-item:" + id3)).shouldBeVisible(); + await when.click("layer-list-group:aa-0"); + await then(get.elementByTestId("layer-list-item:" + id1)).shouldBeVisible(); + await then(get.elementByTestId("layer-list-item:" + id2)).shouldBeVisible(); + await then(get.elementByTestId("layer-list-item:" + id3)).shouldBeVisible(); + await when.click("layer-list-item:" + id2); + await when.click("skip-target-layer-editor"); + await when.click("menu-move-layer-down"); + await then(get.elementByTestId("layer-list-group:aa-0")).shouldNotExist(); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [ - { - id: "aa", - type: "line", - source: "example", - }, - { - id: "b", - type: "line", - source: "example", - }, - { - id: "aa-2", - type: "line", - source: "example", - }, + { id: "aa", type: "line", source: "example" }, + { id: "b", type: "line", source: "example" }, + { id: "aa-2", type: "line", source: "example" }, ], }); }); }); test.describe("symbol", () => { - test("add", () => { - const id = when.modal.fillLayers({ - type: "symbol", - layer: "example", - }); - - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: id, - type: "symbol", - source: "example", - }, - ], + test("add", async ({ driver }) => { + const { get, when, then } = driver; + const id = await when.modal.fillLayers({ type: "symbol", layer: "example" }); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "symbol", source: "example" }], }); }); - test("should show spec info when hovering and clicking single line property", () => { - when.modal.fillLayers({ - type: "symbol", - layer: "example", - }); + test("should show spec info when hovering and clicking single line property", async ({ driver }) => { + const { get, when, then } = driver; + await when.modal.fillLayers({ type: "symbol", layer: "example" }); - when.hover("spec-field-container:text-rotate"); - then(get.elementByTestId("field-doc-button-Rotate")).shouldBeVisible(); - when.click("field-doc-button-Rotate", 0); - then(get.elementByTestId("spec-field-doc")).shouldContainText("Rotates the "); + await when.hover("spec-field-container:text-rotate"); + await then(get.elementByTestId("field-doc-button-Rotate")).shouldBeVisible(); + await when.click("field-doc-button-Rotate", 0); + await then(get.elementByTestId("spec-field-doc")).shouldContainText("Rotates the "); }); - test("should show spec info when hovering and clicking multi line property", () => { - when.modal.fillLayers({ - type: "symbol", - layer: "example", - }); + test("should show spec info when hovering and clicking multi line property", async ({ driver }) => { + const { get, when, then } = driver; + await when.modal.fillLayers({ type: "symbol", layer: "example" }); - when.hover("spec-field-container:text-offset"); - then(get.elementByTestId("field-doc-button-Offset")).shouldBeVisible(); - when.click("field-doc-button-Offset", 0); - then(get.elementByTestId("spec-field-doc")).shouldContainText("Offset distance"); + await when.hover("spec-field-container:text-offset"); + await then(get.elementByTestId("field-doc-button-Offset")).shouldBeVisible(); + await when.click("field-doc-button-Offset", 0); + await then(get.elementByTestId("spec-field-doc")).shouldContainText("Offset distance"); }); - test("should hide spec info when clicking a second time", () => { - when.modal.fillLayers({ - type: "symbol", - layer: "example", - }); + test("should hide spec info when clicking a second time", async ({ driver }) => { + const { get, when, then } = driver; + await when.modal.fillLayers({ type: "symbol", layer: "example" }); - when.hover("spec-field-container:text-rotate"); - then(get.elementByTestId("field-doc-button-Rotate")).shouldBeVisible(); - when.click("field-doc-button-Rotate", 0); - when.wait(200); - when.click("field-doc-button-Rotate", 0); - then(get.elementByTestId("spec-field-doc")).shouldNotBeVisible(); + await when.hover("spec-field-container:text-rotate"); + await then(get.elementByTestId("field-doc-button-Rotate")).shouldBeVisible(); + await when.click("field-doc-button-Rotate", 0); + await when.wait(200); + await when.click("field-doc-button-Rotate", 0); + await then(get.elementByTestId("spec-field-doc")).shouldNotBeVisible(); }); }); test.describe("raster", () => { - test("add", () => { - const id = when.modal.fillLayers({ - type: "raster", - layer: "raster", - }); - - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: id, - type: "raster", - source: "raster", - }, - ], + test("add", async ({ driver }) => { + const { get, when, then } = driver; + const id = await when.modal.fillLayers({ type: "raster", layer: "raster" }); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "raster", source: "raster" }], }); }); }); test.describe("circle", () => { - test("add", () => { - const id = when.modal.fillLayers({ - type: "circle", - layer: "example", - }); - - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: id, - type: "circle", - source: "example", - }, - ], + test("add", async ({ driver }) => { + const { get, when, then } = driver; + const id = await when.modal.fillLayers({ type: "circle", layer: "example" }); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "circle", source: "example" }], }); }); }); test.describe("fill extrusion", () => { - test("add", () => { - const id = when.modal.fillLayers({ - type: "fill-extrusion", - layer: "example", - }); - - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: id, - type: "fill-extrusion", - source: "example", - }, - ], + test("add", async ({ driver }) => { + const { get, when, then } = driver; + const id = await when.modal.fillLayers({ type: "fill-extrusion", layer: "example" }); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "fill-extrusion", source: "example" }], }); }); }); test.describe("hillshade", () => { - test("add", () => { - const id = when.modal.fillLayers({ - type: "hillshade", - layer: "example", + test("add", async ({ driver }) => { + const { get, when, then } = driver; + const id = await when.modal.fillLayers({ type: "hillshade", layer: "example" }); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "hillshade", source: "example" }], }); + }); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + test("set hillshade illumination direction array", async ({ driver }) => { + const { get, when, then } = driver; + const id = await when.modal.fillLayers({ type: "hillshade", layer: "example" }); + await when.collapseGroupInLayerEditor(); + await when.collapseGroupInLayerEditor(1); + await when.setValueToPropertyArray("spec-field:hillshade-illumination-direction", "1"); + await when.addValueToPropertyArray("spec-field:hillshade-illumination-direction", "2"); + await when.addValueToPropertyArray("spec-field:hillshade-illumination-direction", "3"); + await when.addValueToPropertyArray("spec-field:hillshade-illumination-direction", "4"); + + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [ { - id: id, + id, type: "hillshade", source: "example", + paint: { "hillshade-illumination-direction": [1, 2, 3, 4] }, }, ], }); }); - test("set hillshade illumination direction array", () => { - const id = when.modal.fillLayers({ - type: "hillshade", - layer: "example", - }); - when.collapseGroupInLayerEditor(); - when.collapseGroupInLayerEditor(1); - when.setValueToPropertyArray("spec-field:hillshade-illumination-direction", "1"); - when.addValueToPropertyArray("spec-field:hillshade-illumination-direction", "2"); - when.addValueToPropertyArray("spec-field:hillshade-illumination-direction", "3"); - when.addValueToPropertyArray("spec-field:hillshade-illumination-direction", "4"); + test("set hillshade highlight color array", async ({ driver }) => { + const { get, when, then } = driver; + const id = await when.modal.fillLayers({ type: "hillshade", layer: "example" }); + await when.collapseGroupInLayerEditor(); + await when.setValueToPropertyArray("spec-field:hillshade-highlight-color", "blue"); + await when.addValueToPropertyArray("spec-field:hillshade-highlight-color", "#00ff00"); + await when.addValueToPropertyArray("spec-field:hillshade-highlight-color", "rgba(255, 255, 0, 1)"); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [ { - id: id, + id, type: "hillshade", source: "example", paint: { - "hillshade-illumination-direction": [ 1, 2, 3, 4 ] - } - }, - ], - }); - }); - - test("set hillshade highlight color array", () => { - const id = when.modal.fillLayers({ - type: "hillshade", - layer: "example", - }); - when.collapseGroupInLayerEditor(); - when.setValueToPropertyArray("spec-field:hillshade-highlight-color", "blue"); - when.addValueToPropertyArray("spec-field:hillshade-highlight-color", "#00ff00"); - when.addValueToPropertyArray("spec-field:hillshade-highlight-color", "rgba(255, 255, 0, 1)"); - - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: id, - type: "hillshade", - source: "example", - paint: { - "hillshade-highlight-color": [ "blue", "#00ff00", "rgba(255, 255, 0, 1)" ] - } + "hillshade-highlight-color": ["blue", "#00ff00", "rgba(255, 255, 0, 1)"], + }, }, ], }); @@ -413,129 +280,89 @@ test.describe("layers list", () => { }); test.describe("color-relief", () => { - test("add", () => { - const id = when.modal.fillLayers({ - type: "color-relief", - layer: "example", - }); - - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: id, - type: "color-relief", - source: "example", - }, - ], + test("add", async ({ driver }) => { + const { get, when, then } = driver; + const id = await when.modal.fillLayers({ type: "color-relief", layer: "example" }); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "color-relief", source: "example" }], }); }); - test("adds elevation expression when clicking the elevation button", () => { - when.modal.fillLayers({ - type: "color-relief", - layer: "example", - }); - when.collapseGroupInLayerEditor(); - when.click("make-elevation-function"); - then(get.element("[data-wd-key='spec-field-container:color-relief-color'] .cm-line")).shouldBeVisible(); - }); - }); - - test.describe("groups", () => { - test("simple", () => { - when.setStyle("geojson"); - - when.modal.open(); - when.modal.fillLayers({ - id: "foo", - type: "background", - }); - - when.modal.open(); - when.modal.fillLayers({ - id: "foo_bar", - type: "background", - }); - - when.modal.open(); - when.modal.fillLayers({ - id: "foo_bar_baz", - type: "background", - }); - - then(get.elementByTestId("layer-list-item:foo")).shouldBeVisible(); - then(get.elementByTestId("layer-list-item:foo_bar")).shouldNotBeVisible(); - then( - get.elementByTestId("layer-list-item:foo_bar_baz") - ).shouldNotBeVisible(); - when.click("layer-list-group:foo-0"); - then(get.elementByTestId("layer-list-item:foo")).shouldBeVisible(); - then(get.elementByTestId("layer-list-item:foo_bar")).shouldBeVisible(); - then( - get.elementByTestId("layer-list-item:foo_bar_baz") + test("adds elevation expression when clicking the elevation button", async ({ driver }) => { + const { get, when, then } = driver; + await when.modal.fillLayers({ type: "color-relief", layer: "example" }); + await when.collapseGroupInLayerEditor(); + await when.click("make-elevation-function"); + await then( + get.element("[data-wd-key='spec-field-container:color-relief-color'] .cm-line") ).shouldBeVisible(); }); }); + test.describe("groups", () => { + test("simple", async ({ driver }) => { + const { get, when, then } = driver; + await when.setStyle("geojson"); + + await when.modal.open(); + await when.modal.fillLayers({ id: "foo", type: "background" }); + + await when.modal.open(); + await when.modal.fillLayers({ id: "foo_bar", type: "background" }); + + await when.modal.open(); + await when.modal.fillLayers({ id: "foo_bar_baz", type: "background" }); + + await then(get.elementByTestId("layer-list-item:foo")).shouldBeVisible(); + await then(get.elementByTestId("layer-list-item:foo_bar")).shouldNotBeVisible(); + await then(get.elementByTestId("layer-list-item:foo_bar_baz")).shouldNotBeVisible(); + await when.click("layer-list-group:foo-0"); + await then(get.elementByTestId("layer-list-item:foo")).shouldBeVisible(); + await then(get.elementByTestId("layer-list-item:foo_bar")).shouldBeVisible(); + await then(get.elementByTestId("layer-list-item:foo_bar_baz")).shouldBeVisible(); + }); + }); + test.describe("drag and drop", () => { - test("move layer should update local storage", () => { - when.modal.open(); - const firstId = when.modal.fillLayers({ - id: "a", - type: "background", - }); - when.modal.open(); - const secondId = when.modal.fillLayers({ - id: "b", - type: "background", - }); - when.modal.open(); - const thirdId = when.modal.fillLayers({ - id: "c", - type: "background", - }); + test("move layer should update local storage", async ({ driver }) => { + const { get, when, then } = driver; + await when.modal.open(); + const firstId = await when.modal.fillLayers({ id: "a", type: "background" }); + await when.modal.open(); + const secondId = await when.modal.fillLayers({ id: "b", type: "background" }); + await when.modal.open(); + const thirdId = await when.modal.fillLayers({ id: "c", type: "background" }); - when.dragAndDropWithWait("layer-list-item:" + firstId, "layer-list-item:" + thirdId); + await when.dragAndDropWithWait("layer-list-item:" + firstId, "layer-list-item:" + thirdId); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [ - { - id: secondId, - type: "background", - }, - { - id: thirdId, - type: "background", - }, - { - id: firstId, - type: "background", - }, + { id: secondId, type: "background" }, + { id: thirdId, type: "background" }, + { id: firstId, type: "background" }, ], }); }); }); test.describe("sticky header", () => { - test("should keep header visible when scrolling layer list", () => { + test("should keep header visible when scrolling layer list", async ({ driver }) => { + const { get, when, then } = driver; // Setup: Create multiple layers to enable scrolling for (let i = 0; i < 20; i++) { - when.modal.open(); - when.modal.fillLayers({ - id: `layer-${i}`, - type: "background", - }); + await when.modal.open(); + await when.modal.fillLayers({ id: `layer-${i}`, type: "background" }); } - when.wait(500); + await when.wait(500); const header = get.elementByTestId("layer-list.header"); - then(header).shouldBeVisible(); + await then(header).shouldBeVisible(); - // Scroll the layer list container (use ensureScrollable: false to avoid flakiness) - get.elementByTestId("layer-list").scrollTo("bottom", { ensureScrollable: false }); - when.wait(200); - then(header).shouldBeVisible(); - then(get.elementByTestId("layer-list:add-layer")).shouldBeVisible(); + // Scroll the layer list container + await get.elementByTestId("layer-list").evaluate((el) => el.scrollTo(0, el.scrollHeight)); + await when.wait(200); + await then(header).shouldBeVisible(); + await then(get.elementByTestId("layer-list:add-layer")).shouldBeVisible(); }); }); }); diff --git a/e2e/map.spec.ts b/e2e/map.spec.ts index 4ed7acf7..415c73cd 100644 --- a/e2e/map.spec.ts +++ b/e2e/map.spec.ts @@ -1,67 +1,67 @@ -import { MaputnikDriver } from "./maputnik-driver"; +import { test, setupMaputnik } from "./fixtures"; test.describe("map", () => { - const { beforeAndAfter, get, when, then } = new MaputnikDriver(); - beforeAndAfter(); + setupMaputnik(); + test.describe("zoom level", () => { - test("via url", () => { + test("via url", async ({ driver }) => { + const { get, when, then } = driver; const zoomLevel = 12.37; - when.setStyle("geojson", zoomLevel); - then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible(); - then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText( - "Zoom: " + zoomLevel - ); + await when.setStyle("geojson", zoomLevel); + await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible(); + await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText("Zoom: " + zoomLevel); }); - test("via map controls", () => { + test("via map controls", async ({ driver }) => { + const { get, when, then } = driver; const zoomLevel = 12.37; - when.setStyle("geojson", zoomLevel); - then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible(); - when.clickZoomIn(); - then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText( - "Zoom: " + (zoomLevel + 1) - ); + await when.setStyle("geojson", zoomLevel); + await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible(); + await when.clickZoomIn(); + await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText("Zoom: " + (zoomLevel + 1)); }); - test("via style file definition", () => { - when.setStyle("zoom_7_center_0_51"); - then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible(); - then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText( - "Zoom: " + (7) - ); - then(get.locationHash().should("contain", "#7/51/0")); + test("via style file definition", async ({ driver }) => { + const { get, when, then } = driver; + await when.setStyle("zoom_7_center_0_51"); + await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible(); + await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText("Zoom: " + 7); + await then(get.locationHash()).shouldInclude("#7/51/0"); // opening another stylefile does not update the map view again // as discussed in https://github.com/maplibre/maputnik/issues/1546 - when.openASecondStyleWithDifferentZoomAndCenter(); - then(get.locationHash().should("contain", "#7/51/0")); - + await when.openASecondStyleWithDifferentZoomAndCenter(); + await then(get.locationHash()).shouldInclude("#7/51/0"); }); }); test.describe("search", () => { - test("should exist", () => { - then(get.searchControl()).shouldBeVisible(); + test("should exist", async ({ driver }) => { + const { get, then } = driver; + await then(get.searchControl()).shouldBeVisible(); }); }); test.describe("popup", () => { - beforeEach(() => { - when.setStyle("rectangles"); - then(get.locationHash().should("exist")); - }); - test("should open on feature click", () => { - when.clickCenter("maplibre:map"); - then(get.elementByTestId("feature-layer-popup")).shouldBeVisible(); + test.beforeEach(async ({ driver }) => { + await driver.when.setStyle("rectangles"); + await driver.then(driver.get.locationHash()).shouldExist(); }); - test("should open a second feature after closing popup", () => { - when.clickCenter("maplibre:map"); - then(get.elementByTestId("feature-layer-popup")).shouldBeVisible(); - when.closePopup(); - then(get.elementByTestId("feature-layer-popup")).shouldNotExist(); - when.clickCenter("maplibre:map"); - then(get.elementByTestId("feature-layer-popup")).shouldBeVisible(); + test("should open on feature click", async ({ driver }) => { + const { get, when, then } = driver; + await when.clickCenter("maplibre:map"); + await then(get.elementByTestId("feature-layer-popup")).shouldBeVisible(); + }); + + test("should open a second feature after closing popup", async ({ driver }) => { + const { get, when, then } = driver; + await when.clickCenter("maplibre:map"); + await then(get.elementByTestId("feature-layer-popup")).shouldBeVisible(); + await when.closePopup(); + await then(get.elementByTestId("feature-layer-popup")).shouldNotExist(); + await when.clickCenter("maplibre:map"); + await then(get.elementByTestId("feature-layer-popup")).shouldBeVisible(); }); }); }); diff --git a/e2e/maputnik-driver.ts b/e2e/maputnik-driver.ts index 692b038d..02cf9376 100644 --- a/e2e/maputnik-driver.ts +++ b/e2e/maputnik-driver.ts @@ -1,122 +1,307 @@ -/// +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, type Locator, type Page, type Request, type TestInfo } from "@playwright/test"; +import { readCoverage, writeCoverage } from "./coverage"; +import { ModalDriver } from "./modal-driver"; -import { CypressHelper } from "@shellygo/cypress-test-utils"; -import { Assertable, then } from "@shellygo/cypress-test-utils/assertable"; -import MaputnikCypressHelper from "./maputnik-cypress-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 styleFromWindow = (win: Window) => { - const styleId = win.localStorage.getItem("maputnik:latest_style"); - const styleItemKey = `maputnik:style:${styleId}`; - const styleItem = win.localStorage.getItem(styleItemKey); - if (!styleItem) throw new Error("Could not get styleItem from localStorage"); - const obj = JSON.parse(styleItem); - return obj; -}; +const isMac = process.platform === "darwin"; -export class MaputnikAssertable extends Assertable { - shouldEqualToStoredStyle = () => - then( - new CypressHelper().get.window().then((win: Window) => { - const style = styleFromWindow(win); - then(this.chainable).shouldDeepNestedInclude(style); - }) - ); +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(() => { + const styleId = window.localStorage.getItem("maputnik:latest_style"); + const styleItemKey = `maputnik:style:${styleId}`; + const styleItem = window.localStorage.getItem(styleItemKey); + if (!styleItem) throw new Error("Could not get styleItem from localStorage"); + return JSON.parse(styleItem); + }); +} + +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)); + + /** + * Asserts that the object under test (a fixture / response body) contains every + * top-level property of the style currently stored in localStorage. + */ + shouldEqualToStoredStyle = async () => { + if (!this.page) throw new Error("shouldEqualToStoredStyle requires a page-bound assertable"); + const expected = await (this.target as any); + await retry(async () => { + const stored = await styleFromLocalStorage(this.page!); + assertDeepNestedInclude(expected, stored); + }); + }; +} + +/** + * Translates a Cypress-style key sequence (e.g. "{meta}z", "{esc}", "0.") into + * Playwright keyboard actions on the currently focused element. + */ +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 helper = new MaputnikCypressHelper(); - private modalDriver = new ModalDriver(); + private scope: Locator | null = null; + private readonly recordedRequests = new Map(); + private readonly coverageChunks: unknown[] = []; + private readonly modalDriver = new ModalDriver(this); - public beforeAndAfter = () => { - beforeEach(() => { - this.given.setupMockBackedResponses(); - this.when.setStyle("both"); + constructor(private readonly page: Page) { + // Accept confirm dialogs (e.g. the "replace current style" prompt shown when + // a style is loaded via the URL). Playwright dismisses dialogs by default. + page.on("dialog", (dialog) => dialog.accept().catch(() => undefined)); + } + + // ---- Coverage collection ------------------------------------------------- + + /** Snapshots the current coverage before the page is unloaded. */ + private async captureCoverage(): Promise { + const coverage = await readCoverage(this.page); + if (coverage) this.coverageChunks.push(coverage); + } + + async flushCoverage(testInfo: TestInfo): Promise { + await this.captureCoverage(); + this.coverageChunks.forEach((chunk, index) => { + writeCoverage(chunk, `${testInfo.testId}-${index}`); }); - }; + } - public then = (chainable: Cypress.Chainable) => - new MaputnikAssertable(chainable); + // ---- Element access ------------------------------------------------------ + + private root(): Page | Locator { + return this.scope ?? this.page; + } + + private testId(testId: string): Locator { + return this.root().locator(testIdSelector(testId)); + } + + then = (target: T) => new MaputnikAssertable(target, this.page); + + // ---- given --------------------------------------------------------------- public given = { - ...this.helper.given, - setupMockBackedResponses: () => { - this.helper.given.interceptAndMockResponse({ - method: "GET", - url: baseUrl + "example-style.json", - response: { - fixture: "example-style.json", - }, - alias: "example-style.json", + fixture: (_name: string, _alias?: string) => { + // Fixtures are read directly from disk in Playwright, no registration needed. + }, + + intercept: async (url: string, alias: string, _method = "GET") => { + this.recordedRequests.set(alias, []); + // Convert the Cypress-style glob (which may contain "?" in a query string) + // into a regex so query parameters match reliably. + const pattern = new RegExp( + "^" + url.replace(/[.+^${}()|[\]\\?]/g, "\\$&").replace(/\*/g, ".*") + "$" + ); + await this.page.route(pattern, (route) => { + this.recordedRequests.get(alias)!.push(route.request()); + route.continue(); }); - this.helper.given.interceptAndMockResponse({ - method: "GET", - url: baseUrl + "example-layer-style.json", - response: { - fixture: "example-layer-style.json", - }, + }, + + 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.interceptAndMockResponse({ - method: "GET", - url: baseUrl + "geojson-style.json", - response: { - fixture: "geojson-style.json", - }, - }); - this.helper.given.interceptAndMockResponse({ - method: "GET", - url: baseUrl + "raster-style.json", - response: { - fixture: "raster-style.json", - }, - }); - this.helper.given.interceptAndMockResponse({ - method: "GET", - url: baseUrl + "geojson-raster-style.json", - response: { - fixture: "geojson-raster-style.json", - }, - }); - this.helper.given.interceptAndMockResponse({ - method: "GET", - url: baseUrl + "rectangles-style.json", - response: { - fixture: "rectangles-style.json", - }, - }); - this.helper.given.interceptAndMockResponse({ - method: "GET", - url: baseUrl + "example-style-with-fonts.json", - response: { - fixture: "example-style-with-fonts.json", - }, - }); - this.helper.given.interceptAndMockResponse({ - method: "GET", - url: baseUrl + "example-style-with-zoom-7-and-center-0-51.json", - response: { - fixture: "example-style-with-zoom-7-and-center-0-51.json", - }, - }); - this.helper.given.interceptAndMockResponse({ - method: "GET", - url: baseUrl + "example-style-with-zoom-5-and-center-50-50.json", - response: { - fixture: "example-style-with-zoom-5-and-center-50-50.json", - }, - }); - this.helper.given.interceptAndMockResponse({ - method: "GET", - url: "*example.local/*", - response: [], - }); - this.helper.given.interceptAndMockResponse({ - method: "GET", - url: "*example.com/*", - response: [], - }); - this.helper.given.interceptAndMockResponse({ + }, + + setupMockBackedResponses: async () => { + const styleFixtures = [ + "example-style.json", + "example-layer-style.json", + "geojson-style.json", + "raster-style.json", + "geojson-raster-style.json", + "rectangles-style.json", + "example-style-with-fonts.json", + "example-style-with-zoom-7-and-center-0-51.json", + "example-style-with-zoom-5-and-center-50-50.json", + ]; + for (const fixture of styleFixtures) { + await this.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({ method: "GET", url: "https://www.glyph-server.com/*", response: ["Font 1", "Font 2", "Font 3"], @@ -124,149 +309,283 @@ export class MaputnikDriver { }, }; + // ---- when ---------------------------------------------------------------- + public when = { - ...this.helper.when, modal: this.modalDriver.when, - doWithin: (selector: string, fn: () => void) => { - this.helper.when.doWithin(fn, selector); + + visit: async (url: string) => { + await this.captureCoverage(); + const target = url.startsWith("http") ? url : new URL(url, baseUrl).toString(); + await this.page.goto(target); }, - tab: () => this.helper.get.element("body").tab(), - waitForExampleFileResponse: () => { - this.helper.when.waitForResponse("example-style.json"); + + wait: (ms: number) => this.page.waitForTimeout(ms), + + tab: () => this.page.keyboard.press("Tab"), + + typeKeys: (keys: string) => typeSequence(this.page, keys), + + doWithin: async (selector: string, fn: () => Promise | void) => { + const previous = this.scope; + this.scope = (previous ?? this.page).locator(testIdSelector(selector)); + try { + await fn(); + } finally { + this.scope = previous; + } }, - openASecondStyleWithDifferentZoomAndCenter: () => { - cy.contains("button", "Open").click(); - cy.get('[data-wd-key="modal:open.url.input"]') - .should("be.enabled") - .clear() - .type("http://localhost:8888/example-style-with-zoom-5-and-center-50-50.json{enter}"); + + click: async (testId: string, index = 0) => { + // Documentation buttons are wrapped in a