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