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) <noreply@anthropic.com>
This commit is contained in:
HarelM
2026-07-08 12:42:32 +03:00
parent f7b48139a4
commit c3dd253737
33 changed files with 1908 additions and 3879 deletions
+31 -14
View File
@@ -96,6 +96,7 @@ jobs:
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
- run: npm ci - run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run test-unit-ci - run: npm run test-unit-ci
- name: Upload coverage reports to Codecov - name: Upload coverage reports to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
@@ -117,17 +118,21 @@ jobs:
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
- run: npm ci - run: npm ci
- name: Cypress run - run: npx playwright install --with-deps chromium
uses: cypress-io/github-action@fa4a118725a8f001170d49631ea89e5d66fee626 # v7.4.1 - name: Playwright run
with: run: npm run test-e2e
build: npm run build
start: npm run start
browser: chrome
- name: Upload coverage reports to Codecov - name: Upload coverage reports to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with: with:
files: ${{ github.workspace }}/.nyc_output/out.json files: ${{ github.workspace }}/coverage/coverage-final.json
verbose: true 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: e2e-tests-docker:
name: "E2E tests using chrome and docker" name: "E2E tests using chrome and docker"
@@ -141,14 +146,26 @@ jobs:
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
- run: npm ci - run: npm ci
- name: Cypress run - run: npx playwright install --with-deps chromium
uses: cypress-io/github-action@fa4a118725a8f001170d49631ea89e5d66fee626 # v7.4.1 - name: Build docker image
with: run: docker build -t maputnik .
build: docker build -t maputnik . - name: Start maputnik container
start: docker run --rm --network host maputnik --port=8888 run: docker run -d --network host --name maputnik maputnik --port=8888
browser: chrome - 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 - name: Upload coverage reports to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with: with:
files: ${{ github.workspace }}/.nyc_output/out.json files: ${{ github.workspace }}/coverage/coverage-final.json
verbose: true verbose: true
+5 -1
View File
@@ -33,10 +33,14 @@ node_modules
public public
/errorShots /errorShots
/old /old
/cypress/screenshots
/dist/ /dist/
/desktop/version.go /desktop/version.go
# Playwright
/test-results/
/playwright-report/
/playwright/.cache/
# IDE # IDE
.vscode/ .vscode/
.idea/ .idea/
+5 -4
View File
@@ -4,12 +4,13 @@
"check-coverage": false, "check-coverage": false,
"include": ["src/**/*.ts", "src/**/*.tsx"], "include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": [ "exclude": [
"cypress/**/*.*", "e2e/**/*.*",
"**/*.d.ts", "**/*.d.ts",
"**/*.cy.tsx", "**/*.test.ts",
"**/*.cy.ts", "**/*.test.tsx",
"**/*.browser.test.tsx",
"./coverage/**", "./coverage/**",
"./cypress/**", "./e2e/**",
"./dist/**", "./dist/**",
"node_modules" "node_modules"
], ],
+6 -6
View File
@@ -24,22 +24,22 @@ The project type checked and built with:
npm run build 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 ## Pull Requests
+1
View File
@@ -1,6 +1,7 @@
## main ## main
### ✨ Features and improvements ### ✨ 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..._ - _...Add new stuff here..._
### 🐞 Bug fixes ### 🐞 Bug fixes
+30 -11
View File
@@ -79,26 +79,45 @@ npm run sort-styles
``` ```
## Tests ## 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 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] Some useful options:
You can also see the tests as they run or select which suites to run by executing:
``` ```
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 ## Release process
-56
View File
@@ -1,56 +0,0 @@
/// <reference types="cypress-real-events" />
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<string> => 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;
}
-37
View File
@@ -1,37 +0,0 @@
/// <reference types="cypress" />
// ***********************************************
// 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<void>
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
// }
// }
// }
-12
View File
@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Components App</title>
</head>
<body>
<div data-cy-root></div>
</body>
</html>
-37
View File
@@ -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 <reference path="./component" /> 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(<MyComponent />)
-22
View File
@@ -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')
+29 -24
View File
@@ -1,40 +1,45 @@
import { MaputnikDriver } from "./maputnik-driver"; import { test, setupMaputnik } from "./fixtures";
test.describe("accessibility", () => { test.describe("accessibility", () => {
const { beforeAndAfter, get, when, then } = new MaputnikDriver(); setupMaputnik();
beforeAndAfter();
test.describe("skip links", () => { test.describe("skip links", () => {
beforeEach(() => { test.beforeEach(async ({ driver }) => {
when.setStyle("layer"); 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"; const selector = "root:skip:layer-list";
then(get.elementByTestId(selector)).shouldExist(); await then(get.elementByTestId(selector)).shouldExist();
when.tab(); await when.tab();
then(get.elementByTestId(selector)).shouldBeFocused(); await then(get.elementByTestId(selector)).shouldBeFocused();
when.click(selector); await when.click(selector);
then(get.skipTargetLayerList()).shouldBeFocused(); 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"; const selector = "root:skip:layer-editor";
then(get.elementByTestId(selector)).shouldExist(); await then(get.elementByTestId(selector)).shouldExist();
then(get.elementByTestId("skip-target-layer-editor")).shouldExist(); await then(get.elementByTestId("skip-target-layer-editor")).shouldExist();
when.tab().tab(); await when.tab();
then(get.elementByTestId(selector)).shouldBeFocused(); await when.tab();
when.click(selector); await then(get.elementByTestId(selector)).shouldBeFocused();
then(get.skipTargetLayerEditor()).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"; const selector = "root:skip:map-view";
then(get.elementByTestId(selector)).shouldExist(); await then(get.elementByTestId(selector)).shouldExist();
when.tab().tab().tab(); await when.tab();
then(get.elementByTestId(selector)).shouldBeFocused(); await when.tab();
when.click(selector); await when.tab();
then(get.canvas()).shouldBeFocused(); await then(get.elementByTestId(selector)).shouldBeFocused();
await when.click(selector);
await then(get.canvas()).shouldBeFocused();
}); });
}); });
}); });
+12 -11
View File
@@ -1,18 +1,19 @@
import { MaputnikDriver } from "./maputnik-driver"; import { test, setupMaputnik } from "./fixtures";
test.describe("code editor", () => { test.describe("code editor", () => {
const { beforeAndAfter, when, get, then } = new MaputnikDriver(); setupMaputnik();
beforeAndAfter();
test("open code editor", () => { test("open code editor", async ({ driver }) => {
when.click("nav:code-editor"); const { when, get, then } = driver;
then(get.element(".maputnik-code-editor")).shouldExist(); await when.click("nav:code-editor");
await then(get.element(".maputnik-code-editor")).shouldExist();
}); });
test("closes code editor", () => { test("closes code editor", async ({ driver }) => {
when.click("nav:code-editor"); const { when, get, then } = driver;
then(get.element(".maputnik-code-editor")).shouldExist(); await when.click("nav:code-editor");
when.click("nav:code-editor"); await then(get.element(".maputnik-code-editor")).shouldExist();
then(get.element(".maputnik-code-editor")).shouldNotExist(); await when.click("nav:code-editor");
await then(get.element(".maputnik-code-editor")).shouldNotExist();
}); });
}); });
+30
View File
@@ -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<unknown | null> {
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));
}
+27
View File
@@ -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");
});
}
+12
View File
@@ -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 });
}
+23
View File
@@ -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);
}
}
+43 -81
View File
@@ -1,124 +1,86 @@
import { MaputnikDriver } from "./maputnik-driver"; import { test, setupMaputnik } from "./fixtures";
test.describe("history", () => { test.describe("history", () => {
const { beforeAndAfter, when, get, then } = new MaputnikDriver(); setupMaputnik();
beforeAndAfter();
let undoKeyCombo: string; const undoKeyCombo = process.platform === "darwin" ? "{meta}z" : "{ctrl}z";
let redoKeyCombo: string; const redoKeyCombo = process.platform === "darwin" ? "{meta}{shift}z" : "{ctrl}y";
before(() => { test("undo/redo", async ({ driver }) => {
const isMac = get.isMac(); const { get, when, then } = driver;
undoKeyCombo = isMac ? "{meta}z" : "{ctrl}z"; await when.setStyle("geojson");
redoKeyCombo = isMac ? "{meta}{shift}z" : "{ctrl}y"; await when.modal.open();
});
test("undo/redo", () => { await when.modal.fillLayers({
when.setStyle("geojson");
when.modal.open();
when.modal.fillLayers({
id: "step 1", id: "step 1",
type: "background", type: "background",
}); });
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [{ id: "step 1", type: "background" }],
{
id: "step 1",
type: "background",
},
],
}); });
when.modal.open(); await when.modal.open();
when.modal.fillLayers({ await when.modal.fillLayers({
id: "step 2", id: "step 2",
type: "background", type: "background",
}); });
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [
{ { id: "step 1", type: "background" },
id: "step 1", { id: "step 2", type: "background" },
type: "background",
},
{
id: "step 2",
type: "background",
},
], ],
}); });
when.typeKeys(undoKeyCombo); await when.typeKeys(undoKeyCombo);
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [{ id: "step 1", type: "background" }],
{
id: "step 1",
type: "background",
},
],
}); });
when.typeKeys(undoKeyCombo); await when.typeKeys(undoKeyCombo);
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [] }); await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [] });
when.typeKeys(redoKeyCombo); await when.typeKeys(redoKeyCombo);
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [{ id: "step 1", type: "background" }],
{
id: "step 1",
type: "background",
},
],
}); });
when.typeKeys(redoKeyCombo); await when.typeKeys(redoKeyCombo);
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [
{ { id: "step 1", type: "background" },
id: "step 1", { id: "step 2", type: "background" },
type: "background",
},
{
id: "step 2",
type: "background",
},
], ],
}); });
}); });
test("should not redo after undo and value change", () => { test("should not redo after undo and value change", async ({ driver }) => {
when.setStyle("geojson"); const { get, when, then } = driver;
when.modal.open(); await when.setStyle("geojson");
when.modal.fillLayers({ await when.modal.open();
await when.modal.fillLayers({
id: "step 1", id: "step 1",
type: "background", type: "background",
}); });
when.modal.open(); await when.modal.open();
when.modal.fillLayers({ await when.modal.fillLayers({
id: "step 2", id: "step 2",
type: "background", type: "background",
}); });
when.typeKeys(undoKeyCombo); await when.typeKeys(undoKeyCombo);
when.typeKeys(undoKeyCombo); await when.typeKeys(undoKeyCombo);
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [] }); await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [] });
when.modal.open(); await when.modal.open();
when.modal.fillLayers({ await when.modal.fillLayers({
id: "step 3", id: "step 3",
type: "background", type: "background",
}); });
when.typeKeys(redoKeyCombo); await when.typeKeys(redoKeyCombo);
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [{ id: "step 3", type: "background" }],
{
id: "step 3",
type: "background",
},
],
}); });
}); });
}); });
+18 -18
View File
@@ -1,35 +1,35 @@
import { MaputnikDriver } from "./maputnik-driver"; import { test, setupMaputnik } from "./fixtures";
test.describe("i18n", () => { test.describe("i18n", () => {
const { beforeAndAfter, get, when, then } = new MaputnikDriver(); setupMaputnik();
beforeAndAfter();
test.describe("language detector", () => { test.describe("language detector", () => {
test("English", () => { test("English", async ({ driver }) => {
const url = "?lng=en"; const { get, when, then } = driver;
when.visit(url); await when.visit("?lng=en");
then(get.elementByTestId("maputnik-lang-select")).shouldHaveValue("en"); await then(get.elementByTestId("maputnik-lang-select")).shouldHaveValue("en");
}); });
test("Japanese", () => { test("Japanese", async ({ driver }) => {
const url = "?lng=ja"; const { get, when, then } = driver;
when.visit(url); await when.visit("?lng=ja");
then(get.elementByTestId("maputnik-lang-select")).shouldHaveValue("ja"); await then(get.elementByTestId("maputnik-lang-select")).shouldHaveValue("ja");
}); });
}); });
test.describe("language switcher", () => { test.describe("language switcher", () => {
beforeEach(() => { test.beforeEach(async ({ driver }) => {
when.setStyle("layer"); 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"; const selector = "maputnik-lang-select";
then(get.elementByTestId(selector)).shouldExist(); await then(get.elementByTestId(selector)).shouldExist();
when.select(selector, "ja"); await when.select(selector, "ja");
then(get.elementByTestId(selector)).shouldHaveValue("ja"); await then(get.elementByTestId(selector)).shouldHaveValue("ja");
then(get.elementByTestId("nav:settings")).shouldHaveText("スタイル設定"); await then(get.elementByTestId("nav:settings")).shouldHaveText("スタイル設定");
}); });
}); });
}); });
+44 -35
View File
@@ -1,60 +1,69 @@
import { MaputnikDriver } from "./maputnik-driver"; import { test, setupMaputnik } from "./fixtures";
test.describe("keyboard", () => { test.describe("keyboard", () => {
const { beforeAndAfter, given, when, get, then } = new MaputnikDriver(); setupMaputnik();
beforeAndAfter();
test.describe("shortcuts", () => { test.describe("shortcuts", () => {
beforeEach(() => { test.beforeEach(async ({ driver }) => {
given.setupMockBackedResponses(); await driver.given.setupMockBackedResponses();
when.setStyle(""); await driver.when.setStyle("");
}); });
test("ESC should unfocus", () => { test("ESC should unfocus", async ({ driver }) => {
const { get, when, then } = driver;
const targetSelector = "maputnik-select"; const targetSelector = "maputnik-select";
when.focus(targetSelector); await when.focus(targetSelector);
then(get.elementByTestId(targetSelector)).shouldBeFocused(); await then(get.elementByTestId(targetSelector)).shouldBeFocused();
when.typeKeys("{esc}"); await when.typeKeys("{esc}");
then(get.elementByTestId(targetSelector)).shouldNotBeFocused(); await then(get.elementByTestId(targetSelector)).shouldNotBeFocused();
}); });
test("'?' should show shortcuts modal", () => { test("'?' should show shortcuts modal", async ({ driver }) => {
when.typeKeys("?"); const { get, when, then } = driver;
then(get.elementByTestId("modal:shortcuts")).shouldBeVisible(); await when.typeKeys("?");
await then(get.elementByTestId("modal:shortcuts")).shouldBeVisible();
}); });
test("'o' should show open modal", () => { test("'o' should show open modal", async ({ driver }) => {
when.typeKeys("o"); const { get, when, then } = driver;
then(get.elementByTestId("modal:open")).shouldBeVisible(); await when.typeKeys("o");
await then(get.elementByTestId("modal:open")).shouldBeVisible();
}); });
test("'e' should show export modal", () => { test("'e' should show export modal", async ({ driver }) => {
when.typeKeys("e"); const { get, when, then } = driver;
then(get.elementByTestId("modal:export")).shouldBeVisible(); await when.typeKeys("e");
await then(get.elementByTestId("modal:export")).shouldBeVisible();
}); });
test("'d' should show sources modal", () => { test("'d' should show sources modal", async ({ driver }) => {
when.typeKeys("d"); const { get, when, then } = driver;
then(get.elementByTestId("modal:sources")).shouldBeVisible(); await when.typeKeys("d");
await then(get.elementByTestId("modal:sources")).shouldBeVisible();
}); });
test("'s' should show settings modal", () => { test("'s' should show settings modal", async ({ driver }) => {
when.typeKeys("s"); const { get, when, then } = driver;
then(get.elementByTestId("modal:settings")).shouldBeVisible(); await when.typeKeys("s");
await then(get.elementByTestId("modal:settings")).shouldBeVisible();
}); });
test("'i' should change map to inspect mode", () => { test("'i' should change map to inspect mode", async ({ driver }) => {
when.typeKeys("i"); const { get, when, then } = driver;
then(get.inputValue("maputnik-select")).shouldEqual("inspect"); await when.typeKeys("i");
await then(get.inputValue("maputnik-select")).shouldEqual("inspect");
}); });
test("'m' should focus map", () => { test("'m' should focus map", async ({ driver }) => {
when.typeKeys("m"); const { get, when, then } = driver;
then(get.canvas()).shouldBeFocused(); await when.typeKeys("m");
await then(get.canvas()).shouldBeFocused();
}); });
test("'!' should show debug modal", () => { test("'!' should show debug modal", async ({ driver }) => {
when.typeKeys("!"); const { get, when, then } = driver;
then(get.elementByTestId("modal:debug")).shouldBeVisible(); await when.typeKeys("!");
await then(get.elementByTestId("modal:debug")).shouldBeVisible();
}); });
}); });
}); });
+138 -166
View File
@@ -1,96 +1,82 @@
import { MaputnikDriver } from "./maputnik-driver";
import { v1 as uuid } from "uuid"; import { v1 as uuid } from "uuid";
import { test, setupMaputnik } from "./fixtures";
import type { MaputnikDriver } from "./maputnik-driver";
test.describe("layer editor", () => { test.describe("layer editor", () => {
const { beforeAndAfter, get, when, then } = new MaputnikDriver(); setupMaputnik();
beforeAndAfter(); test.beforeEach(async ({ driver }) => {
beforeEach(() => { await driver.when.setStyle("both");
when.setStyle("both"); await driver.when.modal.open();
when.modal.open();
}); });
function createBackground() { async function createBackground(driver: MaputnikDriver) {
const { get, when, then } = driver;
const id = uuid(); const id = uuid();
when.selectWithin("add-layer.layer-type", "background"); await when.selectWithin("add-layer.layer-type", "background");
when.setValue("add-layer.layer-id.input", "background:" + id); await when.setValue("add-layer.layer-id.input", "background:" + id);
when.click("add-layer"); await when.click("add-layer");
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [{ id: "background:" + id, type: "background" }],
{
id: "background:" + id,
type: "background",
},
],
}); });
return id; return id;
} }
test("expand/collapse"); test.skip("expand/collapse", () => {});
test("id", () => {
const bgId = createBackground();
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(); const id = uuid();
when.setValue("layer-editor.layer-id.input", "foobar:" + id); await when.setValue("layer-editor.layer-id.input", "foobar:" + id);
when.click("min-zoom"); await when.click("min-zoom");
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [{ id: "foobar:" + id, type: "background" }],
{
id: "foobar:" + id,
type: "background",
},
],
}); });
}); });
test.describe("source", () => { test.describe("source", () => {
test("should show error when the source is invalid", () => { test("should show error when the source is invalid", async ({ driver }) => {
when.modal.fillLayers({ const { get, when, then } = driver;
await when.modal.fillLayers({
type: "circle", type: "circle",
layer: "invalid", 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", () => { test.describe("min-zoom", () => {
let bgId: string; let bgId: string;
beforeEach(() => { test.beforeEach(async ({ driver }) => {
bgId = createBackground(); const { when } = driver;
when.click("layer-list-item:background:" + bgId); bgId = await createBackground(driver);
when.setValue("min-zoom.input-text", "1"); await when.click("layer-list-item:background:" + bgId);
when.click("layer-editor.layer-id"); await when.setValue("min-zoom.input-text", "1");
await when.click("layer-editor.layer-id");
}); });
test("should update min-zoom in local storage", () => { test("should update min-zoom in local storage", async ({ driver }) => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await driver.then(driver.get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [{ id: "background:" + bgId, type: "background", minzoom: 1 }],
{
id: "background:" + bgId,
type: "background",
minzoom: 1,
},
],
}); });
}); });
test("when clicking next layer should update style on local storage", () => { test("when clicking next layer should update style on local storage", async ({ driver }) => {
when.type("min-zoom.input-text", "{backspace}"); const { get, when, then } = driver;
when.click("max-zoom.input-text"); await when.type("min-zoom.input-text", "{backspace}");
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await when.click("max-zoom.input-text");
layers: [ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
{ layers: [{ id: "background:" + bgId, type: "background", minzoom: 1 }],
id: "background:" + bgId,
type: "background",
minzoom: 1,
},
],
}); });
}); });
}); });
@@ -98,22 +84,17 @@ test.describe("layer editor", () => {
test.describe("max-zoom", () => { test.describe("max-zoom", () => {
let bgId: string; let bgId: string;
beforeEach(() => { test.beforeEach(async ({ driver }) => {
bgId = createBackground(); const { when } = driver;
when.click("layer-list-item:background:" + bgId); bgId = await createBackground(driver);
when.setValue("max-zoom.input-text", "1"); await when.click("layer-list-item:background:" + bgId);
when.click("layer-editor.layer-id"); await when.setValue("max-zoom.input-text", "1");
await when.click("layer-editor.layer-id");
}); });
test("should update style in local storage", () => { test("should update style in local storage", async ({ driver }) => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await driver.then(driver.get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [{ id: "background:" + bgId, type: "background", maxzoom: 1 }],
{
id: "background:" + bgId,
type: "background",
maxzoom: 1,
},
],
}); });
}); });
}); });
@@ -122,41 +103,36 @@ test.describe("layer editor", () => {
let bgId: string; let bgId: string;
const comment = "42"; const comment = "42";
beforeEach(() => { test.beforeEach(async ({ driver }) => {
bgId = createBackground(); const { when } = driver;
when.click("layer-list-item:background:" + bgId); bgId = await createBackground(driver);
when.setValue("layer-comment.input", comment); await when.click("layer-list-item:background:" + bgId);
when.click("layer-editor.layer-id"); await when.setValue("layer-comment.input", comment);
await when.click("layer-editor.layer-id");
}); });
test("should update style in local storage", () => { test("should update style in local storage", async ({ driver }) => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await driver.then(driver.get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [
{ {
id: "background:" + bgId, id: "background:" + bgId,
type: "background", type: "background",
metadata: { metadata: { "maputnik:comment": comment },
"maputnik:comment": comment,
},
}, },
], ],
}); });
}); });
test.describe("when unsetting", () => { test.describe("when unsetting", () => {
beforeEach(() => { test.beforeEach(async ({ driver }) => {
when.clear("layer-comment.input"); const { when } = driver;
when.click("min-zoom.input-text"); await when.clear("layer-comment.input");
await when.click("min-zoom.input-text");
}); });
test("should update style in local storage", () => { test("should update style in local storage", async ({ driver }) => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await driver.then(driver.get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [{ id: "background:" + bgId, type: "background" }],
{
id: "background:" + bgId,
type: "background",
},
],
}); });
}); });
}); });
@@ -164,131 +140,127 @@ test.describe("layer editor", () => {
test.describe("color", () => { test.describe("color", () => {
let bgId: string; let bgId: string;
beforeEach(() => { test.beforeEach(async ({ driver }) => {
bgId = createBackground(); const { when } = driver;
when.click("layer-list-item:background:" + bgId); bgId = await createBackground(driver);
when.click("spec-field:background-color"); await when.click("layer-list-item:background:" + bgId);
await when.click("spec-field:background-color");
}); });
test("should update style in local storage", () => { test("should update style in local storage", async ({ driver }) => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await driver.then(driver.get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [{ id: "background:" + bgId, type: "background" }],
{
id: "background:" + bgId,
type: "background",
},
],
}); });
}); });
}); });
test.describe("opacity", () => { test.describe("opacity", () => {
let bgId: string; let bgId: string;
beforeEach(() => { test.beforeEach(async ({ driver }) => {
bgId = createBackground(); const { when } = driver;
when.click("layer-list-item:background:" + bgId); bgId = await createBackground(driver);
when.type("spec-field-input:background-opacity", "0."); await when.click("layer-list-item:background:" + bgId);
await when.type("spec-field-input:background-opacity", "0.");
}); });
test("should keep '.' in the input field", () => { test("should keep '.' in the input field", async ({ driver }) => {
then(get.elementByTestId("spec-field-input:background-opacity")).shouldHaveValue("0."); await driver
.then(driver.get.elementByTestId("spec-field-input:background-opacity"))
.shouldHaveValue("0.");
}); });
test("should revert to a valid value when focus out", () => { test("should revert to a valid value when focus out", async ({ driver }) => {
when.click("layer-list-item:background:" + bgId); const { get, when, then } = driver;
then(get.elementByTestId("spec-field-input:background-opacity")).shouldHaveValue("0"); await when.click("layer-list-item:background:" + bgId);
await then(get.elementByTestId("spec-field-input:background-opacity")).shouldHaveValue("0");
}); });
}); });
test.describe("filter", () => { test.describe("filter", () => {
test("expand/collapse"); test.skip("expand/collapse", () => {});
test("compound filter"); test.skip("compound filter", () => {});
}); });
test.describe("layout", () => { test.describe("layout", () => {
test("text-font", () => { test("text-font", async ({ driver }) => {
when.setStyle("font"); const { get, when, then } = driver;
when.collapseGroupInLayerEditor(); await when.setStyle("font");
when.collapseGroupInLayerEditor(1); await when.collapseGroupInLayerEditor();
when.collapseGroupInLayerEditor(2); await when.collapseGroupInLayerEditor(1);
when.doWithin("spec-field:text-font", () => { await when.collapseGroupInLayerEditor(2);
get.element(".maputnik-autocomplete input").first().click(); await when.doWithin("spec-field:text-font", async () => {
await get.element(".maputnik-autocomplete input").first().click();
}); });
then(get.element(".maputnik-autocomplete-menu-item")).shouldBeVisible(); await 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")).shouldHaveLength(3);
}); });
}); });
test.describe("paint", () => { test.describe("paint", () => {
test("expand/collapse"); test.skip("expand/collapse", () => {});
test("color"); test.skip("color", () => {});
test("pattern"); test.skip("pattern", () => {});
test("opacity"); test.skip("opacity", () => {});
}); });
test.describe("json-editor", () => { test.describe("json-editor", () => {
test("add", () => { test("add", async ({ driver }) => {
const id = when.modal.fillLayers({ const { get, when, then } = driver;
const id = await when.modal.fillLayers({
type: "circle", type: "circle",
layer: "example", layer: "example",
}); });
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [{ id, type: "circle", source: "example" }],
{
id: id,
type: "circle",
source: "example",
},
],
}); });
const sourceText = get.elementByText('"source"'); const sourceText = get.elementByText('"source"');
await sourceText.click();
await when.typeKeys('"');
sourceText.click(); await then(get.element(".cm-lint-marker-error")).shouldExist();
sourceText.type("\"");
then(get.element(".cm-lint-marker-error")).shouldExist();
}); });
test.skip("expand/collapse", () => {});
test.skip("modify", () => {});
test("expand/collapse"); test("parse error", async ({ driver }) => {
test("modify"); const { get, when, then } = driver;
const bgId = await createBackground(driver);
test("parse error", () => { await when.click("layer-list-item:background:" + bgId);
const bgId = createBackground(); await when.collapseGroupInLayerEditor();
await when.collapseGroupInLayerEditor(1);
await then(get.element(".cm-lint-marker-error")).shouldNotExist();
when.click("layer-list-item:background:" + bgId); await when.appendTextInJsonEditor(
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 {"
); );
then(get.element(".cm-lint-marker-error")).shouldExist(); await then(get.element(".cm-lint-marker-error")).shouldExist();
}); });
}); });
test.describe("sticky header", () => { test.describe("sticky header", () => {
test("should keep layer header visible when scrolling properties", () => { test("should keep layer header visible when scrolling properties", async ({ driver }) => {
// Setup: Create a layer with many properties (e.g., symbol layer) const { get, when, then } = driver;
when.modal.fillLayers({ // Setup: Create a layer with many properties (e.g. symbol layer)
await when.modal.fillLayers({
type: "symbol", type: "symbol",
layer: "example", layer: "example",
}); });
when.wait(500); await when.wait(500);
const header = get.elementByTestId("layer-editor.header"); const header = get.elementByTestId("layer-editor.header");
then(header).shouldBeVisible(); await then(header).shouldBeVisible();
get.element(".maputnik-scroll-container").scrollTo("bottom", { ensureScrollable: false }); await get
when.wait(200); .element(".maputnik-scroll-container")
.evaluate((el) => el.scrollTo(0, el.scrollHeight));
await when.wait(200);
then(header).shouldBeVisible(); await then(header).shouldBeVisible();
then(get.elementByTestId("skip-target-layer-editor")).shouldBeVisible(); await then(get.elementByTestId("skip-target-layer-editor")).shouldBeVisible();
}); });
}); });
}); });
+212 -385
View File
@@ -1,411 +1,278 @@
import { MaputnikDriver } from "./maputnik-driver"; import { test, setupMaputnik } from "./fixtures";
test.describe("layers list", () => { test.describe("layers list", () => {
const { beforeAndAfter, get, when, then } = new MaputnikDriver(); setupMaputnik();
beforeAndAfter(); test.beforeEach(async ({ driver }) => {
beforeEach(() => { await driver.when.setStyle("both");
when.setStyle("both"); await driver.when.modal.open();
when.modal.open();
}); });
test.describe("ops", () => { test.describe("ops", () => {
let id: string; let id: string;
beforeEach(() => { test.beforeEach(async ({ driver }) => {
id = when.modal.fillLayers({ id = await driver.when.modal.fillLayers({ type: "background" });
type: "background",
});
}); });
test("should update layers in local storage", () => { test("should update layers in local storage", async ({ driver }) => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await driver.then(driver.get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [{ id, type: "background" }],
{
id: id,
type: "background",
},
],
}); });
}); });
test.describe("when clicking delete", () => { test.describe("when clicking delete", () => {
beforeEach(() => { test.beforeEach(async ({ driver }) => {
when.click("layer-list-item:" + id + ":delete"); await driver.when.click("layer-list-item:" + id + ":delete");
}); });
test("should empty layers in local storage", () => { test("should empty layers in local storage", async ({ driver }) => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await driver.then(driver.get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [], layers: [],
}); });
}); });
}); });
test.describe("when clicking duplicate", () => { test.describe("when clicking duplicate", () => {
beforeEach(() => { test.beforeEach(async ({ driver }) => {
when.click("layer-list-item:" + id + ":copy"); await driver.when.click("layer-list-item:" + id + ":copy");
}); });
test("should add copy layer in local storage", () => { test("should add copy layer in local storage", async ({ driver }) => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await driver.then(driver.get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [
{ { id: id + "-copy", type: "background" },
id: id + "-copy", { id, type: "background" },
type: "background",
},
{
id: id,
type: "background",
},
], ],
}); });
}); });
}); });
test.describe("when clicking hide", () => { test.describe("when clicking hide", () => {
beforeEach(() => { test.beforeEach(async ({ driver }) => {
when.click("layer-list-item:" + id + ":toggle-visibility"); await driver.when.click("layer-list-item:" + id + ":toggle-visibility");
}); });
test("should update visibility to none in local storage", () => { test("should update visibility to none in local storage", async ({ driver }) => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await driver.then(driver.get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [{ id, type: "background", layout: { visibility: "none" } }],
{
id: id,
type: "background",
layout: {
visibility: "none",
},
},
],
}); });
}); });
test.describe("when clicking show", () => { test.describe("when clicking show", () => {
beforeEach(() => { test.beforeEach(async ({ driver }) => {
when.click("layer-list-item:" + id + ":toggle-visibility"); await driver.when.click("layer-list-item:" + id + ":toggle-visibility");
}); });
test("should update visibility to visible in local storage", () => { test("should update visibility to visible in local storage", async ({ driver }) => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await driver.then(driver.get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [{ id, type: "background", layout: { visibility: "visible" } }],
{
id: id,
type: "background",
layout: {
visibility: "visible",
},
},
],
}); });
}); });
}); });
test.describe("when selecting a layer", () => { test.describe("when selecting a layer", () => {
let secondId: string; let secondId: string;
beforeEach(() => { test.beforeEach(async ({ driver }) => {
when.modal.open(); await driver.when.modal.open();
secondId = when.modal.fillLayers({ secondId = await driver.when.modal.fillLayers({
id: "second-layer", id: "second-layer",
type: "background", type: "background",
}); });
}); });
test("should show the selected layer in the editor", () => { test("should show the selected layer in the editor", async ({ driver }) => {
when.realClick("layer-list-item:" + secondId); const { get, when, then } = driver;
then(get.elementByTestId("layer-editor.layer-id.input")).shouldHaveValue(secondId); await when.realClick("layer-list-item:" + secondId);
when.realClick("layer-list-item:" + id); await then(get.elementByTestId("layer-editor.layer-id.input")).shouldHaveValue(secondId);
then(get.elementByTestId("layer-editor.layer-id.input")).shouldHaveValue(id); await when.realClick("layer-list-item:" + id);
await then(get.elementByTestId("layer-editor.layer-id.input")).shouldHaveValue(id);
}); });
}); });
}); });
}); });
test.describe("background", () => { test.describe("background", () => {
test("add", () => { test("add", async ({ driver }) => {
const id = when.modal.fillLayers({ const { get, when, then } = driver;
type: "background", const id = await when.modal.fillLayers({ type: "background" });
}); await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [{ id, type: "background" }],
layers: [
{
id: id,
type: "background",
},
],
}); });
}); });
test.describe("modify", () => {}); test.skip("modify", () => {});
}); });
test.describe("fill", () => { test.describe("fill", () => {
test("add", () => { test("add", async ({ driver }) => {
const id = when.modal.fillLayers({ const { get, when, then } = driver;
type: "fill", const id = await when.modal.fillLayers({ type: "fill", layer: "example" });
layer: "example", await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
}); layers: [{ id, type: "fill", source: "example" }],
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "fill",
source: "example",
},
],
}); });
}); });
// TODO: Change source // TODO: Change source
test("change source"); test.skip("change source", () => {});
}); });
test.describe("line", () => { test.describe("line", () => {
test("add", () => { test("add", async ({ driver }) => {
const id = when.modal.fillLayers({ const { get, when, then } = driver;
type: "line", const id = await when.modal.fillLayers({ type: "line", layer: "example" });
layer: "example", await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
}); layers: [{ id, type: "line", source: "example" }],
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "line",
source: "example",
},
],
}); });
}); });
test("groups", () => { test("groups", async ({ driver }) => {
when.modal.open(); const { get, when, then } = driver;
const id1 = when.modal.fillLayers({ await when.modal.open();
id: "aa", const id1 = await when.modal.fillLayers({ id: "aa", type: "line", layer: "example" });
type: "line",
layer: "example",
});
when.modal.open(); await when.modal.open();
const id2 = when.modal.fillLayers({ const id2 = await when.modal.fillLayers({ id: "aa-2", type: "line", layer: "example" });
id: "aa-2",
type: "line",
layer: "example",
});
when.modal.open(); await when.modal.open();
const id3 = when.modal.fillLayers({ const id3 = await when.modal.fillLayers({ id: "b", type: "line", layer: "example" });
id: "b",
type: "line",
layer: "example",
});
then(get.elementByTestId("layer-list-item:" + id1)).shouldBeVisible(); await then(get.elementByTestId("layer-list-item:" + id1)).shouldBeVisible();
then(get.elementByTestId("layer-list-item:" + id2)).shouldNotBeVisible(); await then(get.elementByTestId("layer-list-item:" + id2)).shouldNotBeVisible();
then(get.elementByTestId("layer-list-item:" + id3)).shouldBeVisible(); await then(get.elementByTestId("layer-list-item:" + id3)).shouldBeVisible();
when.click("layer-list-group:aa-0"); await when.click("layer-list-group:aa-0");
then(get.elementByTestId("layer-list-item:" + id1)).shouldBeVisible(); await then(get.elementByTestId("layer-list-item:" + id1)).shouldBeVisible();
then(get.elementByTestId("layer-list-item:" + id2)).shouldBeVisible(); await then(get.elementByTestId("layer-list-item:" + id2)).shouldBeVisible();
then(get.elementByTestId("layer-list-item:" + id3)).shouldBeVisible(); await then(get.elementByTestId("layer-list-item:" + id3)).shouldBeVisible();
when.click("layer-list-item:" + id2); await when.click("layer-list-item:" + id2);
when.click("skip-target-layer-editor"); await when.click("skip-target-layer-editor");
when.click("menu-move-layer-down"); await when.click("menu-move-layer-down");
then(get.elementByTestId("layer-list-group:aa-0")).shouldNotExist(); await then(get.elementByTestId("layer-list-group:aa-0")).shouldNotExist();
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [
{ { id: "aa", type: "line", source: "example" },
id: "aa", { id: "b", type: "line", source: "example" },
type: "line", { id: "aa-2", type: "line", source: "example" },
source: "example",
},
{
id: "b",
type: "line",
source: "example",
},
{
id: "aa-2",
type: "line",
source: "example",
},
], ],
}); });
}); });
}); });
test.describe("symbol", () => { test.describe("symbol", () => {
test("add", () => { test("add", async ({ driver }) => {
const id = when.modal.fillLayers({ const { get, when, then } = driver;
type: "symbol", const id = await when.modal.fillLayers({ type: "symbol", layer: "example" });
layer: "example", await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
}); layers: [{ id, type: "symbol", source: "example" }],
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "symbol",
source: "example",
},
],
}); });
}); });
test("should show spec info when hovering and clicking single line property", () => { test("should show spec info when hovering and clicking single line property", async ({ driver }) => {
when.modal.fillLayers({ const { get, when, then } = driver;
type: "symbol", await when.modal.fillLayers({ type: "symbol", layer: "example" });
layer: "example",
});
when.hover("spec-field-container:text-rotate"); await when.hover("spec-field-container:text-rotate");
then(get.elementByTestId("field-doc-button-Rotate")).shouldBeVisible(); await then(get.elementByTestId("field-doc-button-Rotate")).shouldBeVisible();
when.click("field-doc-button-Rotate", 0); await when.click("field-doc-button-Rotate", 0);
then(get.elementByTestId("spec-field-doc")).shouldContainText("Rotates the "); await then(get.elementByTestId("spec-field-doc")).shouldContainText("Rotates the ");
}); });
test("should show spec info when hovering and clicking multi line property", () => { test("should show spec info when hovering and clicking multi line property", async ({ driver }) => {
when.modal.fillLayers({ const { get, when, then } = driver;
type: "symbol", await when.modal.fillLayers({ type: "symbol", layer: "example" });
layer: "example",
});
when.hover("spec-field-container:text-offset"); await when.hover("spec-field-container:text-offset");
then(get.elementByTestId("field-doc-button-Offset")).shouldBeVisible(); await then(get.elementByTestId("field-doc-button-Offset")).shouldBeVisible();
when.click("field-doc-button-Offset", 0); await when.click("field-doc-button-Offset", 0);
then(get.elementByTestId("spec-field-doc")).shouldContainText("Offset distance"); await then(get.elementByTestId("spec-field-doc")).shouldContainText("Offset distance");
}); });
test("should hide spec info when clicking a second time", () => { test("should hide spec info when clicking a second time", async ({ driver }) => {
when.modal.fillLayers({ const { get, when, then } = driver;
type: "symbol", await when.modal.fillLayers({ type: "symbol", layer: "example" });
layer: "example",
});
when.hover("spec-field-container:text-rotate"); await when.hover("spec-field-container:text-rotate");
then(get.elementByTestId("field-doc-button-Rotate")).shouldBeVisible(); await then(get.elementByTestId("field-doc-button-Rotate")).shouldBeVisible();
when.click("field-doc-button-Rotate", 0); await when.click("field-doc-button-Rotate", 0);
when.wait(200); await when.wait(200);
when.click("field-doc-button-Rotate", 0); await when.click("field-doc-button-Rotate", 0);
then(get.elementByTestId("spec-field-doc")).shouldNotBeVisible(); await then(get.elementByTestId("spec-field-doc")).shouldNotBeVisible();
}); });
}); });
test.describe("raster", () => { test.describe("raster", () => {
test("add", () => { test("add", async ({ driver }) => {
const id = when.modal.fillLayers({ const { get, when, then } = driver;
type: "raster", const id = await when.modal.fillLayers({ type: "raster", layer: "raster" });
layer: "raster", await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
}); layers: [{ id, type: "raster", source: "raster" }],
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "raster",
source: "raster",
},
],
}); });
}); });
}); });
test.describe("circle", () => { test.describe("circle", () => {
test("add", () => { test("add", async ({ driver }) => {
const id = when.modal.fillLayers({ const { get, when, then } = driver;
type: "circle", const id = await when.modal.fillLayers({ type: "circle", layer: "example" });
layer: "example", await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
}); layers: [{ id, type: "circle", source: "example" }],
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "circle",
source: "example",
},
],
}); });
}); });
}); });
test.describe("fill extrusion", () => { test.describe("fill extrusion", () => {
test("add", () => { test("add", async ({ driver }) => {
const id = when.modal.fillLayers({ const { get, when, then } = driver;
type: "fill-extrusion", const id = await when.modal.fillLayers({ type: "fill-extrusion", layer: "example" });
layer: "example", await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
}); layers: [{ id, type: "fill-extrusion", source: "example" }],
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "fill-extrusion",
source: "example",
},
],
}); });
}); });
}); });
test.describe("hillshade", () => { test.describe("hillshade", () => {
test("add", () => { test("add", async ({ driver }) => {
const id = when.modal.fillLayers({ const { get, when, then } = driver;
type: "hillshade", const id = await when.modal.fillLayers({ type: "hillshade", layer: "example" });
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: [ layers: [
{ {
id: id, id,
type: "hillshade", type: "hillshade",
source: "example", source: "example",
paint: { "hillshade-illumination-direction": [1, 2, 3, 4] },
}, },
], ],
}); });
}); });
test("set hillshade illumination direction array", () => { test("set hillshade highlight color array", async ({ driver }) => {
const id = when.modal.fillLayers({ const { get, when, then } = driver;
type: "hillshade", const id = await when.modal.fillLayers({ type: "hillshade", layer: "example" });
layer: "example", await when.collapseGroupInLayerEditor();
}); await when.setValueToPropertyArray("spec-field:hillshade-highlight-color", "blue");
when.collapseGroupInLayerEditor(); await when.addValueToPropertyArray("spec-field:hillshade-highlight-color", "#00ff00");
when.collapseGroupInLayerEditor(1); await when.addValueToPropertyArray("spec-field:hillshade-highlight-color", "rgba(255, 255, 0, 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");
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [
{ {
id: id, id,
type: "hillshade", type: "hillshade",
source: "example", source: "example",
paint: { paint: {
"hillshade-illumination-direction": [ 1, 2, 3, 4 ] "hillshade-highlight-color": ["blue", "#00ff00", "rgba(255, 255, 0, 1)"],
} },
},
],
});
});
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)" ]
}
}, },
], ],
}); });
@@ -413,129 +280,89 @@ test.describe("layers list", () => {
}); });
test.describe("color-relief", () => { test.describe("color-relief", () => {
test("add", () => { test("add", async ({ driver }) => {
const id = when.modal.fillLayers({ const { get, when, then } = driver;
type: "color-relief", const id = await when.modal.fillLayers({ type: "color-relief", layer: "example" });
layer: "example", await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
}); layers: [{ id, type: "color-relief", source: "example" }],
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "color-relief",
source: "example",
},
],
}); });
}); });
test("adds elevation expression when clicking the elevation button", () => { test("adds elevation expression when clicking the elevation button", async ({ driver }) => {
when.modal.fillLayers({ const { get, when, then } = driver;
type: "color-relief", await when.modal.fillLayers({ type: "color-relief", layer: "example" });
layer: "example", await when.collapseGroupInLayerEditor();
}); await when.click("make-elevation-function");
when.collapseGroupInLayerEditor(); await then(
when.click("make-elevation-function"); get.element("[data-wd-key='spec-field-container:color-relief-color'] .cm-line")
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")
).shouldBeVisible(); ).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.describe("drag and drop", () => {
test("move layer should update local storage", () => { test("move layer should update local storage", async ({ driver }) => {
when.modal.open(); const { get, when, then } = driver;
const firstId = when.modal.fillLayers({ await when.modal.open();
id: "a", const firstId = await when.modal.fillLayers({ id: "a", type: "background" });
type: "background", await when.modal.open();
}); const secondId = await when.modal.fillLayers({ id: "b", type: "background" });
when.modal.open(); await when.modal.open();
const secondId = when.modal.fillLayers({ const thirdId = await when.modal.fillLayers({ id: "c", type: "background" });
id: "b",
type: "background",
});
when.modal.open();
const thirdId = 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: [ layers: [
{ { id: secondId, type: "background" },
id: secondId, { id: thirdId, type: "background" },
type: "background", { id: firstId, type: "background" },
},
{
id: thirdId,
type: "background",
},
{
id: firstId,
type: "background",
},
], ],
}); });
}); });
}); });
test.describe("sticky header", () => { 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 // Setup: Create multiple layers to enable scrolling
for (let i = 0; i < 20; i++) { for (let i = 0; i < 20; i++) {
when.modal.open(); await when.modal.open();
when.modal.fillLayers({ await when.modal.fillLayers({ id: `layer-${i}`, type: "background" });
id: `layer-${i}`,
type: "background",
});
} }
when.wait(500); await when.wait(500);
const header = get.elementByTestId("layer-list.header"); 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) // Scroll the layer list container
get.elementByTestId("layer-list").scrollTo("bottom", { ensureScrollable: false }); await get.elementByTestId("layer-list").evaluate((el) => el.scrollTo(0, el.scrollHeight));
when.wait(200); await when.wait(200);
then(header).shouldBeVisible(); await then(header).shouldBeVisible();
then(get.elementByTestId("layer-list:add-layer")).shouldBeVisible(); await then(get.elementByTestId("layer-list:add-layer")).shouldBeVisible();
}); });
}); });
}); });
+42 -42
View File
@@ -1,67 +1,67 @@
import { MaputnikDriver } from "./maputnik-driver"; import { test, setupMaputnik } from "./fixtures";
test.describe("map", () => { test.describe("map", () => {
const { beforeAndAfter, get, when, then } = new MaputnikDriver(); setupMaputnik();
beforeAndAfter();
test.describe("zoom level", () => { test.describe("zoom level", () => {
test("via url", () => { test("via url", async ({ driver }) => {
const { get, when, then } = driver;
const zoomLevel = 12.37; const zoomLevel = 12.37;
when.setStyle("geojson", zoomLevel); await when.setStyle("geojson", zoomLevel);
then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible(); await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible();
then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText( await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText("Zoom: " + zoomLevel);
"Zoom: " + zoomLevel
);
}); });
test("via map controls", () => { test("via map controls", async ({ driver }) => {
const { get, when, then } = driver;
const zoomLevel = 12.37; const zoomLevel = 12.37;
when.setStyle("geojson", zoomLevel); await when.setStyle("geojson", zoomLevel);
then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible(); await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible();
when.clickZoomIn(); await when.clickZoomIn();
then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText( await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText("Zoom: " + (zoomLevel + 1));
"Zoom: " + (zoomLevel + 1)
);
}); });
test("via style file definition", () => { test("via style file definition", async ({ driver }) => {
when.setStyle("zoom_7_center_0_51"); const { get, when, then } = driver;
then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible(); await when.setStyle("zoom_7_center_0_51");
then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText( await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible();
"Zoom: " + (7) await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText("Zoom: " + 7);
); await then(get.locationHash()).shouldInclude("#7/51/0");
then(get.locationHash().should("contain", "#7/51/0"));
// opening another stylefile does not update the map view again // opening another stylefile does not update the map view again
// as discussed in https://github.com/maplibre/maputnik/issues/1546 // as discussed in https://github.com/maplibre/maputnik/issues/1546
when.openASecondStyleWithDifferentZoomAndCenter(); await when.openASecondStyleWithDifferentZoomAndCenter();
then(get.locationHash().should("contain", "#7/51/0")); await then(get.locationHash()).shouldInclude("#7/51/0");
}); });
}); });
test.describe("search", () => { test.describe("search", () => {
test("should exist", () => { test("should exist", async ({ driver }) => {
then(get.searchControl()).shouldBeVisible(); const { get, then } = driver;
await then(get.searchControl()).shouldBeVisible();
}); });
}); });
test.describe("popup", () => { test.describe("popup", () => {
beforeEach(() => { test.beforeEach(async ({ driver }) => {
when.setStyle("rectangles"); await driver.when.setStyle("rectangles");
then(get.locationHash().should("exist")); await driver.then(driver.get.locationHash()).shouldExist();
});
test("should open on feature click", () => {
when.clickCenter("maplibre:map");
then(get.elementByTestId("feature-layer-popup")).shouldBeVisible();
}); });
test("should open a second feature after closing popup", () => { test("should open on feature click", async ({ driver }) => {
when.clickCenter("maplibre:map"); const { get, when, then } = driver;
then(get.elementByTestId("feature-layer-popup")).shouldBeVisible(); await when.clickCenter("maplibre:map");
when.closePopup(); await then(get.elementByTestId("feature-layer-popup")).shouldBeVisible();
then(get.elementByTestId("feature-layer-popup")).shouldNotExist(); });
when.clickCenter("maplibre:map");
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();
}); });
}); });
}); });
+542 -223
View File
@@ -1,122 +1,307 @@
/// <reference types="cypress-plugin-tab" /> 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 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 isMac = process.platform === "darwin";
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;
};
export class MaputnikAssertable<T> extends Assertable<T> { function testIdSelector(testId: string): string {
shouldEqualToStoredStyle = () => return `[${DATA_ATTRIBUTE}="${testId}"]`;
then( }
new CypressHelper().get.window().then((win: Window) => {
const style = styleFromWindow(win); export function readFixture(name: string): any {
then(this.chainable).shouldDeepNestedInclude(style); 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<any> {
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> | void, timeout = 10000, interval = 100): Promise<void> {
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<T> {
readonly __maputnikQuery = true as const;
constructor(private readonly getter: () => Promise<T>) {}
get(): Promise<T> {
return this.getter();
}
then<U>(mapper: (value: T) => U | Promise<U>): Query<U> {
return new Query<U>(async () => mapper(await this.getter()));
}
}
function isQuery(target: unknown): target is Query<unknown> {
return typeof target === "object" && target !== null && (target as Query<unknown>).__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<string, unknown>): void {
for (const key of Object.keys(expected)) {
expect(actual?.[key], `property "${key}"`).toEqual(expected[key]);
}
}
export class MaputnikAssertable<T> {
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<void> {
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<string, unknown>) =>
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<void> {
const tokens = text.match(/\{[^}]+\}|[^{]+/g) ?? [];
const modifierMap: Record<string, string> = { meta: "Meta", ctrl: "Control", shift: "Shift", alt: "Alt" };
const namedKeys: Record<string, string> = {
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 { export class MaputnikDriver {
private helper = new MaputnikCypressHelper(); private scope: Locator | null = null;
private modalDriver = new ModalDriver(); private readonly recordedRequests = new Map<string, Request[]>();
private readonly coverageChunks: unknown[] = [];
private readonly modalDriver = new ModalDriver(this);
public beforeAndAfter = () => { constructor(private readonly page: Page) {
beforeEach(() => { // Accept confirm dialogs (e.g. the "replace current style" prompt shown when
this.given.setupMockBackedResponses(); // a style is loaded via the URL). Playwright dismisses dialogs by default.
this.when.setStyle("both"); page.on("dialog", (dialog) => dialog.accept().catch(() => undefined));
}
// ---- Coverage collection -------------------------------------------------
/** Snapshots the current coverage before the page is unloaded. */
private async captureCoverage(): Promise<void> {
const coverage = await readCoverage(this.page);
if (coverage) this.coverageChunks.push(coverage);
}
async flushCoverage(testInfo: TestInfo): Promise<void> {
await this.captureCoverage();
this.coverageChunks.forEach((chunk, index) => {
writeCoverage(chunk, `${testInfo.testId}-${index}`);
}); });
}; }
public then = (chainable: Cypress.Chainable<any>) => // ---- Element access ------------------------------------------------------
new MaputnikAssertable(chainable);
private root(): Page | Locator {
return this.scope ?? this.page;
}
private testId(testId: string): Locator {
return this.root().locator(testIdSelector(testId));
}
then = <T>(target: T) => new MaputnikAssertable(target, this.page);
// ---- given ---------------------------------------------------------------
public given = { public given = {
...this.helper.given, fixture: (_name: string, _alias?: string) => {
setupMockBackedResponses: () => { // Fixtures are read directly from disk in Playwright, no registration needed.
this.helper.given.interceptAndMockResponse({ },
method: "GET",
url: baseUrl + "example-style.json", intercept: async (url: string, alias: string, _method = "GET") => {
response: { this.recordedRequests.set(alias, []);
fixture: "example-style.json", // Convert the Cypress-style glob (which may contain "?" in a query string)
}, // into a regex so query parameters match reliably.
alias: "example-style.json", 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", interceptAndMockResponse: async (options: {
response: { method?: string;
fixture: "example-layer-style.json", 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", setupMockBackedResponses: async () => {
response: { const styleFixtures = [
fixture: "geojson-style.json", "example-style.json",
}, "example-layer-style.json",
}); "geojson-style.json",
this.helper.given.interceptAndMockResponse({ "raster-style.json",
method: "GET", "geojson-raster-style.json",
url: baseUrl + "raster-style.json", "rectangles-style.json",
response: { "example-style-with-fonts.json",
fixture: "raster-style.json", "example-style-with-zoom-7-and-center-0-51.json",
}, "example-style-with-zoom-5-and-center-50-50.json",
}); ];
this.helper.given.interceptAndMockResponse({ for (const fixture of styleFixtures) {
method: "GET", await this.given.interceptAndMockResponse({
url: baseUrl + "geojson-raster-style.json", method: "GET",
response: { url: baseUrl + fixture,
fixture: "geojson-raster-style.json", response: { fixture },
}, alias: fixture === "example-style.json" ? "example-style.json" : undefined,
}); });
this.helper.given.interceptAndMockResponse({ }
method: "GET", await this.given.interceptAndMockResponse({ method: "GET", url: /example\.local\//, response: [] });
url: baseUrl + "rectangles-style.json", await this.given.interceptAndMockResponse({ method: "GET", url: /example\.com\//, response: [] });
response: { await this.given.interceptAndMockResponse({
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({
method: "GET", method: "GET",
url: "https://www.glyph-server.com/*", url: "https://www.glyph-server.com/*",
response: ["Font 1", "Font 2", "Font 3"], response: ["Font 1", "Font 2", "Font 3"],
@@ -124,149 +309,283 @@ export class MaputnikDriver {
}, },
}; };
// ---- when ----------------------------------------------------------------
public when = { public when = {
...this.helper.when,
modal: this.modalDriver.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: () => { wait: (ms: number) => this.page.waitForTimeout(ms),
this.helper.when.waitForResponse("example-style.json");
tab: () => this.page.keyboard.press("Tab"),
typeKeys: (keys: string) => typeSequence(this.page, keys),
doWithin: async (selector: string, fn: () => Promise<void> | 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(); click: async (testId: string, index = 0) => {
cy.get('[data-wd-key="modal:open.url.input"]') // Documentation buttons are wrapped in a <label>/.maputnik-doc-target that
.should("be.enabled") // Playwright treats as intercepting the click; bypass the check for them.
.clear() const force = testId.startsWith("field-doc-button-");
.type("http://localhost:8888/example-style-with-zoom-5-and-center-50-50.json{enter}"); await this.testId(testId).nth(index).click({ force });
}, },
chooseExampleFile: () => {
this.helper.given.fixture("example-style.json", "example-style.json"); realClick: async (testId: string) => {
this.helper.when.openFileByFixture("example-style.json", "modal:open.dropzone", "modal:open.file.input"); await this.testId(testId).click();
this.helper.when.wait(200);
}, },
dropExampleFile: () => {
this.helper.given.fixture("example-style.json", "example-style.json"); hover: async (testId: string) => {
this.helper.when.dropFileByFixture("example-style.json", "modal:open.dropzone"); await this.testId(testId).hover();
this.helper.when.wait(200);
}, },
setStyle: (
styleProperties: "geojson" | "raster" | "both" | "layer" | "rectangles" | "font" | "zoom_7_center_0_51" | "", focus: async (testId: string) => {
await this.testId(testId).focus();
},
clear: async (testId: string) => {
await this.testId(testId).clear();
},
select: async (testId: string, value: string) => {
await this.testId(testId).selectOption(value);
},
selectWithin: async (selector: string, value: string) => {
await this.root().locator(testIdSelector(selector)).locator("select").selectOption(value);
},
setValue: async (testId: string, text: string) => {
const input = this.testId(testId);
await input.fill("");
await input.fill(text);
},
type: async (testId: string, text: string) => {
await this.testId(testId).focus();
// Place the caret at the start of the field (matching how the original
// Cypress suite typed), so a leading "{backspace}" is a no-op rather than
// clearing an already-committed value.
await this.page.keyboard.press("Home");
await typeSequence(this.page, text);
},
setValueToPropertyArray: async (selector: string, value: string) => {
await this.when.doWithin(selector, async () => {
const input = this.root().locator(".maputnik-array-block-content input").last();
await input.focus();
await typeSequence(this.page, "{selectall}" + value);
});
},
addValueToPropertyArray: async (selector: string, value: string) => {
await this.when.doWithin(selector, async () => {
await this.root().locator(".maputnik-array-add-value").click();
const input = this.root().locator(".maputnik-array-block-content input").last();
await input.focus();
await typeSequence(this.page, "{selectall}" + value);
});
},
setStyle: async (
styleProperties:
| "geojson"
| "raster"
| "both"
| "layer"
| "rectangles"
| "font"
| "zoom_7_center_0_51"
| "",
zoom?: number zoom?: number
) => { ) => {
const url = new URL(baseUrl); const styleFileByKey: Record<string, string> = {
switch (styleProperties) { geojson: "geojson-style.json",
case "geojson": raster: "raster-style.json",
url.searchParams.set("style", baseUrl + "geojson-style.json"); both: "geojson-raster-style.json",
break; layer: "example-layer-style.json",
case "raster": rectangles: "rectangles-style.json",
url.searchParams.set("style", baseUrl + "raster-style.json"); font: "example-style-with-fonts.json",
break; zoom_7_center_0_51: "example-style-with-zoom-7-and-center-0-51.json",
case "both": };
url.searchParams.set("style", baseUrl + "geojson-raster-style.json");
break;
case "layer":
url.searchParams.set("style", baseUrl + "example-layer-style.json");
break;
case "rectangles":
url.searchParams.set("style", baseUrl + "rectangles-style.json");
break;
case "font":
url.searchParams.set("style", baseUrl + "example-style-with-fonts.json");
break;
case "zoom_7_center_0_51":
url.searchParams.set("style", baseUrl + "example-style-with-zoom-7-and-center-0-51.json");
break;
}
const url = new URL(baseUrl);
if (styleProperties && styleFileByKey[styleProperties]) {
url.searchParams.set("style", baseUrl + styleFileByKey[styleProperties]);
}
if (zoom) { if (zoom) {
url.hash = `${zoom}/41.3805/2.1635`; url.hash = `${zoom}/41.3805/2.1635`;
} }
this.helper.when.visit(url.toString());
if (styleProperties) { await this.when.visit(url.toString());
this.helper.when.acceptConfirm();
} const toolbarLink = this.testId("toolbar:link");
// when methods should not include assertions await toolbarLink.scrollIntoViewIfNeeded();
const toolbarLink = this.helper.get.elementByTestId("toolbar:link"); await expect(toolbarLink).toBeVisible();
toolbarLink.scrollIntoView();
toolbarLink.should("be.visible");
}, },
typeKeys: (keys: string) => this.helper.get.element("body").type(keys), openASecondStyleWithDifferentZoomAndCenter: async () => {
await this.page.getByRole("button", { name: "Open" }).click();
clickZoomIn: () => { const input = this.testId("modal:open.url.input");
this.helper.get.element(".maplibregl-ctrl-zoom-in").click(); await expect(input).toBeEnabled();
await input.fill("http://localhost:8888/example-style-with-zoom-5-and-center-50-50.json");
await input.press("Enter");
}, },
selectWithin: (selector: string, value: string) => { chooseExampleFile: async () => {
this.when.doWithin(selector, () => { await this.openFileByFixture("example-style.json", "modal:open.dropzone", "modal:open.file.input");
this.helper.get.element("select").select(value); await this.when.wait(200);
},
dropExampleFile: async () => {
await this.dropFileByFixture("example-style.json", "modal:open.dropzone");
await this.when.wait(200);
},
clickZoomIn: async () => {
await this.page.locator(".maplibregl-ctrl-zoom-in").click();
},
closePopup: async () => {
await this.page.locator(".maplibregl-popup-close-button").click();
},
clickCenter: async (testId: string) => {
const { x, y } = await centerOf(this.testId(testId));
await this.page.mouse.move(x, y);
await this.page.mouse.down();
await this.when.wait(200);
await this.page.mouse.up();
},
collapseGroupInLayerEditor: async (index = 0) => {
await this.page.locator(".maputnik-layer-editor-group__button").nth(index).click();
},
appendTextInJsonEditor: async (text: string) => {
await this.page.locator(".cm-line").first().click();
// Move to the very start of the document so the inserted text breaks the
// root JSON structure (CodeMirror auto-closes brackets otherwise).
await this.page.keyboard.press("Home");
await typeSequence(this.page, text);
},
setTextInJsonEditor: async (text: string) => {
const firstLine = this.page.locator(".cm-line").first();
await firstLine.click();
await this.page.keyboard.press(isMac ? "Meta+a" : "Control+a");
await this.page.keyboard.type(text);
},
dragAndDropWithWait: async (source: string, target: string) => {
const from = await centerOf(this.testId(source));
const to = await centerOf(this.testId(target));
await this.page.mouse.move(from.x, from.y);
await this.page.mouse.down();
await this.page.mouse.move(from.x, from.y + 10);
await this.page.mouse.move(to.x, to.y, { steps: 10 });
await this.when.wait(100);
await this.page.mouse.up();
},
waitForResponse: async (alias: string) => {
const requests = this.recordedRequests.get(alias);
if (!requests) throw new Error(`No intercept registered for alias "${alias}"`);
await retry(async () => {
if (requests.length === 0) throw new Error(`No request recorded for alias "${alias}"`);
}); });
return requests[requests.length - 1];
}, },
select: (selector: string, value: string) => { waitForExampleFileResponse: () => this.when.waitForResponse("example-style.json"),
this.helper.get.elementByTestId(selector).select(value);
},
focus: (selector: string) => { clearLocalStorage: () => this.page.evaluate(() => window.localStorage.clear()),
this.helper.when.focus(selector);
},
setValue: (selector: string, text: string) => {
this.helper.get
.elementByTestId(selector)
.clear()
.type(text, { parseSpecialCharSequences: false });
},
setValueToPropertyArray: (selector: string, value: string) => {
this.when.doWithin(selector, () => {
this.helper.get.element(".maputnik-array-block-content input").last().type("{selectall}"+value, {force: true });
});
},
addValueToPropertyArray: (selector: string, value: string) => {
this.when.doWithin(selector, () => {
this.helper.get.element(".maputnik-array-add-value").click({ force: true });
this.helper.get.element(".maputnik-array-block-content input").last().type("{selectall}"+value, {force: true });
});
},
closePopup: () => {
this.helper.get.element(".maplibregl-popup-close-button").click();
},
collapseGroupInLayerEditor: (index = 0) => {
this.helper.get.element(".maputnik-layer-editor-group__button").eq(index).realClick();
},
appendTextInJsonEditor: (text: string) => {
this.helper.get.element(".cm-line").first().click().type(text, { parseSpecialCharSequences: false });
},
setTextInJsonEditor: (text: string) => {
this.helper.get.element(".cm-line").first().click().clear().type(text, { parseSpecialCharSequences: false });
}
}; };
// ---- get -----------------------------------------------------------------
public get = { public get = {
...this.helper.get, isMac: () => isMac,
isMac: () => {
return Cypress.platform === "darwin"; element: (selector: string) => this.root().locator(selector),
elementByTestId: (testId: string) => this.testId(testId),
elementByText: (text: string) => this.root().getByText(text),
elementByAttribute: (attribute: string, value: string) =>
this.root().locator(`[${attribute}="${value}"]`),
canvas: () => this.page.locator("canvas"),
searchControl: () => this.page.locator(".maplibregl-ctrl-geocoder"),
skipTargetLayerList: () => this.testId("skip-target-layer-list"),
skipTargetLayerEditor: () => this.testId("skip-target-layer-editor"),
inputValue: (testId: string) => new Query<string>(() => this.testId(testId).first().inputValue()),
elementsText: (testId: string) => new Query<string>(() => this.testId(testId).first().innerText()),
locationHash: () => new Query<string>(async () => new URL(this.page.url()).hash),
styleFromLocalStorage: () => new Query<any>(() => styleFromLocalStorage(this.page)),
fixture: (name: string) => Promise.resolve(readFixture(name)),
responseBody: (alias: string) => {
// Our mocked style responses always return the matching fixture.
const name = alias.endsWith(".json") ? alias : `${alias}.json`;
return Promise.resolve(readFixture(name));
}, },
styleFromLocalStorage: () => exampleFileUrl: () => baseUrl + "example-style.json",
this.helper.get.window().then((win) => styleFromWindow(win)),
exampleFileUrl: () => {
return baseUrl + "example-style.json";
},
skipTargetLayerList: () =>
this.helper.get.elementByTestId("skip-target-layer-list"),
skipTargetLayerEditor: () =>
this.helper.get.elementByTestId("skip-target-layer-editor"),
canvas: () => this.helper.get.element("canvas"),
searchControl: () => this.helper.get.element(".maplibregl-ctrl-geocoder")
}; };
// ---- file open helpers ---------------------------------------------------
private async openFileByFixture(fixture: string, buttonTestId: string, inputTestId: string): Promise<void> {
const content = JSON.stringify(readFixture(fixture));
const hasPicker = await this.page.evaluate(() => "showOpenFilePicker" in window);
if (hasPicker) {
await this.page.evaluate((fileContent) => {
(window as any).showOpenFilePicker = async () => [
{ getFile: async () => ({ text: async () => fileContent }) },
];
}, content);
await this.testId(buttonTestId).click();
} else {
await this.testId(inputTestId).setInputFiles({
name: fixture,
mimeType: "application/json",
buffer: Buffer.from(content),
});
}
}
private async dropFileByFixture(fixture: string, dropzoneTestId: string): Promise<void> {
const content = JSON.stringify(readFixture(fixture));
const dataTransfer = await this.page.evaluateHandle((fileContent) => {
const dt = new DataTransfer();
dt.items.add(new File([fileContent], "example-style.json", { type: "application/json" }));
return dt;
}, content);
const dropzone = this.testId(dropzoneTestId);
await dropzone.dispatchEvent("dragenter", { dataTransfer });
await dropzone.dispatchEvent("dragover", { dataTransfer });
await dropzone.dispatchEvent("drop", { dataTransfer });
}
} }
+30 -25
View File
@@ -1,40 +1,45 @@
import { v1 as uuid } from "uuid"; import { v1 as uuid } from "uuid";
import MaputnikCypressHelper from "./maputnik-cypress-helper"; import { expect } from "@playwright/test";
import type { MaputnikDriver } from "./maputnik-driver";
export default class ModalDriver { export class ModalDriver {
private helper = new MaputnikCypressHelper(); constructor(private readonly driver: MaputnikDriver) {}
public when = { public when = {
fillLayers: (opts: { type: string; layer?: string; id?: string }) => { fillLayers: async (opts: { type: string; layer?: string; id?: string }) => {
// Having logic in test code is an anti pattern. const { when, get } = this.driver;
// This should be split to multiple single responsibility functions const id = opts.id ?? `${opts.type}:${uuid()}`;
const type = opts.type;
const layer = opts.layer;
let id;
if (opts.id) {
id = opts.id;
} else {
id = `${type}:${uuid()}`;
}
this.helper.when.selectOption("add-layer.layer-type.select", type);
this.helper.when.type("add-layer.layer-id.input", id);
if (layer) { await when.select("add-layer.layer-type.select", opts.type);
this.helper.when.doWithin(() => { await when.type("add-layer.layer-id.input", id);
this.helper.get.element("input").clear().type(layer!);
}, "add-layer.layer-source-block"); if (opts.layer) {
await when.doWithin("add-layer.layer-source-block", async () => {
const input = get.element("input");
await input.click();
await input.fill(opts.layer!);
// The source input is a controlled downshift combobox; wait for React
// to settle on the typed value before submitting.
await expect(input).toHaveValue(opts.layer!);
});
// Close the autocomplete menu so it does not intercept the add button.
await get.elementByTestId("add-layer.layer-id.input").click();
} }
this.helper.when.click("add-layer"); await when.click("add-layer");
return id; return id;
}, },
open: () => { open: async () => {
this.helper.when.click("layer-list:add-layer"); // No-op when the add-layer modal is already open (some specs call open()
// both in a beforeEach and at the start of the test body).
const modal = this.driver.get.elementByTestId("modal:add-layer").first();
if (await modal.isVisible()) return;
await this.driver.when.click("layer-list:add-layer");
}, },
close: (key: string) => { close: async (key: string) => {
this.helper.when.click(key + ".close-modal"); await this.driver.when.click(key + ".close-modal");
}, },
}; };
} }
+252 -252
View File
@@ -1,102 +1,111 @@
import { MaputnikDriver } from "./maputnik-driver"; import { test, expect, setupMaputnik } from "./fixtures";
import tokens from "../../src/config/tokens.json" with {type: "json"}; import tokens from "../src/config/tokens.json" with { type: "json" };
test.describe("modals", () => { test.describe("modals", () => {
const { beforeAndAfter, when, get, given, then } = new MaputnikDriver(); setupMaputnik();
beforeAndAfter();
beforeEach(() => { test.beforeEach(async ({ driver }) => {
when.setStyle(""); await driver.when.setStyle("");
}); });
test.describe("open", () => { test.describe("open", () => {
beforeEach(() => { test.beforeEach(async ({ driver }) => {
when.click("nav:open"); await driver.when.click("nav:open");
}); });
test("close", () => { test("close", async ({ driver }) => {
when.modal.close("modal:open"); const { get, when, then } = driver;
then(get.elementByTestId("modal:open")).shouldNotExist(); await when.modal.close("modal:open");
await then(get.elementByTestId("modal:open")).shouldNotExist();
}); });
test("upload", () => { test("upload", async ({ driver }) => {
when.chooseExampleFile(); const { get, when, then } = driver;
then(get.fixture("example-style.json")).shouldEqualToStoredStyle(); await when.chooseExampleFile();
await then(get.fixture("example-style.json")).shouldEqualToStoredStyle();
}); });
test("upload via drag and drop", () => { test("upload via drag and drop", async ({ driver }) => {
when.dropExampleFile(); const { get, when, then } = driver;
then(get.fixture("example-style.json")).shouldEqualToStoredStyle(); await when.dropExampleFile();
await then(get.fixture("example-style.json")).shouldEqualToStoredStyle();
}); });
test.describe("when click open url", () => { test.describe("when click open url", () => {
beforeEach(() => { test.beforeEach(async ({ driver }) => {
const { get, when } = driver;
const styleFileUrl = get.exampleFileUrl(); const styleFileUrl = get.exampleFileUrl();
when.setValue("modal:open.url.input", styleFileUrl); await when.setValue("modal:open.url.input", styleFileUrl);
when.click("modal:open.url.button"); await when.click("modal:open.url.button");
when.wait(200); await when.wait(200);
}); });
test("load from url", () => { test("load from url", async ({ driver }) => {
then(get.responseBody("example-style.json")).shouldEqualToStoredStyle(); await driver.then(driver.get.responseBody("example-style.json")).shouldEqualToStoredStyle();
}); });
}); });
}); });
test.describe("shortcuts", () => { test.describe("shortcuts", () => {
test("open/close", () => { test("open/close", async ({ driver }) => {
when.setStyle(""); const { get, when, then } = driver;
when.typeKeys("?"); await when.setStyle("");
when.modal.close("modal:shortcuts"); await when.typeKeys("?");
then(get.elementByTestId("modal:shortcuts")).shouldNotExist(); await when.modal.close("modal:shortcuts");
await then(get.elementByTestId("modal:shortcuts")).shouldNotExist();
}); });
}); });
test.describe("export", () => { test.describe("export", () => {
beforeEach(() => { test.beforeEach(async ({ driver }) => {
when.click("nav:export"); await driver.when.click("nav:export");
}); });
test("close", () => { test("close", async ({ driver }) => {
when.modal.close("modal:export"); const { get, when, then } = driver;
then(get.elementByTestId("modal:export")).shouldNotExist(); await when.modal.close("modal:export");
await then(get.elementByTestId("modal:export")).shouldNotExist();
}); });
// TODO: Work out how to download a file and check the contents // TODO: Work out how to download a file and check the contents
test("download"); test.skip("download", () => {});
}); });
test.describe("sources", () => { test.describe("sources", () => {
beforeEach(() => { test.beforeEach(async ({ driver }) => {
when.setStyle("layer"); await driver.when.setStyle("layer");
when.click("nav:sources"); await driver.when.click("nav:sources");
}); });
test("active sources"); test.skip("active sources", () => {});
test("public source"); test.skip("public source", () => {});
test("add new source", () => { test("add new source", async ({ driver }) => {
const { get, when, then } = driver;
const sourceId = "n1z2v3r"; const sourceId = "n1z2v3r";
when.setValue("modal:sources.add.source_id", sourceId); await when.setValue("modal:sources.add.source_id", sourceId);
when.select("modal:sources.add.source_type", "tile_vector"); await when.select("modal:sources.add.source_type", "tile_vector");
when.select("modal:sources.add.scheme_type", "tms"); await when.select("modal:sources.add.scheme_type", "tms");
when.click("modal:sources.add.add_source"); await when.click("modal:sources.add.add_source");
when.wait(200); await when.wait(200);
then( await then(get.styleFromLocalStorage().then((style) => style.sources[sourceId])).shouldInclude({
get.styleFromLocalStorage().then((style) => style.sources[sourceId])
).shouldInclude({
scheme: "tms", scheme: "tms",
}); });
}); });
test("add new pmtiles source", () => { test("add new pmtiles source", async ({ driver }) => {
const { get, when, then } = driver;
const sourceId = "pmtilestest"; const sourceId = "pmtilestest";
when.setValue("modal:sources.add.source_id", sourceId); await when.setValue("modal:sources.add.source_id", sourceId);
when.select("modal:sources.add.source_type", "pmtiles_vector"); await when.select("modal:sources.add.source_type", "pmtiles_vector");
when.setValue("modal:sources.add.source_url", "https://data.source.coop/protomaps/openstreetmap/v4.pmtiles"); await when.setValue(
when.click("modal:sources.add.add_source"); "modal:sources.add.source_url",
when.click("modal:sources.add.add_source"); "https://data.source.coop/protomaps/openstreetmap/v4.pmtiles"
when.wait(200); );
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await when.click("modal:sources.add.add_source");
await when.click("modal:sources.add.add_source");
await when.wait(200);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: { sources: {
pmtilestest: { pmtilestest: {
type: "vector", type: "vector",
@@ -106,338 +115,329 @@ test.describe("modals", () => {
}); });
}); });
test("add new raster source", () => { test("add new raster source", async ({ driver }) => {
const { get, when, then } = driver;
const sourceId = "rastertest"; const sourceId = "rastertest";
when.setValue("modal:sources.add.source_id", sourceId); await when.setValue("modal:sources.add.source_id", sourceId);
when.select("modal:sources.add.source_type", "tile_raster"); await when.select("modal:sources.add.source_type", "tile_raster");
when.select("modal:sources.add.scheme_type", "xyz"); await when.select("modal:sources.add.scheme_type", "xyz");
when.setValue("modal:sources.add.tile_size", "128"); await when.setValue("modal:sources.add.tile_size", "128");
when.click("modal:sources.add.add_source"); await when.click("modal:sources.add.add_source");
when.wait(200); await when.wait(200);
then( await then(get.styleFromLocalStorage().then((style) => style.sources[sourceId])).shouldInclude({
get.styleFromLocalStorage().then((style) => style.sources[sourceId])
).shouldInclude({
tileSize: 128, tileSize: 128,
}); });
}); });
}); });
test.describe("inspect", () => { test.describe("inspect", () => {
test("toggle", () => { test("toggle", async ({ driver }) => {
// There is no assertion in this test // There is no assertion in this test
when.setStyle("geojson"); await driver.when.setStyle("geojson");
when.select("maputnik-select", "inspect"); await driver.when.select("maputnik-select", "inspect");
}); });
}); });
test.describe("style settings", () => { test.describe("style settings", () => {
beforeEach(() => { test.beforeEach(async ({ driver }) => {
when.click("nav:settings"); await driver.when.click("nav:settings");
}); });
test.describe("when click name filed spec information", () => { test.describe("when click name filed spec information", () => {
beforeEach(() => { test.beforeEach(async ({ driver }) => {
when.click("field-doc-button-Name"); await driver.when.click("field-doc-button-Name");
}); });
test("should show the spec information", () => { test("should show the spec information", async ({ driver }) => {
then(get.elementsText("spec-field-doc")).shouldInclude( await driver.then(driver.get.elementsText("spec-field-doc")).shouldInclude("name for the style");
"name for the style"
);
}); });
}); });
test.describe("when set name and click owner", () => { test.describe("when set name and click owner", () => {
beforeEach(() => { test.beforeEach(async ({ driver }) => {
when.setValue("modal:settings.name", "foobar"); const { when } = driver;
when.click("modal:settings.owner"); await when.setValue("modal:settings.name", "foobar");
when.wait(200); await when.click("modal:settings.owner");
await when.wait(200);
}); });
test("show name specifications", () => { test("show name specifications", async ({ driver }) => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await driver.then(driver.get.styleFromLocalStorage()).shouldDeepNestedInclude({
name: "foobar", name: "foobar",
}); });
}); });
}); });
test.describe("when set owner and click name", () => { test.describe("when set owner and click name", () => {
beforeEach(() => { test.beforeEach(async ({ driver }) => {
when.setValue("modal:settings.owner", "foobar"); const { when } = driver;
when.click("modal:settings.name"); await when.setValue("modal:settings.owner", "foobar");
when.wait(200); await when.click("modal:settings.name");
await when.wait(200);
}); });
test("should update owner in local storage", () => { test("should update owner in local storage", async ({ driver }) => {
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await driver.then(driver.get.styleFromLocalStorage()).shouldDeepNestedInclude({
owner: "foobar", owner: "foobar",
}); });
}); });
}); });
test("sprite url", () => { test("sprite url", async ({ driver }) => {
when.setTextInJsonEditor("\"http://example.com\""); const { get, when, then } = driver;
when.click("modal:settings.name"); await when.setTextInJsonEditor('"http://example.com"');
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await when.click("modal:settings.name");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sprite: "http://example.com", sprite: "http://example.com",
}); });
}); });
test("sprite object", () => { test("sprite object", async ({ driver }) => {
when.setTextInJsonEditor(JSON.stringify([{ id: "1", url: "2" }])); const { get, when, then } = driver;
await when.setTextInJsonEditor(JSON.stringify([{ id: "1", url: "2" }]));
when.click("modal:settings.name"); await when.click("modal:settings.name");
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sprite: [{ id: "1", url: "2" }], sprite: [{ id: "1", url: "2" }],
}); });
}); });
test("glyphs url", () => { test("glyphs url", async ({ driver }) => {
const { get, when, then } = driver;
const glyphsUrl = "http://example.com/{fontstack}/{range}.pbf"; const glyphsUrl = "http://example.com/{fontstack}/{range}.pbf";
when.setValue("modal:settings.glyphs", glyphsUrl); await when.setValue("modal:settings.glyphs", glyphsUrl);
when.click("modal:settings.name"); await when.click("modal:settings.name");
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
glyphs: glyphsUrl, glyphs: glyphsUrl,
}); });
}); });
test("maptiler access token", () => { test("maptiler access token", async ({ driver }) => {
const { get, when, then } = driver;
const apiKey = "testing123"; const apiKey = "testing123";
when.setValue( await when.setValue("modal:settings.maputnik:openmaptiles_access_token", apiKey);
"modal:settings.maputnik:openmaptiles_access_token", await when.click("modal:settings.name");
apiKey await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({
);
when.click("modal:settings.name");
then(
get.styleFromLocalStorage().then((style) => style.metadata)
).shouldInclude({
"maputnik:openmaptiles_access_token": apiKey, "maputnik:openmaptiles_access_token": apiKey,
}); });
}); });
test("thunderforest access token", () => { test("thunderforest access token", async ({ driver }) => {
const { get, when, then } = driver;
const apiKey = "testing123"; const apiKey = "testing123";
when.setValue( await when.setValue("modal:settings.maputnik:thunderforest_access_token", apiKey);
"modal:settings.maputnik:thunderforest_access_token", await when.click("modal:settings.name");
apiKey await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({
); "maputnik:thunderforest_access_token": apiKey,
when.click("modal:settings.name"); });
then(
get.styleFromLocalStorage().then((style) => style.metadata)
).shouldInclude({ "maputnik:thunderforest_access_token": apiKey });
}); });
test("stadia access token", () => { test("stadia access token", async ({ driver }) => {
const { get, when, then } = driver;
const apiKey = "testing123"; const apiKey = "testing123";
when.setValue( await when.setValue("modal:settings.maputnik:stadia_access_token", apiKey);
"modal:settings.maputnik:stadia_access_token", await when.click("modal:settings.name");
apiKey await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({
); "maputnik:stadia_access_token": apiKey,
when.click("modal:settings.name"); });
then(
get.styleFromLocalStorage().then((style) => style.metadata)
).shouldInclude({ "maputnik:stadia_access_token": apiKey });
}); });
test("locationiq access token", () => { test("locationiq access token", async ({ driver }) => {
const { get, when, then } = driver;
const apiKey = "testing123"; const apiKey = "testing123";
when.setValue( await when.setValue("modal:settings.maputnik:locationiq_access_token", apiKey);
"modal:settings.maputnik:locationiq_access_token", await when.click("modal:settings.name");
apiKey await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({
); "maputnik:locationiq_access_token": apiKey,
when.click("modal:settings.name"); });
then(
get.styleFromLocalStorage().then((style) => style.metadata)
).shouldInclude({ "maputnik:locationiq_access_token": apiKey });
}); });
test("style projection mercator", () => { test("style projection mercator", async ({ driver }) => {
when.select("modal:settings.projection", "mercator"); const { get, when, then } = driver;
then( await when.select("modal:settings.projection", "mercator");
get.styleFromLocalStorage().then((style) => style.projection) await then(get.styleFromLocalStorage().then((style) => style.projection)).shouldInclude({
).shouldInclude({ type: "mercator" }); type: "mercator",
});
}); });
test("style projection globe", () => { test("style projection globe", async ({ driver }) => {
when.select("modal:settings.projection", "globe"); const { get, when, then } = driver;
then( await when.select("modal:settings.projection", "globe");
get.styleFromLocalStorage().then((style) => style.projection) await then(get.styleFromLocalStorage().then((style) => style.projection)).shouldInclude({
).shouldInclude({ type: "globe" }); type: "globe",
});
}); });
test("style projection vertical-perspective", async ({ driver }) => {
test("style projection vertical-perspective", () => { const { get, when, then } = driver;
when.select("modal:settings.projection", "vertical-perspective"); await when.select("modal:settings.projection", "vertical-perspective");
then( await then(get.styleFromLocalStorage().then((style) => style.projection)).shouldInclude({
get.styleFromLocalStorage().then((style) => style.projection) type: "vertical-perspective",
).shouldInclude({ type: "vertical-perspective" }); });
}); });
test("style renderer", () => { test("style renderer", async ({ driver }) => {
cy.on("uncaught:exception", () => false); // this is due to the fact that this is an invalid style for openlayers const { get, when, then } = driver;
when.select("modal:settings.maputnik:renderer", "ol"); await when.select("modal:settings.maputnik:renderer", "ol");
then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual( await then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual("ol");
"ol"
);
when.click("modal:settings.name"); await when.click("modal:settings.name");
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
metadata: { "maputnik:renderer": "ol" }, metadata: { "maputnik:renderer": "ol" },
}); });
}); });
test("include API key when change renderer", async ({ driver }) => {
const { get, when, given } = driver;
await when.click("modal:settings.close-modal");
await when.click("nav:open");
test("include API key when change renderer", () => { await get.elementByAttribute("aria-label", "MapTiler Basic").click();
await when.wait(1000);
await when.click("nav:settings");
when.click("modal:settings.close-modal"); await when.select("modal:settings.maputnik:renderer", "mlgljs");
when.click("nav:open"); await driver.then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual("mlgljs");
get.elementByAttribute("aria-label", "MapTiler Basic").should("exist").click(); await when.select("modal:settings.maputnik:renderer", "ol");
when.wait(1000); await driver.then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual("ol");
when.click("nav:settings");
when.select("modal:settings.maputnik:renderer", "mlgljs"); await given.intercept(
then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual( "https://api.maptiler.com/tiles/v3-openmaptiles/tiles.json?key=*",
"mlgljs" "tileRequest",
"GET"
); );
when.select("modal:settings.maputnik:renderer", "ol"); await when.select("modal:settings.maputnik:renderer", "mlgljs");
then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual( await driver.then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual("mlgljs");
"ol"
const request = await when.waitForResponse("tileRequest");
expect(request.url()).toContain(
`https://api.maptiler.com/tiles/v3-openmaptiles/tiles.json?key=${tokens.openmaptiles}`
); );
given.intercept("https://api.maptiler.com/tiles/v3-openmaptiles/tiles.json?key=*", "tileRequest", "GET");
when.select("modal:settings.maputnik:renderer", "mlgljs");
then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual(
"mlgljs"
);
when.waitForResponse("tileRequest").its("request").its("url").should("include", `https://api.maptiler.com/tiles/v3-openmaptiles/tiles.json?key=${tokens.openmaptiles}`);
when.waitForResponse("tileRequest").its("request").its("url").should("include", `https://api.maptiler.com/tiles/v3-openmaptiles/tiles.json?key=${tokens.openmaptiles}`);
when.waitForResponse("tileRequest").its("request").its("url").should("include", `https://api.maptiler.com/tiles/v3-openmaptiles/tiles.json?key=${tokens.openmaptiles}`);
}); });
}); });
test.describe("add layer", () => { test.describe("add layer", () => {
beforeEach(() => { test.beforeEach(async ({ driver }) => {
when.setStyle("layer"); await driver.when.setStyle("layer");
when.modal.open(); await driver.when.modal.open();
}); });
test("shows duplicate id error", () => { test("shows duplicate id error", async ({ driver }) => {
when.setValue("add-layer.layer-id.input", "background"); const { get, when, then } = driver;
when.click("add-layer"); await when.setValue("add-layer.layer-id.input", "background");
then(get.elementByTestId("modal:add-layer")).shouldExist(); await when.click("add-layer");
then(get.element(".maputnik-modal-error")).shouldContainText( await then(get.elementByTestId("modal:add-layer")).shouldExist();
"Layer ID already exists" await then(get.element(".maputnik-modal-error")).shouldContainText("Layer ID already exists");
);
}); });
}); });
test.describe("sources", () => { test.describe("sources placeholder", () => {
test("toggle"); test.skip("toggle", () => {});
}); });
test.describe("global state", () => { test.describe("global state", () => {
beforeEach(() => { test.beforeEach(async ({ driver }) => {
when.click("nav:global-state"); await driver.when.click("nav:global-state");
}); });
test("add variable", () => { test("add variable", async ({ driver }) => {
when.wait(100); const { get, when, then } = driver;
when.click("global-state-add-variable"); await when.wait(100);
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await when.click("global-state-add-variable");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
state: { key1: { default: "value" } }, state: { key1: { default: "value" } },
}); });
}); });
test("add multiple variables", async ({ driver }) => {
test("add multiple variables", () => { const { get, when, then } = driver;
when.click("global-state-add-variable"); await when.click("global-state-add-variable");
when.click("global-state-add-variable"); await when.click("global-state-add-variable");
when.click("global-state-add-variable"); await when.click("global-state-add-variable");
when.wait(100); await when.wait(100);
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
state: { key1: { default: "value" }, key2: { default: "value" }, key3: { default: "value" } }, state: { key1: { default: "value" }, key2: { default: "value" }, key3: { default: "value" } },
}); });
}); });
test("remove variable", () => { test("remove variable", async ({ driver }) => {
when.click("global-state-add-variable"); const { get, when, then } = driver;
when.click("global-state-add-variable"); await when.click("global-state-add-variable");
when.click("global-state-add-variable"); await when.click("global-state-add-variable");
when.click("global-state-remove-variable", 0); await when.click("global-state-add-variable");
when.wait(100); await when.click("global-state-remove-variable", 0);
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await when.wait(100);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
state: { key2: { default: "value" }, key3: { default: "value" } }, state: { key2: { default: "value" }, key3: { default: "value" } },
}); });
}); });
test("edit variable key", () => { test("edit variable key", async ({ driver }) => {
when.click("global-state-add-variable"); const { get, when, then } = driver;
when.wait(100); await when.click("global-state-add-variable");
when.setValue("global-state-variable-key:0", "mykey"); await when.wait(100);
when.typeKeys("{enter}"); await when.setValue("global-state-variable-key:0", "mykey");
when.wait(100); await when.typeKeys("{enter}");
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await when.wait(100);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
state: { mykey: { default: "value" } }, state: { mykey: { default: "value" } },
}); });
}); });
test("edit variable value", () => { test("edit variable value", async ({ driver }) => {
when.click("global-state-add-variable"); const { get, when, then } = driver;
when.wait(100); await when.click("global-state-add-variable");
when.setValue("global-state-variable-value:0", "myvalue"); await when.wait(100);
when.typeKeys("{enter}"); await when.setValue("global-state-variable-value:0", "myvalue");
when.wait(100); await when.typeKeys("{enter}");
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await when.wait(100);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
state: { key1: { default: "myvalue" } }, state: { key1: { default: "myvalue" } },
}); });
}); });
}); });
test.describe("error panel", () => { test.describe("error panel", () => {
test("not visible when no errors", () => { test("not visible when no errors", async ({ driver }) => {
then(get.element("maputnik-message-panel-error")).shouldNotExist(); await driver.then(driver.get.element("maputnik-message-panel-error")).shouldNotExist();
}); });
test("visible on style error", () => { test("visible on style error", async ({ driver }) => {
when.modal.open(); const { get, when, then } = driver;
when.modal.fillLayers({ await when.modal.open();
await when.modal.fillLayers({
type: "circle", type: "circle",
layer: "invalid", layer: "invalid",
}); });
then(get.element(".maputnik-message-panel-error")).shouldBeVisible(); await then(get.element(".maputnik-message-panel-error")).shouldBeVisible();
}); });
}); });
test.describe("Handle localStorage QuotaExceededError", () => { test.describe("Handle localStorage QuotaExceededError", () => {
test("handles quota exceeded error when opening style from URL", () => { test("handles quota exceeded error when opening style from URL", async ({ driver, page }) => {
const { get, when, then } = driver;
// Clear localStorage to start fresh // Clear localStorage to start fresh
cy.clearLocalStorage(); await when.clearLocalStorage();
// fill localStorage until we get a QuotaExceededError // fill localStorage until we get a QuotaExceededError
cy.window().then(win => { await page.evaluate(() => {
let chunkSize = 1000; let chunkSize = 1000;
const chunk = new Array(chunkSize).join("x"); const chunk = new Array(chunkSize).join("x");
let index = 0; let index = 0;
// Keep adding until we hit the quota // Keep adding until we hit the quota
while (true) { for (;;) {
try { try {
const key = `maputnik:fill-${index++}`; const key = `maputnik:fill-${index++}`;
win.localStorage.setItem(key, chunk); window.localStorage.setItem(key, chunk);
} catch (e: any) { } catch (e: any) {
// Verify it's a quota error // Verify it's a quota error
if (e.name === "QuotaExceededError") { if (e.name === "QuotaExceededError") {
if (chunkSize <= 1) return; if (chunkSize <= 1) return;
else { chunkSize /= 2;
chunkSize /= 2; continue;
continue;
}
} }
throw e; // Unexpected error throw e; // Unexpected error
} }
@@ -445,12 +445,12 @@ test.describe("modals", () => {
}); });
// Open the style via URL input // Open the style via URL input
when.click("nav:open"); await when.click("nav:open");
when.setValue("modal:open.url.input", get.exampleFileUrl()); await when.setValue("modal:open.url.input", get.exampleFileUrl());
when.click("modal:open.url.button"); await when.click("modal:open.url.button");
then(get.responseBody("example-style.json")).shouldEqualToStoredStyle(); await then(get.responseBody("example-style.json")).shouldEqualToStoredStyle();
then(get.styleFromLocalStorage()).shouldExist(); await then(get.styleFromLocalStorage()).shouldExist();
}); });
}); });
}); });
+267 -2349
View File
File diff suppressed because it is too large Load Diff
+9 -8
View File
@@ -11,10 +11,10 @@
"build-linux": "tsc && vite build --mode=desktop && cd desktop && make bin/linux/maputnik", "build-linux": "tsc && vite build --mode=desktop && cd desktop && make bin/linux/maputnik",
"i18n:extract": "npx i18next-cli extract", "i18n:extract": "npx i18next-cli extract",
"lint": "eslint", "lint": "eslint",
"test": "cypress run", "test": "playwright test",
"test-e2e": "playwright test",
"test-unit": "vitest", "test-unit": "vitest",
"test-unit-ci": "vitest run --coverage --reporter=json", "test-unit-ci": "vitest run --coverage --reporter=json",
"cy:open": "cypress open",
"lint-css": "stylelint \"src/styles/*.scss\"", "lint-css": "stylelint \"src/styles/*.scss\"",
"sort-styles": "jq 'sort_by(.id)' src/config/styles.json > tmp.json && mv tmp.json src/config/styles.json" "sort-styles": "jq 'sort_by(.id)' src/config/styles.json > tmp.json && mv tmp.json src/config/styles.json"
}, },
@@ -98,10 +98,9 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@cypress/code-coverage": "^4.0.3",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@istanbuljs/nyc-config-typescript": "^1.0.2", "@istanbuljs/nyc-config-typescript": "^1.0.2",
"@shellygo/cypress-test-utils": "^6.0.6", "@playwright/test": "^1.61.1",
"@stylistic/eslint-plugin": "^5.10.0", "@stylistic/eslint-plugin": "^5.10.0",
"@types/codemirror": "^5.60.17", "@types/codemirror": "^5.60.17",
"@types/color": "^4.2.1", "@types/color": "^4.2.1",
@@ -125,10 +124,10 @@
"@types/string-hash": "^1.1.3", "@types/string-hash": "^1.1.3",
"@types/wicg-file-system-access": "^2023.10.7", "@types/wicg-file-system-access": "^2023.10.7",
"@vitejs/plugin-react": "^5.1.4", "@vitejs/plugin-react": "^5.1.4",
"@vitest/coverage-v8": "^4.1.9", "@vitest/browser": "^4.1.10",
"@vitest/browser-playwright": "^4.1.10",
"@vitest/coverage-v8": "^4.1.10",
"cors": "^2.8.6", "cors": "^2.8.6",
"cypress": "^15.18.0",
"cypress-plugin-tab": "^2.0.0",
"eslint": "^10.6.0", "eslint": "^10.6.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
@@ -136,6 +135,7 @@
"i18next-cli": "^1.53.2", "i18next-cli": "^1.53.2",
"istanbul": "^0.4.5", "istanbul": "^0.4.5",
"istanbul-lib-coverage": "^3.2.2", "istanbul-lib-coverage": "^3.2.2",
"nyc": "^18.0.0",
"postcss": "^8.5.16", "postcss": "^8.5.16",
"react-hot-loader": "^4.13.1", "react-hot-loader": "^4.13.1",
"sass": "^1.101.0", "sass": "^1.101.0",
@@ -147,6 +147,7 @@
"uuid": "^14.0.1", "uuid": "^14.0.1",
"vite": "^7.3.2", "vite": "^7.3.2",
"vite-plugin-istanbul": "^9.0.1", "vite-plugin-istanbul": "^9.0.1",
"vitest": "^4.1.9" "vitest": "^4.1.10",
"vitest-browser-react": "^2.2.0"
} }
} }
+40 -35
View File
@@ -1,41 +1,46 @@
import { defineConfig } from "cypress"; import { defineConfig, devices } from "@playwright/test";
import { createRequire } from "module";
const require = createRequire(import.meta.url); const isCI = !!process.env.CI;
// When the app is already served elsewhere (e.g. the docker e2e job) set
// E2E_NO_WEBSERVER=1 so Playwright does not start its own dev server.
const useExternalServer = !!process.env.E2E_NO_WEBSERVER;
const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:8888/";
export default defineConfig({ export default defineConfig({
env: { testDir: "./e2e",
codeCoverage: { testMatch: "**/*.spec.ts",
exclude: "cypress/**/*.*", globalSetup: "./e2e/global-setup.ts",
}, globalTeardown: "./e2e/global-teardown.ts",
fullyParallel: true,
forbidOnly: isCI,
retries: isCI ? 2 : 0,
reporter: isCI ? [["list"], ["html", { open: "never" }]] : "list",
use: {
baseURL,
trace: "on-first-retry",
}, },
projects: [
e2e: { {
setupNodeEvents(on, config) { name: "chromium",
// implement node event listeners here use: {
require("@cypress/code-coverage/task")(on, config); ...devices["Desktop Chrome"],
on("before:browser:launch", (browser, launchOptions) => { launchOptions: {
if (browser.family !== "chromium") { // Allow WebGL (maplibre) to fall back to software rendering in headless.
return; args: [
} "--disable-gpu",
launchOptions.args.push("--disable-gpu"); "--enable-features=AllowSwiftShaderFallback,AllowSoftwareGLFallbackDueToCrashes",
launchOptions.args.push("--enable-features=AllowSwiftShaderFallback,AllowSoftwareGLFallbackDueToCrashes"); "--enable-unsafe-swiftshader",
launchOptions.args.push("--enable-unsafe-swiftshader"); ],
return launchOptions; },
}); },
return config;
}, },
baseUrl: "http://localhost:8888", ],
scrollBehavior: "center", webServer: useExternalServer
retries: { ? undefined
runMode: 2, : {
openMode: 0, command: "npm run start",
url: "http://localhost:8888/maputnik/",
reuseExistingServer: !isCI,
timeout: 120000,
}, },
},
component: {
devServer: {
framework: "react",
bundler: "vite",
},
},
}); });
@@ -1,18 +1,24 @@
import { expect, test } from "vitest";
import { render } from "vitest-browser-react";
import { page } from "vitest/browser";
import InputAutocomplete from "./InputAutocomplete"; import InputAutocomplete from "./InputAutocomplete";
import { mount } from "cypress/react";
const fruits = ["apple", "banana", "cherry"]; const fruits = ["apple", "banana", "cherry"];
describe("<InputAutocomplete />", () => { test("filters options when typing", async () => {
it("filters options when typing", () => { render(<InputAutocomplete aria-label="Fruit" options={fruits.map((f) => [f, f])} />);
mount(
<InputAutocomplete aria-label="Fruit" options={fruits.map(f => [f, f])} /> const input = page.getByLabelText("Fruit");
); await input.click();
cy.get("input").focus();
cy.get(".maputnik-autocomplete-menu-item").should("have.length", 3); const menuItems = page.getByRole("option");
cy.get("input").type("ch"); await expect.element(menuItems.first()).toBeVisible();
cy.get(".maputnik-autocomplete-menu-item").should("have.length", 1).and("contain", "cherry"); expect(menuItems.all()).toHaveLength(3);
cy.get(".maputnik-autocomplete-menu-item").click();
cy.get("input").should("have.value", "cherry"); await input.fill("ch");
}); await expect.element(page.getByText("cherry")).toBeVisible();
expect(page.getByRole("option").all()).toHaveLength(1);
await page.getByText("cherry").click();
await expect.element(input).toHaveValue("cherry");
}); });
+3 -10
View File
@@ -3,7 +3,7 @@
"target": "ES2020", "target": "ES2020",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["geojson", "@types/wicg-file-system-access"], "types": ["geojson", "@types/wicg-file-system-access", "node"],
"module": "ESNext", "module": "ESNext",
"skipLibCheck": true, "skipLibCheck": true,
@@ -21,14 +21,7 @@
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },
"include": ["src", "cypress/e2e"], "include": ["src", "e2e"],
"exclude": ["dist"], "exclude": ["dist"],
"references": [{ "path": "./tsconfig.node.json" }], "references": [{ "path": "./tsconfig.node.json" }]
// TODO: Remove when issue is resolved https://github.com/cypress-io/cypress/issues/27448
"ts-node": {
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Node",
}
}
} }
+1 -2
View File
@@ -12,10 +12,9 @@ export default defineConfig(({ mode }) => ({
plugins: [ plugins: [
react(), react(),
istanbul({ istanbul({
cypress: true,
requireEnv: false, requireEnv: false,
nycrcPath: "./.nycrc.json", nycrcPath: "./.nycrc.json",
forceBuildInstrument: true, //Instrument the source code for cypress runs forceBuildInstrument: true, // Instrument the source so e2e runs can collect coverage
}), }),
], ],
base: mode === "desktop" ? "/" : "/maputnik/", base: mode === "desktop" ? "/" : "/maputnik/",
+37
View File
@@ -0,0 +1,37 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { playwright } from "@vitest/browser-playwright";
export default defineConfig({
plugins: [react()],
test: {
projects: [
{
extends: true,
test: {
name: "unit",
environment: "node",
include: ["src/**/*.test.{ts,tsx}"],
exclude: ["src/**/*.browser.test.{ts,tsx}"],
},
},
{
extends: true,
test: {
name: "browser",
include: ["src/**/*.browser.test.{ts,tsx}"],
browser: {
enabled: true,
provider: playwright(),
headless: true,
instances: [{ browser: "chromium" }],
},
},
},
],
coverage: {
provider: "v8",
reporter: ["json", "lcov", "text-summary"],
},
},
});