Compare commits

..

3 Commits

Author SHA1 Message Date
dependabot[bot] c24049cb88 chore(deps): Bump @codemirror/view from 6.43.5 to 6.43.6 (#1993)
Bumps [@codemirror/view](https://github.com/codemirror/view) from 6.43.5
to 6.43.6.
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/codemirror/view/commits">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@codemirror/view&package-manager=npm_and_yarn&previous-version=6.43.5&new-version=6.43.6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-09 13:52:29 +00:00
dependabot[bot] 1047286d9e chore(deps): Bump @codemirror/state from 6.7.0 to 6.7.1 (#1992)
Bumps [@codemirror/state](https://github.com/codemirror/state) from
6.7.0 to 6.7.1.
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/codemirror/state/commits">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@codemirror/state&package-manager=npm_and_yarn&previous-version=6.7.0&new-version=6.7.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-09 13:51:55 +00:00
dependabot[bot] 99b5dd785d chore(deps-dev): Bump the vitest group with 2 updates (#1991)
Bumps the vitest group with 2 updates:
[@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8)
and
[vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).

Updates `@vitest/coverage-v8` from 4.1.9 to 4.1.10
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitest-dev/vitest/releases">@​vitest/coverage-v8's
releases</a>.</em></p>
<blockquote>
<h2>v4.1.10</h2>
<h3>   🐞 Bug Fixes</h3>
<ul>
<li><strong>browser</strong>: Check fs access in builtin commands
[backport to v4]  -  by <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>,
<strong>Hiroshi Ogawa</strong> and <strong>OpenCode
(claude-opus-4-8)</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10680">vitest-dev/vitest#10680</a>
<a href="https://github.com/vitest-dev/vitest/commit/5c18dd267"><!-- raw
HTML omitted -->(5c18d)<!-- raw HTML omitted --></a></li>
<li><strong>vm</strong>: Fix external module resolve error with deps
optimizer query for encoded URI [backport to v4]  -  by <a
href="https://github.com/SveLil"><code>@​SveLil</code></a> and <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10661">vitest-dev/vitest#10661</a>
<a href="https://github.com/vitest-dev/vitest/commit/bae52b511"><!-- raw
HTML omitted -->(bae52)<!-- raw HTML omitted --></a></li>
</ul>
<h5>    <a
href="https://github.com/vitest-dev/vitest/compare/v4.1.9...v4.1.10">View
changes on GitHub</a></h5>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vitest-dev/vitest/commit/db616d227b6e0cb07a94f5d1bba262ee95db7e46"><code>db616d2</code></a>
chore: release v4.1.10 (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8/issues/10718">#10718</a>)</li>
<li>See full diff in <a
href="https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/coverage-v8">compare
view</a></li>
</ul>
</details>
<br />

Updates `vitest` from 4.1.9 to 4.1.10
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitest-dev/vitest/releases">vitest's
releases</a>.</em></p>
<blockquote>
<h2>v4.1.10</h2>
<h3>   🐞 Bug Fixes</h3>
<ul>
<li><strong>browser</strong>: Check fs access in builtin commands
[backport to v4]  -  by <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>,
<strong>Hiroshi Ogawa</strong> and <strong>OpenCode
(claude-opus-4-8)</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10680">vitest-dev/vitest#10680</a>
<a href="https://github.com/vitest-dev/vitest/commit/5c18dd267"><!-- raw
HTML omitted -->(5c18d)<!-- raw HTML omitted --></a></li>
<li><strong>vm</strong>: Fix external module resolve error with deps
optimizer query for encoded URI [backport to v4]  -  by <a
href="https://github.com/SveLil"><code>@​SveLil</code></a> and <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10661">vitest-dev/vitest#10661</a>
<a href="https://github.com/vitest-dev/vitest/commit/bae52b511"><!-- raw
HTML omitted -->(bae52)<!-- raw HTML omitted --></a></li>
</ul>
<h5>    <a
href="https://github.com/vitest-dev/vitest/compare/v4.1.9...v4.1.10">View
changes on GitHub</a></h5>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vitest-dev/vitest/commit/db616d227b6e0cb07a94f5d1bba262ee95db7e46"><code>db616d2</code></a>
chore: release v4.1.10 (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/10718">#10718</a>)</li>
<li><a
href="https://github.com/vitest-dev/vitest/commit/bae52b5112a6fd8200101b88bf8af9685d077295"><code>bae52b5</code></a>
fix(vm): fix external module resolve error with deps optimizer query for
enco...</li>
<li>See full diff in <a
href="https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/vitest">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-09 13:51:14 +00:00
43 changed files with 3893 additions and 1856 deletions
+18 -30
View File
@@ -96,7 +96,6 @@ 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
@@ -118,21 +117,17 @@ jobs:
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
- run: npm ci - run: npm ci
- run: npx playwright install --with-deps chromium - name: Cypress run
- name: Playwright run uses: cypress-io/github-action@fa4a118725a8f001170d49631ea89e5d66fee626 # v7.4.1
run: npm run test-e2e with:
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 }}/coverage/coverage-final.json files: ${{ github.workspace }}/.nyc_output/out.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"
@@ -146,21 +141,14 @@ jobs:
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
- run: npm ci - run: npm ci
- run: npx playwright install --with-deps chromium - name: Cypress run
- name: Build docker image uses: cypress-io/github-action@fa4a118725a8f001170d49631ea89e5d66fee626 # v7.4.1
run: docker build -t maputnik . with:
- name: Start maputnik container build: docker build -t maputnik .
run: docker run -d --network host --name maputnik maputnik --port=8888 start: docker run --rm --network host maputnik --port=8888
- name: Wait for maputnik to be ready browser: chrome
run: | - name: Upload coverage reports to Codecov
for i in $(seq 1 60); do uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
if curl -sSf http://localhost:8888/ > /dev/null; then with:
echo "maputnik is up"; exit 0 files: ${{ github.workspace }}/.nyc_output/out.json
fi verbose: true
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"
+1 -5
View File
@@ -33,14 +33,10 @@ 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/
+4 -5
View File
@@ -4,13 +4,12 @@
"check-coverage": false, "check-coverage": false,
"include": ["src/**/*.ts", "src/**/*.tsx"], "include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": [ "exclude": [
"e2e/**/*.*", "cypress/**/*.*",
"**/*.d.ts", "**/*.d.ts",
"**/*.test.ts", "**/*.cy.tsx",
"**/*.test.tsx", "**/*.cy.ts",
"**/*.browser.test.tsx",
"./coverage/**", "./coverage/**",
"./e2e/**", "./cypress/**",
"./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
``` ```
Install the Playwright browser (first time only): To run the tests make sure that xvfb is installed:
``` ```
npx playwright install --with-deps chromium apt install xvfb
``` ```
Then run the end-to-end tests (Playwright starts the dev server automatically): Run the development server in the background with Vite:
``` ```
npm run test nohup npm run start &
``` ```
Run the unit and component tests with Vitest: Then start the Cypress tests with:
``` ```
npm run test-unit xvfb-run -a npm run test
``` ```
## Pull Requests ## Pull Requests
-1
View File
@@ -1,7 +1,6 @@
## 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
+8 -27
View File
@@ -79,45 +79,26 @@ npm run sort-styles
``` ```
## Tests ## Tests
For E2E testing we use [Cypress](https://www.cypress.io/)
### End-to-end tests [Cypress](https://www.cypress.io/) doesn't start a server so you'll need to start one manually by running `npm run start`.
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. Now open a terminal and run the following using *chrome*:
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*:
Some useful options:
``` ```
# see the tests run in a headed browser npm run test -- --browser firefox
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). See the following docs for more info: (Launching Browsers)[https://docs.cypress.io/guides/guides/launching-browsers]
### Unit & component tests You can also see the tests as they run or select which suites to run by executing:
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 npm run cy:open
``` ```
## Release process ## Release process
+42
View File
@@ -0,0 +1,42 @@
import { defineConfig } from "cypress";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
export default defineConfig({
env: {
codeCoverage: {
exclude: "cypress/**/*.*",
},
},
e2e: {
specPattern: "e2e/**/*.spec.ts",
setupNodeEvents(on, config) {
// implement node event listeners here
require("@cypress/code-coverage/task")(on, config);
on("before:browser:launch", (browser, launchOptions) => {
if (browser.family !== "chromium") {
return;
}
launchOptions.args.push("--disable-gpu");
launchOptions.args.push("--enable-features=AllowSwiftShaderFallback,AllowSoftwareGLFallbackDueToCrashes");
launchOptions.args.push("--enable-unsafe-swiftshader");
return launchOptions;
});
return config;
},
baseUrl: "http://localhost:8888",
scrollBehavior: "center",
retries: {
runMode: 2,
openMode: 0,
},
},
component: {
devServer: {
framework: "react",
bundler: "vite",
},
},
});
+37
View File
@@ -0,0 +1,37 @@
/// <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
@@ -0,0 +1,12 @@
<!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
@@ -0,0 +1,37 @@
// ***********************************************************
// 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
@@ -0,0 +1,22 @@
// ***********************************************************
// 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')
+25 -31
View File
@@ -1,48 +1,42 @@
import { test, describe, beforeEach } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver"; import { MaputnikDriver } from "./maputnik-driver";
describe("accessibility", () => { const test = it;
const { given, get, when, then } = new MaputnikDriver();
beforeEach(async () => { describe("accessibility", () => {
await given.setupMockBackedResponses(); const { beforeAndAfter, get, when, then } = new MaputnikDriver();
await when.setStyle("both"); beforeAndAfter();
});
describe("skip links", () => { describe("skip links", () => {
beforeEach(async () => { beforeEach(() => {
await when.setStyle("layer"); when.setStyle("layer");
}); });
test("skip link to layer list", async () => { test("skip link to layer list", () => {
const selector = "root:skip:layer-list"; const selector = "root:skip:layer-list";
await then(get.elementByTestId(selector)).shouldExist(); then(get.elementByTestId(selector)).shouldExist();
await when.tab(); when.tab();
await then(get.elementByTestId(selector)).shouldBeFocused(); then(get.elementByTestId(selector)).shouldBeFocused();
await when.click(selector); when.click(selector);
await then(get.skipTargetLayerList()).shouldBeFocused(); then(get.skipTargetLayerList()).shouldBeFocused();
}); });
test("skip link to layer editor", async () => { test("skip link to layer editor", () => {
const selector = "root:skip:layer-editor"; const selector = "root:skip:layer-editor";
await then(get.elementByTestId(selector)).shouldExist(); then(get.elementByTestId(selector)).shouldExist();
await then(get.elementByTestId("skip-target-layer-editor")).shouldExist(); then(get.elementByTestId("skip-target-layer-editor")).shouldExist();
await when.tab(); when.tab().tab();
await when.tab(); then(get.elementByTestId(selector)).shouldBeFocused();
await then(get.elementByTestId(selector)).shouldBeFocused(); when.click(selector);
await when.click(selector); then(get.skipTargetLayerEditor()).shouldBeFocused();
await then(get.skipTargetLayerEditor()).shouldBeFocused();
}); });
test("skip link to map view", async () => { test("skip link to map view", () => {
const selector = "root:skip:map-view"; const selector = "root:skip:map-view";
await then(get.elementByTestId(selector)).shouldExist(); then(get.elementByTestId(selector)).shouldExist();
await when.tab(); when.tab().tab().tab();
await when.tab(); then(get.elementByTestId(selector)).shouldBeFocused();
await when.tab(); when.click(selector);
await then(get.elementByTestId(selector)).shouldBeFocused(); then(get.canvas()).shouldBeFocused();
await when.click(selector);
await then(get.canvas()).shouldBeFocused();
}); });
}); });
}); });
+12 -15
View File
@@ -1,23 +1,20 @@
import { beforeEach, describe, test } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver"; import { MaputnikDriver } from "./maputnik-driver";
const test = it;
describe("code editor", () => { describe("code editor", () => {
const { given, get, when, then } = new MaputnikDriver(); const { beforeAndAfter, when, get, then } = new MaputnikDriver();
beforeAndAfter();
beforeEach(async () => { test("open code editor", () => {
await given.setupMockBackedResponses(); when.click("nav:code-editor");
await when.setStyle("both"); then(get.element(".maputnik-code-editor")).shouldExist();
}); });
test("open code editor", async () => { test("closes code editor", () => {
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")).shouldExist();
}); when.click("nav:code-editor");
then(get.element(".maputnik-code-editor")).shouldNotExist();
test("closes code editor", async () => {
await when.click("nav:code-editor");
await then(get.element(".maputnik-code-editor")).shouldExist();
await when.click("nav:code-editor");
await then(get.element(".maputnik-code-editor")).shouldNotExist();
}); });
}); });
+98 -62
View File
@@ -1,90 +1,126 @@
import { beforeEach, describe, test } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver"; import { MaputnikDriver } from "./maputnik-driver";
const test = it;
describe("history", () => { describe("history", () => {
const { given, get, when, then } = new MaputnikDriver(); const { beforeAndAfter, when, get, then } = new MaputnikDriver();
beforeAndAfter();
const undoKeyCombo = process.platform === "darwin" ? "{meta}z" : "{ctrl}z"; let undoKeyCombo: string;
const redoKeyCombo = process.platform === "darwin" ? "{meta}{shift}z" : "{ctrl}y"; let redoKeyCombo: string;
beforeEach(async () => { before(() => {
await given.setupMockBackedResponses(); const isMac = get.isMac();
await when.setStyle("both"); undoKeyCombo = isMac ? "{meta}z" : "{ctrl}z";
redoKeyCombo = isMac ? "{meta}{shift}z" : "{ctrl}y";
}); });
test("undo/redo", async () => { test("undo/redo", () => {
await when.setStyle("geojson"); when.setStyle("geojson");
await when.modal.open(); when.modal.open();
await when.modal.fillLayers({ when.modal.fillLayers({
id: "step 1", id: "step 1",
type: "background", type: "background",
}); });
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "step 1", type: "background" }],
});
await when.modal.open();
await when.modal.fillLayers({
id: "step 2",
type: "background",
});
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [
{ id: "step 1", type: "background" }, {
{ id: "step 2", type: "background" },
],
});
await when.typeKeys(undoKeyCombo);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "step 1", type: "background" }],
});
await when.typeKeys(undoKeyCombo);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [] });
await when.typeKeys(redoKeyCombo);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "step 1", type: "background" }],
});
await when.typeKeys(redoKeyCombo);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{ id: "step 1", type: "background" },
{ id: "step 2", type: "background" },
],
});
});
test("should not redo after undo and value change", async () => {
await when.setStyle("geojson");
await when.modal.open();
await when.modal.fillLayers({
id: "step 1", id: "step 1",
type: "background", type: "background",
},
],
}); });
await when.modal.open(); when.modal.open();
await when.modal.fillLayers({ when.modal.fillLayers({
id: "step 2", id: "step 2",
type: "background", type: "background",
}); });
await when.typeKeys(undoKeyCombo); then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
await when.typeKeys(undoKeyCombo); layers: [
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [] }); {
id: "step 1",
type: "background",
},
{
id: "step 2",
type: "background",
},
],
});
await when.modal.open(); when.typeKeys(undoKeyCombo);
await when.modal.fillLayers({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "step 1",
type: "background",
},
],
});
when.typeKeys(undoKeyCombo);
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [] });
when.typeKeys(redoKeyCombo);
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "step 1",
type: "background",
},
],
});
when.typeKeys(redoKeyCombo);
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "step 1",
type: "background",
},
{
id: "step 2",
type: "background",
},
],
});
});
test("should not redo after undo and value change", () => {
when.setStyle("geojson");
when.modal.open();
when.modal.fillLayers({
id: "step 1",
type: "background",
});
when.modal.open();
when.modal.fillLayers({
id: "step 2",
type: "background",
});
when.typeKeys(undoKeyCombo);
when.typeKeys(undoKeyCombo);
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [] });
when.modal.open();
when.modal.fillLayers({
id: "step 3", id: "step 3",
type: "background", type: "background",
}); });
await when.typeKeys(redoKeyCombo); when.typeKeys(redoKeyCombo);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "step 3", type: "background" }], layers: [
{
id: "step 3",
type: "background",
},
],
}); });
}); });
}); });
+19 -20
View File
@@ -1,38 +1,37 @@
import { beforeEach, describe, test } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver"; import { MaputnikDriver } from "./maputnik-driver";
describe("i18n", () => { const test = it;
const { given, get, when, then } = new MaputnikDriver();
beforeEach(async () => { describe("i18n", () => {
await given.setupMockBackedResponses(); const { beforeAndAfter, get, when, then } = new MaputnikDriver();
await when.setStyle("both"); beforeAndAfter();
});
describe("language detector", () => { describe("language detector", () => {
test("English", async () => { test("English", () => {
await when.visit("?lng=en"); const url = "?lng=en";
await then(get.elementByTestId("maputnik-lang-select")).shouldHaveValue("en"); when.visit(url);
then(get.elementByTestId("maputnik-lang-select")).shouldHaveValue("en");
}); });
test("Japanese", async () => { test("Japanese", () => {
await when.visit("?lng=ja"); const url = "?lng=ja";
await then(get.elementByTestId("maputnik-lang-select")).shouldHaveValue("ja"); when.visit(url);
then(get.elementByTestId("maputnik-lang-select")).shouldHaveValue("ja");
}); });
}); });
describe("language switcher", () => { describe("language switcher", () => {
beforeEach(async () => { beforeEach(() => {
await when.setStyle("layer"); when.setStyle("layer");
}); });
test("the language switcher switches to Japanese", async () => { test("the language switcher switches to Japanese", () => {
const selector = "maputnik-lang-select"; const selector = "maputnik-lang-select";
await then(get.elementByTestId(selector)).shouldExist(); then(get.elementByTestId(selector)).shouldExist();
await when.select(selector, "ja"); when.select(selector, "ja");
await then(get.elementByTestId(selector)).shouldHaveValue("ja"); then(get.elementByTestId(selector)).shouldHaveValue("ja");
await then(get.elementByTestId("nav:settings")).shouldHaveText("スタイル設定"); then(get.elementByTestId("nav:settings")).shouldHaveText("スタイル設定");
}); });
}); });
}); });
+35 -40
View File
@@ -1,66 +1,61 @@
import { beforeEach, describe, test } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver"; import { MaputnikDriver } from "./maputnik-driver";
const test = it;
describe("keyboard", () => { describe("keyboard", () => {
const { given, get, when, then } = new MaputnikDriver(); const { beforeAndAfter, given, when, get, then } = new MaputnikDriver();
beforeAndAfter();
beforeEach(async () => {
await given.setupMockBackedResponses();
await when.setStyle("both");
});
describe("shortcuts", () => { describe("shortcuts", () => {
beforeEach(async () => { beforeEach(() => {
await given.setupMockBackedResponses(); given.setupMockBackedResponses();
await when.setStyle(""); when.setStyle("");
}); });
test("ESC should unfocus", async () => { test("ESC should unfocus", () => {
const targetSelector = "maputnik-select"; const targetSelector = "maputnik-select";
await when.focus(targetSelector); when.focus(targetSelector);
await then(get.elementByTestId(targetSelector)).shouldBeFocused(); then(get.elementByTestId(targetSelector)).shouldBeFocused();
await when.typeKeys("{esc}"); when.typeKeys("{esc}");
await then(get.elementByTestId(targetSelector)).shouldNotBeFocused(); then(get.elementByTestId(targetSelector)).shouldNotBeFocused();
}); });
test("'?' should show shortcuts modal", async () => { test("'?' should show shortcuts modal", () => {
await when.typeKeys("?"); when.typeKeys("?");
await then(get.elementByTestId("modal:shortcuts")).shouldBeVisible(); then(get.elementByTestId("modal:shortcuts")).shouldBeVisible();
}); });
test("'o' should show open modal", async () => { test("'o' should show open modal", () => {
await when.typeKeys("o"); when.typeKeys("o");
await then(get.elementByTestId("modal:open")).shouldBeVisible(); then(get.elementByTestId("modal:open")).shouldBeVisible();
}); });
test("'e' should show export modal", async () => { test("'e' should show export modal", () => {
await when.typeKeys("e"); when.typeKeys("e");
await then(get.elementByTestId("modal:export")).shouldBeVisible(); then(get.elementByTestId("modal:export")).shouldBeVisible();
}); });
test("'d' should show sources modal", async () => { test("'d' should show sources modal", () => {
await when.typeKeys("d"); when.typeKeys("d");
await then(get.elementByTestId("modal:sources")).shouldBeVisible(); then(get.elementByTestId("modal:sources")).shouldBeVisible();
}); });
test("'s' should show settings modal", async () => { test("'s' should show settings modal", () => {
await when.typeKeys("s"); when.typeKeys("s");
await then(get.elementByTestId("modal:settings")).shouldBeVisible(); then(get.elementByTestId("modal:settings")).shouldBeVisible();
}); });
test("'i' should change map to inspect mode", async () => { test("'i' should change map to inspect mode", () => {
await when.typeKeys("i"); when.typeKeys("i");
await then(get.inputValue("maputnik-select")).shouldEqual("inspect"); then(get.inputValue("maputnik-select")).shouldEqual("inspect");
}); });
test("'m' should focus map", async () => { test("'m' should focus map", () => {
await when.typeKeys("m"); when.typeKeys("m");
await then(get.canvas()).shouldBeFocused(); then(get.canvas()).shouldBeFocused();
}); });
test("'!' should show debug modal", async () => { test("'!' should show debug modal", () => {
await when.typeKeys("!"); when.typeKeys("!");
await then(get.elementByTestId("modal:debug")).shouldBeVisible(); then(get.elementByTestId("modal:debug")).shouldBeVisible();
}); });
}); });
}); });
+171 -121
View File
@@ -1,79 +1,98 @@
import { v1 as uuid } from "uuid";
import { beforeEach, describe, test } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver"; import { MaputnikDriver } from "./maputnik-driver";
import { v1 as uuid } from "uuid";
const test = it;
describe("layer editor", () => { describe("layer editor", () => {
const { given, get, when, then } = new MaputnikDriver(); const { beforeAndAfter, get, when, then } = new MaputnikDriver();
beforeAndAfter();
beforeEach(async () => { beforeEach(() => {
await given.setupMockBackedResponses(); when.setStyle("both");
await when.setStyle("both"); when.modal.open();
await when.modal.open();
}); });
async function createBackground() { function createBackground() {
const id = uuid(); const id = uuid();
await when.selectWithin("add-layer.layer-type", "background"); when.selectWithin("add-layer.layer-type", "background");
await when.setValue("add-layer.layer-id.input", "background:" + id); when.setValue("add-layer.layer-id.input", "background:" + id);
await when.click("add-layer"); when.click("add-layer");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "background:" + id, type: "background" }], layers: [
{
id: "background:" + id,
type: "background",
},
],
}); });
return id; return id;
} }
test.skip("expand/collapse", () => {}); test("expand/collapse");
test("id", () => {
const bgId = createBackground();
test("id", async () => { when.click("layer-list-item:background:" + bgId);
const bgId = await createBackground();
await when.click("layer-list-item:background:" + bgId);
const id = uuid(); const id = uuid();
await when.setValue("layer-editor.layer-id.input", "foobar:" + id); when.setValue("layer-editor.layer-id.input", "foobar:" + id);
await when.click("min-zoom"); when.click("min-zoom");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "foobar:" + id, type: "background" }], layers: [
{
id: "foobar:" + id,
type: "background",
},
],
}); });
}); });
describe("source", () => { describe("source", () => {
test("should show error when the source is invalid", async () => { test("should show error when the source is invalid", () => {
await when.modal.fillLayers({ when.modal.fillLayers({
type: "circle", type: "circle",
layer: "invalid", layer: "invalid",
}); });
await then( then(get.element(".maputnik-input-block--error .maputnik-input-block-label")).shouldHaveCss("color", "rgb(207, 74, 74)");
get.element(".maputnik-input-block--error .maputnik-input-block-label")
).shouldHaveCss("color", "rgb(207, 74, 74)");
}); });
}); });
describe("min-zoom", () => { describe("min-zoom", () => {
let bgId: string; let bgId: string;
beforeEach(async () => { beforeEach(() => {
bgId = await createBackground(); bgId = createBackground();
await when.click("layer-list-item:background:" + bgId); when.click("layer-list-item:background:" + bgId);
await when.setValue("min-zoom.input-text", "1"); when.setValue("min-zoom.input-text", "1");
await when.click("layer-editor.layer-id"); when.click("layer-editor.layer-id");
}); });
test("should update min-zoom in local storage", async () => { test("should update min-zoom in local storage", () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "background:" + bgId, type: "background", minzoom: 1 }], layers: [
{
id: "background:" + bgId,
type: "background",
minzoom: 1,
},
],
}); });
}); });
test("when clicking next layer should update style on local storage", async () => { it("when clicking next layer should update style on local storage", () => {
await when.type("min-zoom.input-text", "{backspace}"); when.type("min-zoom.input-text", "{backspace}");
await when.click("max-zoom.input-text"); when.click("max-zoom.input-text");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "background:" + bgId, type: "background", minzoom: 1 }], layers: [
{
id: "background:" + bgId,
type: "background",
minzoom: 1,
},
],
}); });
}); });
}); });
@@ -81,16 +100,22 @@ describe("layer editor", () => {
describe("max-zoom", () => { describe("max-zoom", () => {
let bgId: string; let bgId: string;
beforeEach(async () => { beforeEach(() => {
bgId = await createBackground(); bgId = createBackground();
await when.click("layer-list-item:background:" + bgId); when.click("layer-list-item:background:" + bgId);
await when.setValue("max-zoom.input-text", "1"); when.setValue("max-zoom.input-text", "1");
await when.click("layer-editor.layer-id"); when.click("layer-editor.layer-id");
}); });
test("should update style in local storage", async () => { test("should update style in local storage", () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "background:" + bgId, type: "background", maxzoom: 1 }], layers: [
{
id: "background:" + bgId,
type: "background",
maxzoom: 1,
},
],
}); });
}); });
}); });
@@ -99,34 +124,41 @@ describe("layer editor", () => {
let bgId: string; let bgId: string;
const comment = "42"; const comment = "42";
beforeEach(async () => { beforeEach(() => {
bgId = await createBackground(); bgId = createBackground();
await when.click("layer-list-item:background:" + bgId); when.click("layer-list-item:background:" + bgId);
await when.setValue("layer-comment.input", comment); when.setValue("layer-comment.input", comment);
await when.click("layer-editor.layer-id"); when.click("layer-editor.layer-id");
}); });
test("should update style in local storage", async () => { test("should update style in local storage", () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [
{ {
id: "background:" + bgId, id: "background:" + bgId,
type: "background", type: "background",
metadata: { "maputnik:comment": comment }, metadata: {
"maputnik:comment": comment,
},
}, },
], ],
}); });
}); });
describe("when unsetting", () => { describe("when unsetting", () => {
beforeEach(async () => { beforeEach(() => {
await when.clear("layer-comment.input"); when.clear("layer-comment.input");
await when.click("min-zoom.input-text"); when.click("min-zoom.input-text");
}); });
test("should update style in local storage", async () => { test("should update style in local storage", () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "background:" + bgId, type: "background" }], layers: [
{
id: "background:" + bgId,
type: "background",
},
],
}); });
}); });
}); });
@@ -134,113 +166,131 @@ describe("layer editor", () => {
describe("color", () => { describe("color", () => {
let bgId: string; let bgId: string;
beforeEach(async () => { beforeEach(() => {
bgId = await createBackground(); bgId = createBackground();
await when.click("layer-list-item:background:" + bgId); when.click("layer-list-item:background:" + bgId);
await when.click("spec-field:background-color"); when.click("spec-field:background-color");
}); });
test("should update style in local storage", async () => { test("should update style in local storage", () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "background:" + bgId, type: "background" }], layers: [
{
id: "background:" + bgId,
type: "background",
},
],
}); });
}); });
}); });
describe("opacity", () => { describe("opacity", () => {
let bgId: string; let bgId: string;
beforeEach(async () => { beforeEach(() => {
bgId = await createBackground(); bgId = createBackground();
await when.click("layer-list-item:background:" + bgId); when.click("layer-list-item:background:" + bgId);
await when.type("spec-field-input:background-opacity", "0."); when.type("spec-field-input:background-opacity", "0.");
}); });
test("should keep '.' in the input field", async () => { test("should keep '.' in the input field", () => {
await then(get.elementByTestId("spec-field-input:background-opacity")).shouldHaveValue("0."); then(get.elementByTestId("spec-field-input:background-opacity")).shouldHaveValue("0.");
}); });
test("should revert to a valid value when focus out", async () => { test("should revert to a valid value when focus out", () => {
await when.click("layer-list-item:background:" + bgId); when.click("layer-list-item:background:" + bgId);
await then(get.elementByTestId("spec-field-input:background-opacity")).shouldHaveValue("0"); then(get.elementByTestId("spec-field-input:background-opacity")).shouldHaveValue("0");
}); });
}); });
describe("filter", () => { describe("filter", () => {
test.skip("expand/collapse", () => {}); test("expand/collapse");
test.skip("compound filter", () => {}); test("compound filter");
}); });
describe("layout", () => { describe("layout", () => {
test("text-font", async () => { test("text-font", () => {
await when.setStyle("font"); when.setStyle("font");
await when.collapseGroupInLayerEditor(); when.collapseGroupInLayerEditor();
await when.collapseGroupInLayerEditor(1); when.collapseGroupInLayerEditor(1);
await when.collapseGroupInLayerEditor(2); when.collapseGroupInLayerEditor(2);
await when.clickWithin("spec-field:text-font", ".maputnik-autocomplete input"); when.doWithin("spec-field:text-font", () => {
await then(get.element(".maputnik-autocomplete-menu-item")).shouldBeVisible(); get.element(".maputnik-autocomplete input").first().click();
await then(get.element(".maputnik-autocomplete-menu-item")).shouldHaveLength(3); });
then(get.element(".maputnik-autocomplete-menu-item")).shouldBeVisible();
then(get.element(".maputnik-autocomplete-menu-item")).shouldHaveLength(3);
}); });
}); });
describe("paint", () => { describe("paint", () => {
test.skip("expand/collapse", () => {}); test("expand/collapse");
test.skip("color", () => {}); test("color");
test.skip("pattern", () => {}); test("pattern");
test.skip("opacity", () => {}); test("opacity");
}); });
describe("json-editor", () => { describe("json-editor", () => {
test("add", async () => { test("add", () => {
const id = await when.modal.fillLayers({ const id = when.modal.fillLayers({
type: "circle", type: "circle",
layer: "example", layer: "example",
}); });
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "circle", source: "example" }], layers: [
{
id: id,
type: "circle",
source: "example",
},
],
}); });
await when.clickByText('"source"'); const sourceText = get.elementByText('"source"');
await when.typeKeys('"');
await then(get.element(".cm-lint-marker-error")).shouldExist(); sourceText.click();
sourceText.type("\"");
then(get.element(".cm-lint-marker-error")).shouldExist();
}); });
test.skip("expand/collapse", () => {});
test.skip("modify", () => {});
test("parse error", async () => { test("expand/collapse");
const bgId = await createBackground(); test("modify");
await when.click("layer-list-item:background:" + bgId); test("parse error", () => {
await when.collapseGroupInLayerEditor(); const bgId = createBackground();
await when.collapseGroupInLayerEditor(1);
await then(get.element(".cm-lint-marker-error")).shouldNotExist();
// Inject an invalid token (CodeMirror auto-closes brackets/quotes, so a when.click("layer-list-item:background:" + bgId);
// bare word reliably breaks the JSON) and expect a lint error. when.collapseGroupInLayerEditor();
await when.appendTextInJsonEditor("zzz"); when.collapseGroupInLayerEditor(1);
await then(get.element(".cm-lint-marker-error")).shouldExist(); 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();
}); });
}); });
describe("sticky header", () => { describe("sticky header", () => {
test("should keep layer header visible when scrolling properties", async () => { test("should keep layer header visible when scrolling properties", () => {
// Setup: Create a layer with many properties (e.g. symbol layer) // Setup: Create a layer with many properties (e.g., symbol layer)
await when.modal.fillLayers({ when.modal.fillLayers({
type: "symbol", type: "symbol",
layer: "example", layer: "example",
}); });
await when.wait(500); when.wait(500);
const header = get.elementByTestId("layer-editor.header"); const header = get.elementByTestId("layer-editor.header");
await then(header).shouldBeVisible(); then(header).shouldBeVisible();
await when.scrollToBottom(get.element(".maputnik-scroll-container")); get.element(".maputnik-scroll-container").scrollTo("bottom", { ensureScrollable: false });
await when.wait(200); when.wait(200);
await then(header).shouldBeVisible(); then(header).shouldBeVisible();
await then(get.elementByTestId("skip-target-layer-editor")).shouldBeVisible(); then(get.elementByTestId("skip-target-layer-editor")).shouldBeVisible();
}); });
}); });
}); });
+412 -221
View File
@@ -1,265 +1,412 @@
import { beforeEach, describe, test } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver"; import { MaputnikDriver } from "./maputnik-driver";
const test = it;
describe("layers list", () => { describe("layers list", () => {
const { given, get, when, then } = new MaputnikDriver(); const { beforeAndAfter, get, when, then } = new MaputnikDriver();
beforeAndAfter();
beforeEach(async () => { beforeEach(() => {
await given.setupMockBackedResponses(); when.setStyle("both");
await when.setStyle("both"); when.modal.open();
await when.modal.open();
}); });
describe("ops", () => { describe("ops", () => {
let id: string; let id: string;
beforeEach(async () => { beforeEach(() => {
id = await when.modal.fillLayers({ type: "background" }); id = when.modal.fillLayers({
type: "background",
});
}); });
test("should update layers in local storage", async () => { test("should update layers in local storage", () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "background" }], layers: [
{
id: id,
type: "background",
},
],
}); });
}); });
describe("when clicking delete", () => { describe("when clicking delete", () => {
beforeEach(async () => { beforeEach(() => {
await when.click("layer-list-item:" + id + ":delete"); when.click("layer-list-item:" + id + ":delete");
}); });
test("should empty layers in local storage", async () => { test("should empty layers in local storage", () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [], layers: [],
}); });
}); });
}); });
describe("when clicking duplicate", () => { describe("when clicking duplicate", () => {
beforeEach(async () => { beforeEach(() => {
await when.click("layer-list-item:" + id + ":copy"); when.click("layer-list-item:" + id + ":copy");
}); });
test("should add copy layer in local storage", async () => { test("should add copy layer in local storage", () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [
{ id: id + "-copy", type: "background" }, {
{ id, type: "background" }, id: id + "-copy",
type: "background",
},
{
id: id,
type: "background",
},
], ],
}); });
}); });
}); });
describe("when clicking hide", () => { describe("when clicking hide", () => {
beforeEach(async () => { beforeEach(() => {
await when.click("layer-list-item:" + id + ":toggle-visibility"); when.click("layer-list-item:" + id + ":toggle-visibility");
}); });
test("should update visibility to none in local storage", async () => { test("should update visibility to none in local storage", () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "background", layout: { visibility: "none" } }], layers: [
{
id: id,
type: "background",
layout: {
visibility: "none",
},
},
],
}); });
}); });
describe("when clicking show", () => { describe("when clicking show", () => {
beforeEach(async () => { beforeEach(() => {
await when.click("layer-list-item:" + id + ":toggle-visibility"); when.click("layer-list-item:" + id + ":toggle-visibility");
}); });
test("should update visibility to visible in local storage", async () => { test("should update visibility to visible in local storage", () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "background", layout: { visibility: "visible" } }], layers: [
{
id: id,
type: "background",
layout: {
visibility: "visible",
},
},
],
}); });
}); });
}); });
describe("when selecting a layer", () => { describe("when selecting a layer", () => {
let secondId: string; let secondId: string;
beforeEach(async () => { beforeEach(() => {
await when.modal.open(); when.modal.open();
secondId = await when.modal.fillLayers({ secondId = when.modal.fillLayers({
id: "second-layer", id: "second-layer",
type: "background", type: "background",
}); });
}); });
test("should show the selected layer in the editor", async () => { test("should show the selected layer in the editor", () => {
await when.realClick("layer-list-item:" + secondId); when.realClick("layer-list-item:" + secondId);
await then(get.elementByTestId("layer-editor.layer-id.input")).shouldHaveValue(secondId); then(get.elementByTestId("layer-editor.layer-id.input")).shouldHaveValue(secondId);
await when.realClick("layer-list-item:" + id); when.realClick("layer-list-item:" + id);
await then(get.elementByTestId("layer-editor.layer-id.input")).shouldHaveValue(id); then(get.elementByTestId("layer-editor.layer-id.input")).shouldHaveValue(id);
}); });
}); });
}); });
}); });
describe("background", () => { describe("background", () => {
test("add", async () => { test("add", () => {
const id = await when.modal.fillLayers({ type: "background" }); const id = when.modal.fillLayers({
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ type: "background",
layers: [{ id, type: "background" }], });
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "background",
},
],
}); });
}); });
test.skip("modify", () => {}); describe("modify", () => {});
}); });
describe("fill", () => { describe("fill", () => {
test("add", async () => { test("add", () => {
const id = await when.modal.fillLayers({ type: "fill", layer: "example" }); const id = when.modal.fillLayers({
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ type: "fill",
layers: [{ id, type: "fill", source: "example" }], layer: "example",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "fill",
source: "example",
},
],
}); });
}); });
// TODO: Change source // TODO: Change source
test.skip("change source", () => {}); test("change source");
}); });
describe("line", () => { describe("line", () => {
test("add", async () => { test("add", () => {
const id = await when.modal.fillLayers({ type: "line", layer: "example" }); const id = when.modal.fillLayers({
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ type: "line",
layers: [{ id, type: "line", source: "example" }], layer: "example",
});
}); });
test("groups", async () => { then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
const id1 = await when.modal.fillLayers({ id: "aa", type: "line", layer: "example" });
await when.modal.open();
const id2 = await when.modal.fillLayers({ id: "aa-2", type: "line", layer: "example" });
await when.modal.open();
const id3 = await when.modal.fillLayers({ id: "b", type: "line", layer: "example" });
await then(get.elementByTestId("layer-list-item:" + id1)).shouldBeVisible();
await then(get.elementByTestId("layer-list-item:" + id2)).shouldNotBeVisible();
await then(get.elementByTestId("layer-list-item:" + id3)).shouldBeVisible();
await when.click("layer-list-group:aa-0");
await then(get.elementByTestId("layer-list-item:" + id1)).shouldBeVisible();
await then(get.elementByTestId("layer-list-item:" + id2)).shouldBeVisible();
await then(get.elementByTestId("layer-list-item:" + id3)).shouldBeVisible();
await when.click("layer-list-item:" + id2);
await when.click("skip-target-layer-editor");
await when.click("menu-move-layer-down");
await then(get.elementByTestId("layer-list-group:aa-0")).shouldNotExist();
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [
{ id: "aa", type: "line", source: "example" }, {
{ id: "b", type: "line", source: "example" }, id: id,
{ id: "aa-2", type: "line", source: "example" }, type: "line",
source: "example",
},
],
});
});
test("groups", () => {
when.modal.open();
const id1 = when.modal.fillLayers({
id: "aa",
type: "line",
layer: "example",
});
when.modal.open();
const id2 = when.modal.fillLayers({
id: "aa-2",
type: "line",
layer: "example",
});
when.modal.open();
const id3 = when.modal.fillLayers({
id: "b",
type: "line",
layer: "example",
});
then(get.elementByTestId("layer-list-item:" + id1)).shouldBeVisible();
then(get.elementByTestId("layer-list-item:" + id2)).shouldNotBeVisible();
then(get.elementByTestId("layer-list-item:" + id3)).shouldBeVisible();
when.click("layer-list-group:aa-0");
then(get.elementByTestId("layer-list-item:" + id1)).shouldBeVisible();
then(get.elementByTestId("layer-list-item:" + id2)).shouldBeVisible();
then(get.elementByTestId("layer-list-item:" + id3)).shouldBeVisible();
when.click("layer-list-item:" + id2);
when.click("skip-target-layer-editor");
when.click("menu-move-layer-down");
then(get.elementByTestId("layer-list-group:aa-0")).shouldNotExist();
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "aa",
type: "line",
source: "example",
},
{
id: "b",
type: "line",
source: "example",
},
{
id: "aa-2",
type: "line",
source: "example",
},
], ],
}); });
}); });
}); });
describe("symbol", () => { describe("symbol", () => {
test("add", async () => { test("add", () => {
const id = await when.modal.fillLayers({ type: "symbol", layer: "example" }); const id = when.modal.fillLayers({
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ type: "symbol",
layers: [{ id, type: "symbol", source: "example" }], layer: "example",
});
}); });
test("should show spec info when hovering and clicking single line property", async () => { then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
await when.modal.fillLayers({ type: "symbol", layer: "example" });
await when.hover("spec-field-container:text-rotate");
await then(get.elementByTestId("field-doc-button-Rotate")).shouldBeVisible();
await when.click("field-doc-button-Rotate", 0);
await then(get.elementByTestId("spec-field-doc")).shouldContainText("Rotates the ");
});
test("should show spec info when hovering and clicking multi line property", async () => {
await when.modal.fillLayers({ type: "symbol", layer: "example" });
await when.hover("spec-field-container:text-offset");
await then(get.elementByTestId("field-doc-button-Offset")).shouldBeVisible();
await when.click("field-doc-button-Offset", 0);
await then(get.elementByTestId("spec-field-doc")).shouldContainText("Offset distance");
});
test("should hide spec info when clicking a second time", async () => {
await when.modal.fillLayers({ type: "symbol", layer: "example" });
await when.hover("spec-field-container:text-rotate");
await then(get.elementByTestId("field-doc-button-Rotate")).shouldBeVisible();
await when.click("field-doc-button-Rotate", 0);
await when.wait(200);
await when.click("field-doc-button-Rotate", 0);
await then(get.elementByTestId("spec-field-doc")).shouldNotBeVisible();
});
});
describe("raster", () => {
test("add", async () => {
const id = await when.modal.fillLayers({ type: "raster", layer: "raster" });
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "raster", source: "raster" }],
});
});
});
describe("circle", () => {
test("add", async () => {
const id = await when.modal.fillLayers({ type: "circle", layer: "example" });
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "circle", source: "example" }],
});
});
});
describe("fill extrusion", () => {
test("add", async () => {
const id = await when.modal.fillLayers({ type: "fill-extrusion", layer: "example" });
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "fill-extrusion", source: "example" }],
});
});
});
describe("hillshade", () => {
test("add", async () => {
const id = await when.modal.fillLayers({ type: "hillshade", layer: "example" });
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "hillshade", source: "example" }],
});
});
test("set hillshade illumination direction array", async () => {
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: "symbol",
source: "example", source: "example",
paint: { "hillshade-illumination-direction": [1, 2, 3, 4] },
}, },
], ],
}); });
}); });
test("set hillshade highlight color array", async () => { test("should show spec info when hovering and clicking single line property", () => {
const id = await when.modal.fillLayers({ type: "hillshade", layer: "example" }); when.modal.fillLayers({
await when.collapseGroupInLayerEditor(); type: "symbol",
await when.setValueToPropertyArray("spec-field:hillshade-highlight-color", "blue"); layer: "example",
await when.addValueToPropertyArray("spec-field:hillshade-highlight-color", "#00ff00"); });
await when.addValueToPropertyArray("spec-field:hillshade-highlight-color", "rgba(255, 255, 0, 1)");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ when.hover("spec-field-container:text-rotate");
then(get.elementByTestId("field-doc-button-Rotate")).shouldBeVisible();
when.click("field-doc-button-Rotate", 0);
then(get.elementByTestId("spec-field-doc")).shouldContainText("Rotates the ");
});
test("should show spec info when hovering and clicking multi line property", () => {
when.modal.fillLayers({
type: "symbol",
layer: "example",
});
when.hover("spec-field-container:text-offset");
then(get.elementByTestId("field-doc-button-Offset")).shouldBeVisible();
when.click("field-doc-button-Offset", 0);
then(get.elementByTestId("spec-field-doc")).shouldContainText("Offset distance");
});
test("should hide spec info when clicking a second time", () => {
when.modal.fillLayers({
type: "symbol",
layer: "example",
});
when.hover("spec-field-container:text-rotate");
then(get.elementByTestId("field-doc-button-Rotate")).shouldBeVisible();
when.click("field-doc-button-Rotate", 0);
when.wait(200);
when.click("field-doc-button-Rotate", 0);
then(get.elementByTestId("spec-field-doc")).shouldNotBeVisible();
});
});
describe("raster", () => {
test("add", () => {
const id = when.modal.fillLayers({
type: "raster",
layer: "raster",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [
{ {
id, id: id,
type: "raster",
source: "raster",
},
],
});
});
});
describe("circle", () => {
test("add", () => {
const id = when.modal.fillLayers({
type: "circle",
layer: "example",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "circle",
source: "example",
},
],
});
});
});
describe("fill extrusion", () => {
test("add", () => {
const id = when.modal.fillLayers({
type: "fill-extrusion",
layer: "example",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "fill-extrusion",
source: "example",
},
],
});
});
});
describe("hillshade", () => {
test("add", () => {
const id = when.modal.fillLayers({
type: "hillshade",
layer: "example",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "hillshade",
source: "example",
},
],
});
});
test("set hillshade illumination direction array", () => {
const id = when.modal.fillLayers({
type: "hillshade",
layer: "example",
});
when.collapseGroupInLayerEditor();
when.collapseGroupInLayerEditor(1);
when.setValueToPropertyArray("spec-field:hillshade-illumination-direction", "1");
when.addValueToPropertyArray("spec-field:hillshade-illumination-direction", "2");
when.addValueToPropertyArray("spec-field:hillshade-illumination-direction", "3");
when.addValueToPropertyArray("spec-field:hillshade-illumination-direction", "4");
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "hillshade", type: "hillshade",
source: "example", source: "example",
paint: { paint: {
"hillshade-highlight-color": ["blue", "#00ff00", "rgba(255, 255, 0, 1)"], "hillshade-illumination-direction": [ 1, 2, 3, 4 ]
}
}, },
],
});
});
test("set hillshade highlight color array", () => {
const id = when.modal.fillLayers({
type: "hillshade",
layer: "example",
});
when.collapseGroupInLayerEditor();
when.setValueToPropertyArray("spec-field:hillshade-highlight-color", "blue");
when.addValueToPropertyArray("spec-field:hillshade-highlight-color", "#00ff00");
when.addValueToPropertyArray("spec-field:hillshade-highlight-color", "rgba(255, 255, 0, 1)");
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "hillshade",
source: "example",
paint: {
"hillshade-highlight-color": [ "blue", "#00ff00", "rgba(255, 255, 0, 1)" ]
}
}, },
], ],
}); });
@@ -267,85 +414,129 @@ describe("layers list", () => {
}); });
describe("color-relief", () => { describe("color-relief", () => {
test("add", async () => { test("add", () => {
const id = await when.modal.fillLayers({ type: "color-relief", layer: "example" }); const id = when.modal.fillLayers({
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ type: "color-relief",
layers: [{ id, type: "color-relief", source: "example" }], layer: "example",
});
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: id,
type: "color-relief",
source: "example",
},
],
}); });
}); });
test("adds elevation expression when clicking the elevation button", async () => { test("adds elevation expression when clicking the elevation button", () => {
await when.modal.fillLayers({ type: "color-relief", layer: "example" }); when.modal.fillLayers({
await when.collapseGroupInLayerEditor(); type: "color-relief",
await when.click("make-elevation-function"); layer: "example",
await then( });
get.element("[data-wd-key='spec-field-container:color-relief-color'] .cm-line") when.collapseGroupInLayerEditor();
).shouldBeVisible(); when.click("make-elevation-function");
then(get.element("[data-wd-key='spec-field-container:color-relief-color'] .cm-line")).shouldBeVisible();
}); });
}); });
describe("groups", () => { describe("groups", () => {
test("simple", async () => { test("simple", () => {
await when.setStyle("geojson"); when.setStyle("geojson");
await when.modal.open(); when.modal.open();
await when.modal.fillLayers({ id: "foo", type: "background" }); when.modal.fillLayers({
id: "foo",
type: "background",
});
await when.modal.open(); when.modal.open();
await when.modal.fillLayers({ id: "foo_bar", type: "background" }); when.modal.fillLayers({
id: "foo_bar",
type: "background",
});
await when.modal.open(); when.modal.open();
await when.modal.fillLayers({ id: "foo_bar_baz", type: "background" }); when.modal.fillLayers({
id: "foo_bar_baz",
type: "background",
});
await then(get.elementByTestId("layer-list-item:foo")).shouldBeVisible(); then(get.elementByTestId("layer-list-item:foo")).shouldBeVisible();
await then(get.elementByTestId("layer-list-item:foo_bar")).shouldNotBeVisible(); then(get.elementByTestId("layer-list-item:foo_bar")).shouldNotBeVisible();
await then(get.elementByTestId("layer-list-item:foo_bar_baz")).shouldNotBeVisible(); then(
await when.click("layer-list-group:foo-0"); get.elementByTestId("layer-list-item:foo_bar_baz")
await then(get.elementByTestId("layer-list-item:foo")).shouldBeVisible(); ).shouldNotBeVisible();
await then(get.elementByTestId("layer-list-item:foo_bar")).shouldBeVisible(); when.click("layer-list-group:foo-0");
await then(get.elementByTestId("layer-list-item:foo_bar_baz")).shouldBeVisible(); 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();
}); });
}); });
describe("drag and drop", () => { describe("drag and drop", () => {
test("move layer should update local storage", async () => { test("move layer should update local storage", () => {
const firstId = await when.modal.fillLayers({ id: "a", type: "background" }); when.modal.open();
await when.modal.open(); const firstId = when.modal.fillLayers({
const secondId = await when.modal.fillLayers({ id: "b", type: "background" }); id: "a",
await when.modal.open(); type: "background",
const thirdId = await when.modal.fillLayers({ id: "c", type: "background" }); });
when.modal.open();
const secondId = when.modal.fillLayers({
id: "b",
type: "background",
});
when.modal.open();
const thirdId = when.modal.fillLayers({
id: "c",
type: "background",
});
await when.dragAndDropWithWait("layer-list-item:" + firstId, "layer-list-item:" + thirdId); when.dragAndDropWithWait("layer-list-item:" + firstId, "layer-list-item:" + thirdId);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [
{ id: secondId, type: "background" }, {
{ id: thirdId, type: "background" }, id: secondId,
{ id: firstId, type: "background" }, type: "background",
},
{
id: thirdId,
type: "background",
},
{
id: firstId,
type: "background",
},
], ],
}); });
}); });
}); });
describe("sticky header", () => { describe("sticky header", () => {
test("should keep header visible when scrolling layer list", async () => { test("should keep header visible when scrolling layer list", () => {
// Setup: Create multiple layers to enable scrolling // Setup: Create multiple layers to enable scrolling
// The modal is already open (beforeEach) for the first layer. for (let i = 0; i < 20; i++) {
await when.modal.fillLayers({ id: "layer-0", type: "background" }); when.modal.open();
for (let i = 1; i < 20; i++) { when.modal.fillLayers({
await when.modal.open(); id: `layer-${i}`,
await when.modal.fillLayers({ id: `layer-${i}`, type: "background" }); type: "background",
});
} }
await when.wait(500); when.wait(500);
const header = get.elementByTestId("layer-list.header"); const header = get.elementByTestId("layer-list.header");
await then(header).shouldBeVisible(); then(header).shouldBeVisible();
// Scroll the layer list container // Scroll the layer list container (use ensureScrollable: false to avoid flakiness)
await when.scrollToBottom(get.elementByTestId("layer-list")); get.elementByTestId("layer-list").scrollTo("bottom", { ensureScrollable: false });
await when.wait(200); when.wait(200);
await then(header).shouldBeVisible(); then(header).shouldBeVisible();
await then(get.elementByTestId("layer-list:add-layer")).shouldBeVisible(); then(get.elementByTestId("layer-list:add-layer")).shouldBeVisible();
}); });
}); });
}); });
+43 -41
View File
@@ -1,67 +1,69 @@
import { beforeEach, describe, test } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver"; import { MaputnikDriver } from "./maputnik-driver";
const test = it;
describe("map", () => { describe("map", () => {
const { given, get, when, then } = new MaputnikDriver(); const { beforeAndAfter, get, when, then } = new MaputnikDriver();
beforeAndAfter();
beforeEach(async () => {
await given.setupMockBackedResponses();
await when.setStyle("both");
});
describe("zoom level", () => { describe("zoom level", () => {
test("via url", async () => { test("via url", () => {
const zoomLevel = 12.37; const zoomLevel = 12.37;
await when.setStyle("geojson", zoomLevel); when.setStyle("geojson", zoomLevel);
await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible(); then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible();
await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText("Zoom: " + zoomLevel); then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText(
"Zoom: " + zoomLevel
);
}); });
test("via map controls", async () => { test("via map controls", () => {
const zoomLevel = 12.37; const zoomLevel = 12.37;
await when.setStyle("geojson", zoomLevel); when.setStyle("geojson", zoomLevel);
await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible(); then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible();
await when.clickZoomIn(); when.clickZoomIn();
await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText("Zoom: " + (zoomLevel + 1)); then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText(
"Zoom: " + (zoomLevel + 1)
);
}); });
test("via style file definition", async () => { test("via style file definition", () => {
await when.setStyle("zoom_7_center_0_51"); when.setStyle("zoom_7_center_0_51");
await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible(); then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible();
await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText("Zoom: " + 7); then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText(
await then(get.locationHash()).shouldInclude("#7/51/0"); "Zoom: " + (7)
);
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
await when.openASecondStyleWithDifferentZoomAndCenter(); when.openASecondStyleWithDifferentZoomAndCenter();
await then(get.locationHash()).shouldInclude("#7/51/0"); then(get.locationHash().should("contain", "#7/51/0"));
}); });
}); });
describe("search", () => { describe("search", () => {
test("should exist", async () => { test("should exist", () => {
await then(get.searchControl()).shouldBeVisible(); then(get.searchControl()).shouldBeVisible();
}); });
}); });
describe("popup", () => { describe("popup", () => {
beforeEach(async () => { beforeEach(() => {
await when.setStyle("rectangles"); when.setStyle("rectangles");
await then(get.locationHash()).shouldExist(); then(get.locationHash().should("exist"));
});
test("should open on feature click", () => {
when.clickCenter("maplibre:map");
then(get.elementByTestId("feature-layer-popup")).shouldBeVisible();
}); });
test("should open on feature click", async () => { test("should open a second feature after closing popup", () => {
await when.clickCenter("maplibre:map"); when.clickCenter("maplibre:map");
await then(get.elementByTestId("feature-layer-popup")).shouldBeVisible(); then(get.elementByTestId("feature-layer-popup")).shouldBeVisible();
}); when.closePopup();
then(get.elementByTestId("feature-layer-popup")).shouldNotExist();
test("should open a second feature after closing popup", async () => { when.clickCenter("maplibre:map");
await when.clickCenter("maplibre:map"); then(get.elementByTestId("feature-layer-popup")).shouldBeVisible();
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();
}); });
}); });
}); });
+56
View File
@@ -0,0 +1,56 @@
/// <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;
}
+214 -134
View File
@@ -1,55 +1,122 @@
import { PlaywrightHelper } from "./playwright-helper"; /// <reference types="cypress-plugin-tab" />
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 isMac = process.platform === "darwin";
/** const styleFromWindow = (win: Window) => {
* The maputnik-specific driver. It builds on the generic {@link PlaywrightHelper} const styleId = win.localStorage.getItem("maputnik:latest_style");
* — spreading its `given`/`when`/`get` primitives and adding domain concepts const styleItemKey = `maputnik:style:${styleId}`;
* (loading a style, the add-layer modal, the JSON editor, …). All Playwright const styleItem = win.localStorage.getItem(styleItemKey);
* access goes through the helper; the driver never touches `page` directly.
*/
export class MaputnikDriver {
private readonly helper = new PlaywrightHelper();
private readonly modalDriver = new ModalDriver();
then = this.helper.then;
/** Reads the maputnik style currently persisted in localStorage. */
private async readStoredStyle(): Promise<any> {
const styleId = await this.helper.get.localStorageItem("maputnik:latest_style");
const styleItem = await this.helper.get.localStorageItem(`maputnik:style:${styleId}`);
if (!styleItem) throw new Error("Could not get styleItem from localStorage"); if (!styleItem) throw new Error("Could not get styleItem from localStorage");
return JSON.parse(styleItem); const obj = JSON.parse(styleItem);
return obj;
};
export class MaputnikAssertable<T> extends Assertable<T> {
shouldEqualToStoredStyle = () =>
then(
new CypressHelper().get.window().then((win: Window) => {
const style = styleFromWindow(win);
then(this.chainable).shouldDeepNestedInclude(style);
})
);
} }
export class MaputnikDriver {
private helper = new MaputnikCypressHelper();
private modalDriver = new ModalDriver();
public beforeAndAfter = () => {
beforeEach(() => {
this.given.setupMockBackedResponses();
this.when.setStyle("both");
});
};
public then = (chainable: Cypress.Chainable<any>) =>
new MaputnikAssertable(chainable);
public given = { public given = {
...this.helper.given, ...this.helper.given,
setupMockBackedResponses: () => {
setupMockBackedResponses: async () => { this.helper.given.interceptAndMockResponse({
const styleFixtures = [
"example-style.json",
"example-layer-style.json",
"geojson-style.json",
"raster-style.json",
"geojson-raster-style.json",
"rectangles-style.json",
"example-style-with-fonts.json",
"example-style-with-zoom-7-and-center-0-51.json",
"example-style-with-zoom-5-and-center-50-50.json",
];
for (const fixture of styleFixtures) {
await this.helper.given.interceptAndMockResponse({
method: "GET", method: "GET",
url: baseUrl + fixture, url: baseUrl + "example-style.json",
response: { fixture }, response: {
alias: fixture === "example-style.json" ? "example-style.json" : undefined, fixture: "example-style.json",
},
alias: "example-style.json",
}); });
} this.helper.given.interceptAndMockResponse({
await this.helper.given.interceptAndMockResponse({ method: "GET", url: /example\.local\//, response: [] }); method: "GET",
await this.helper.given.interceptAndMockResponse({ method: "GET", url: /example\.com\//, response: [] }); url: baseUrl + "example-layer-style.json",
await this.helper.given.interceptAndMockResponse({ response: {
fixture: "example-layer-style.json",
},
});
this.helper.given.interceptAndMockResponse({
method: "GET",
url: baseUrl + "geojson-style.json",
response: {
fixture: "geojson-style.json",
},
});
this.helper.given.interceptAndMockResponse({
method: "GET",
url: baseUrl + "raster-style.json",
response: {
fixture: "raster-style.json",
},
});
this.helper.given.interceptAndMockResponse({
method: "GET",
url: baseUrl + "geojson-raster-style.json",
response: {
fixture: "geojson-raster-style.json",
},
});
this.helper.given.interceptAndMockResponse({
method: "GET",
url: baseUrl + "rectangles-style.json",
response: {
fixture: "rectangles-style.json",
},
});
this.helper.given.interceptAndMockResponse({
method: "GET",
url: baseUrl + "example-style-with-fonts.json",
response: {
fixture: "example-style-with-fonts.json",
},
});
this.helper.given.interceptAndMockResponse({
method: "GET",
url: baseUrl + "example-style-with-zoom-7-and-center-0-51.json",
response: {
fixture: "example-style-with-zoom-7-and-center-0-51.json",
},
});
this.helper.given.interceptAndMockResponse({
method: "GET",
url: baseUrl + "example-style-with-zoom-5-and-center-50-50.json",
response: {
fixture: "example-style-with-zoom-5-and-center-50-50.json",
},
});
this.helper.given.interceptAndMockResponse({
method: "GET",
url: "*example.local/*",
response: [],
});
this.helper.given.interceptAndMockResponse({
method: "GET",
url: "*example.com/*",
response: [],
});
this.helper.given.interceptAndMockResponse({
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"],
@@ -59,134 +126,147 @@ export class MaputnikDriver {
public when = { public when = {
...this.helper.when, ...this.helper.when,
modal: this.modalDriver.when, modal: this.modalDriver.when,
doWithin: (selector: string, fn: () => void) => {
setStyle: async ( this.helper.when.doWithin(fn, selector);
styleProperties: },
| "geojson" tab: () => this.helper.get.element("body").tab(),
| "raster" waitForExampleFileResponse: () => {
| "both" this.helper.when.waitForResponse("example-style.json");
| "layer" },
| "rectangles" openASecondStyleWithDifferentZoomAndCenter: () => {
| "font" cy.contains("button", "Open").click();
| "zoom_7_center_0_51" cy.get('[data-wd-key="modal:open.url.input"]')
| "", .should("be.enabled")
.clear()
.type("http://localhost:8888/example-style-with-zoom-5-and-center-50-50.json{enter}");
},
chooseExampleFile: () => {
this.helper.given.fixture("example-style.json", "example-style.json");
this.helper.when.openFileByFixture("example-style.json", "modal:open.dropzone", "modal:open.file.input");
this.helper.when.wait(200);
},
dropExampleFile: () => {
this.helper.given.fixture("example-style.json", "example-style.json");
this.helper.when.dropFileByFixture("example-style.json", "modal:open.dropzone");
this.helper.when.wait(200);
},
setStyle: (
styleProperties: "geojson" | "raster" | "both" | "layer" | "rectangles" | "font" | "zoom_7_center_0_51" | "",
zoom?: number zoom?: number
) => { ) => {
const styleFileByKey: Record<string, string> = {
geojson: "geojson-style.json",
raster: "raster-style.json",
both: "geojson-raster-style.json",
layer: "example-layer-style.json",
rectangles: "rectangles-style.json",
font: "example-style-with-fonts.json",
zoom_7_center_0_51: "example-style-with-zoom-7-and-center-0-51.json",
};
const url = new URL(baseUrl); const url = new URL(baseUrl);
if (styleProperties && styleFileByKey[styleProperties]) { switch (styleProperties) {
url.searchParams.set("style", baseUrl + styleFileByKey[styleProperties]); case "geojson":
url.searchParams.set("style", baseUrl + "geojson-style.json");
break;
case "raster":
url.searchParams.set("style", baseUrl + "raster-style.json");
break;
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;
} }
if (zoom) { if (zoom) {
url.hash = `${zoom}/41.3805/2.1635`; url.hash = `${zoom}/41.3805/2.1635`;
} }
this.helper.when.visit(url.toString());
await this.helper.when.visit(url.toString()); if (styleProperties) {
this.helper.when.acceptConfirm();
}
// when methods should not include assertions
const toolbarLink = this.helper.get.elementByTestId("toolbar:link"); const toolbarLink = this.helper.get.elementByTestId("toolbar:link");
await toolbarLink.scrollIntoViewIfNeeded(); toolbarLink.scrollIntoView();
await this.then(toolbarLink).shouldBeVisible(); toolbarLink.should("be.visible");
}, },
openASecondStyleWithDifferentZoomAndCenter: async () => { typeKeys: (keys: string) => this.helper.get.element("body").type(keys),
await this.helper.when.clickButtonByName("Open");
const input = this.helper.get.elementByTestId("modal:open.url.input"); clickZoomIn: () => {
await input.fill("http://localhost:8888/example-style-with-zoom-5-and-center-50-50.json"); this.helper.get.element(".maplibregl-ctrl-zoom-in").click();
await input.press("Enter");
}, },
chooseExampleFile: async () => { selectWithin: (selector: string, value: string) => {
await this.helper.when.openFileByFixture("example-style.json", "modal:open.dropzone", "modal:open.file.input"); this.when.doWithin(selector, () => {
await this.helper.when.wait(200); this.helper.get.element("select").select(value);
});
}, },
dropExampleFile: async () => { select: (selector: string, value: string) => {
await this.helper.when.dropFileByFixture("example-style.json", "modal:open.dropzone"); this.helper.get.elementByTestId(selector).select(value);
await this.helper.when.wait(200);
}, },
clickZoomIn: async () => { focus: (selector: string) => {
await this.helper.get.element(".maplibregl-ctrl-zoom-in").click(); this.helper.when.focus(selector);
}, },
closePopup: async () => { setValue: (selector: string, text: string) => {
await this.helper.get.element(".maplibregl-popup-close-button").click(); this.helper.get
.elementByTestId(selector)
.clear()
.type(text, { parseSpecialCharSequences: false });
}, },
collapseGroupInLayerEditor: async (index = 0) => { setValueToPropertyArray: (selector: string, value: string) => {
await this.helper.get.element(".maputnik-layer-editor-group__button").nth(index).click(); this.when.doWithin(selector, () => {
this.helper.get.element(".maputnik-array-block-content input").last().type("{selectall}"+value, {force: true });
});
}, },
appendTextInJsonEditor: async (text: string) => { addValueToPropertyArray: (selector: string, value: string) => {
await this.helper.get.element(".cm-line").first().click(); this.when.doWithin(selector, () => {
// Move to the very start of the document so the inserted text breaks the this.helper.get.element(".maputnik-array-add-value").click({ force: true });
// root JSON structure (CodeMirror auto-closes brackets otherwise). this.helper.get.element(".maputnik-array-block-content input").last().type("{selectall}"+value, {force: true });
await this.helper.when.typeKeys("{home}"); });
await this.helper.when.typeText(text);
}, },
setTextInJsonEditor: async (text: string) => { closePopup: () => {
await this.helper.get.element(".cm-line").first().click(); this.helper.get.element(".maplibregl-popup-close-button").click();
await this.helper.when.typeKeys("{selectall}");
await this.helper.when.typeText(text);
}, },
setValueToPropertyArray: async (selector: string, value: string) => { collapseGroupInLayerEditor: (index = 0) => {
const input = this.helper.get.elementByTestId(selector).locator(".maputnik-array-block-content input").last(); this.helper.get.element(".maputnik-layer-editor-group__button").eq(index).realClick();
await input.focus();
await this.helper.when.typeKeys("{selectall}");
await this.helper.when.typeText(value);
}, },
addValueToPropertyArray: async (selector: string, value: string) => { appendTextInJsonEditor: (text: string) => {
const block = this.helper.get.elementByTestId(selector); this.helper.get.element(".cm-line").first().click().type(text, { parseSpecialCharSequences: false });
await block.locator(".maputnik-array-add-value").click();
const input = block.locator(".maputnik-array-block-content input").last();
await input.focus();
await this.helper.when.typeKeys("{selectall}");
await this.helper.when.typeText(value);
}, },
waitForExampleFileResponse: () => this.helper.when.waitForResponse("example-style.json"), setTextInJsonEditor: (text: string) => {
this.helper.get.element(".cm-line").first().click().clear().type(text, { parseSpecialCharSequences: false });
/** Fill localStorage until we get a QuotaExceededError. */ }
fillLocalStorage: () => this.helper.when.fillLocalStorageUntilQuota("maputnik:fill-"),
}; };
public get = { public get = {
...this.helper.get, ...this.helper.get,
isMac: () => {
isMac: () => isMac, return Cypress.platform === "darwin";
canvas: () => this.helper.get.element("canvas"),
searchControl: () => this.helper.get.element(".maplibregl-ctrl-geocoder"),
skipTargetLayerList: () => this.helper.get.elementByTestId("skip-target-layer-list"),
skipTargetLayerEditor: () => this.helper.get.elementByTestId("skip-target-layer-editor"),
styleFromLocalStorage: () => this.helper.query(() => this.readStoredStyle()),
fixture: (name: string) => this.helper.readFixture(name),
responseBody: (alias: string) => {
// Our mocked style responses always return the matching fixture.
const name = alias.endsWith(".json") ? alias : `${alias}.json`;
return this.helper.readFixture(name);
}, },
exampleFileUrl: () => baseUrl + "example-style.json", styleFromLocalStorage: () =>
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")
}; };
} }
+26 -24
View File
@@ -1,38 +1,40 @@
import { v1 as uuid } from "uuid"; import { v1 as uuid } from "uuid";
import { PlaywrightHelper } from "./playwright-helper"; import MaputnikCypressHelper from "./maputnik-cypress-helper";
export class ModalDriver { export default class ModalDriver {
private readonly helper = new PlaywrightHelper(); private helper = new MaputnikCypressHelper();
public when = { public when = {
fillLayers: async (opts: { type: string; layer?: string; id?: string }) => { fillLayers: (opts: { type: string; layer?: string; id?: string }) => {
const { when, get, then } = this.helper; // Having logic in test code is an anti pattern.
const id = opts.id ?? `${opts.type}:${uuid()}`; // This should be split to multiple single responsibility functions
const type = opts.type;
await when.select("add-layer.layer-type.select", opts.type); const layer = opts.layer;
await when.type("add-layer.layer-id.input", id); let id;
if (opts.id) {
if (opts.layer) { id = opts.id;
const input = get.elementByTestId("add-layer.layer-source-block").locator("input"); } else {
await input.click(); id = `${type}:${uuid()}`;
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 then(input).shouldHaveValue(opts.layer);
// Close the autocomplete menu so it does not intercept the add button.
await get.elementByTestId("add-layer.layer-id.input").click();
} }
await when.click("add-layer"); this.helper.when.selectOption("add-layer.layer-type.select", type);
this.helper.when.type("add-layer.layer-id.input", id);
if (layer) {
this.helper.when.doWithin(() => {
this.helper.get.element("input").clear().type(layer!);
}, "add-layer.layer-source-block");
}
this.helper.when.click("add-layer");
return id; return id;
}, },
open: async () => { open: () => {
await this.helper.when.click("layer-list:add-layer"); this.helper.when.click("layer-list:add-layer");
}, },
close: async (key: string) => { close: (key: string) => {
await this.helper.when.click(key + ".close-modal"); this.helper.when.click(key + ".close-modal");
}, },
}; };
} }
+270 -218
View File
@@ -1,106 +1,103 @@
import { test, expect, describe, beforeEach } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver"; import { MaputnikDriver } from "./maputnik-driver";
import tokens from "../src/config/tokens.json" with {type: "json"}; import tokens from "../src/config/tokens.json" with {type: "json"};
const test = it;
describe("modals", () => { describe("modals", () => {
const { given, get, when, then } = new MaputnikDriver(); const { beforeAndAfter, when, get, given, then } = new MaputnikDriver();
beforeAndAfter();
beforeEach(async () => { beforeEach(() => {
await given.setupMockBackedResponses(); when.setStyle("");
await when.setStyle("both");
await when.setStyle("");
}); });
describe("open", () => { describe("open", () => {
beforeEach(async () => { beforeEach(() => {
await when.click("nav:open"); when.click("nav:open");
}); });
test("close", async () => { test("close", () => {
await when.modal.close("modal:open"); when.modal.close("modal:open");
await then(get.elementByTestId("modal:open")).shouldNotExist(); then(get.elementByTestId("modal:open")).shouldNotExist();
}); });
test("upload", async () => { test("upload", () => {
await when.chooseExampleFile(); when.chooseExampleFile();
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.fixture("example-style.json")); then(get.fixture("example-style.json")).shouldEqualToStoredStyle();
}); });
test("upload via drag and drop", async () => { test("upload via drag and drop", () => {
await when.dropExampleFile(); when.dropExampleFile();
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.fixture("example-style.json")); then(get.fixture("example-style.json")).shouldEqualToStoredStyle();
}); });
describe("when click open url", () => { describe("when click open url", () => {
beforeEach(async () => { beforeEach(() => {
const styleFileUrl = get.exampleFileUrl(); const styleFileUrl = get.exampleFileUrl();
await when.setValue("modal:open.url.input", styleFileUrl); when.setValue("modal:open.url.input", styleFileUrl);
await when.click("modal:open.url.button"); when.click("modal:open.url.button");
await when.wait(200); when.wait(200);
}); });
test("load from url", async () => { test("load from url", () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.responseBody("example-style.json")); then(get.responseBody("example-style.json")).shouldEqualToStoredStyle();
}); });
}); });
}); });
describe("shortcuts", () => { describe("shortcuts", () => {
test("open/close", async () => { test("open/close", () => {
await when.setStyle(""); when.setStyle("");
await when.typeKeys("?"); when.typeKeys("?");
await when.modal.close("modal:shortcuts"); when.modal.close("modal:shortcuts");
await then(get.elementByTestId("modal:shortcuts")).shouldNotExist(); then(get.elementByTestId("modal:shortcuts")).shouldNotExist();
}); });
}); });
describe("export", () => { describe("export", () => {
beforeEach(async () => { beforeEach(() => {
await when.click("nav:export"); when.click("nav:export");
}); });
test("close", async () => { test("close", () => {
await when.modal.close("modal:export"); when.modal.close("modal:export");
await then(get.elementByTestId("modal:export")).shouldNotExist(); 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.skip("download", () => {}); test("download");
}); });
describe("sources", () => { describe("sources", () => {
beforeEach(async () => { beforeEach(() => {
await when.setStyle("layer"); when.setStyle("layer");
await when.click("nav:sources"); when.click("nav:sources");
}); });
test.skip("active sources", () => {}); test("active sources");
test.skip("public source", () => {}); test("public source");
test("add new source", async () => { test("add new source", () => {
const sourceId = "n1z2v3r"; const sourceId = "n1z2v3r";
await when.setValue("modal:sources.add.source_id", sourceId); when.setValue("modal:sources.add.source_id", sourceId);
await when.select("modal:sources.add.source_type", "tile_vector"); when.select("modal:sources.add.source_type", "tile_vector");
await when.select("modal:sources.add.scheme_type", "tms"); when.select("modal:sources.add.scheme_type", "tms");
await when.click("modal:sources.add.add_source"); when.click("modal:sources.add.add_source");
await when.wait(200); when.wait(200);
await then(get.styleFromLocalStorage().then((style) => style.sources[sourceId])).shouldInclude({ then(
get.styleFromLocalStorage().then((style) => style.sources[sourceId])
).shouldInclude({
scheme: "tms", scheme: "tms",
}); });
}); });
test("add new pmtiles source", async () => { test("add new pmtiles source", () => {
const sourceId = "pmtilestest"; const sourceId = "pmtilestest";
await when.setValue("modal:sources.add.source_id", sourceId); when.setValue("modal:sources.add.source_id", sourceId);
await when.select("modal:sources.add.source_type", "pmtiles_vector"); when.select("modal:sources.add.source_type", "pmtiles_vector");
await when.setValue( when.setValue("modal:sources.add.source_url", "https://data.source.coop/protomaps/openstreetmap/v4.pmtiles");
"modal:sources.add.source_url", when.click("modal:sources.add.add_source");
"https://data.source.coop/protomaps/openstreetmap/v4.pmtiles" when.click("modal:sources.add.add_source");
); when.wait(200);
await when.click("modal:sources.add.add_source"); then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
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",
@@ -110,296 +107,351 @@ describe("modals", () => {
}); });
}); });
test("add new raster source", async () => { test("add new raster source", () => {
const sourceId = "rastertest"; const sourceId = "rastertest";
await when.setValue("modal:sources.add.source_id", sourceId); when.setValue("modal:sources.add.source_id", sourceId);
await when.select("modal:sources.add.source_type", "tile_raster"); when.select("modal:sources.add.source_type", "tile_raster");
await when.select("modal:sources.add.scheme_type", "xyz"); when.select("modal:sources.add.scheme_type", "xyz");
await when.setValue("modal:sources.add.tile_size", "128"); when.setValue("modal:sources.add.tile_size", "128");
await when.click("modal:sources.add.add_source"); when.click("modal:sources.add.add_source");
await when.wait(200); when.wait(200);
await then(get.styleFromLocalStorage().then((style) => style.sources[sourceId])).shouldInclude({ then(
get.styleFromLocalStorage().then((style) => style.sources[sourceId])
).shouldInclude({
tileSize: 128, tileSize: 128,
}); });
}); });
}); });
describe("inspect", () => { describe("inspect", () => {
test("toggle", async () => { test("toggle", () => {
// There is no assertion in this test // There is no assertion in this test
await when.setStyle("geojson"); when.setStyle("geojson");
await when.select("maputnik-select", "inspect"); when.select("maputnik-select", "inspect");
}); });
}); });
describe("style settings", () => { describe("style settings", () => {
beforeEach(async () => { beforeEach(() => {
await when.click("nav:settings"); when.click("nav:settings");
}); });
describe("when click name filed spec information", () => { describe("when click name filed spec information", () => {
beforeEach(async () => { beforeEach(() => {
await when.click("field-doc-button-Name"); when.click("field-doc-button-Name");
}); });
test("should show the spec information", async () => { test("should show the spec information", () => {
await then(get.elementsText("spec-field-doc")).shouldInclude("name for the style"); then(get.elementsText("spec-field-doc")).shouldInclude(
"name for the style"
);
}); });
}); });
describe("when set name and click owner", () => { describe("when set name and click owner", () => {
beforeEach(async () => { beforeEach(() => {
await when.setValue("modal:settings.name", "foobar"); when.setValue("modal:settings.name", "foobar");
await when.click("modal:settings.owner"); when.click("modal:settings.owner");
await when.wait(200); when.wait(200);
}); });
test("show name specifications", async () => { test("show name specifications", () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
name: "foobar", name: "foobar",
}); });
}); });
}); });
describe("when set owner and click name", () => { describe("when set owner and click name", () => {
beforeEach(async () => { beforeEach(() => {
await when.setValue("modal:settings.owner", "foobar"); when.setValue("modal:settings.owner", "foobar");
await when.click("modal:settings.name"); when.click("modal:settings.name");
await when.wait(200); when.wait(200);
}); });
test("should update owner in local storage", async () => { test("should update owner in local storage", () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
owner: "foobar", owner: "foobar",
}); });
}); });
}); });
test("sprite url", async () => { test("sprite url", () => {
await when.setTextInJsonEditor('"http://example.com"'); when.setTextInJsonEditor("\"http://example.com\"");
await when.click("modal:settings.name"); when.click("modal:settings.name");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sprite: "http://example.com", sprite: "http://example.com",
}); });
}); });
test("sprite object", async () => { test("sprite object", () => {
await when.setTextInJsonEditor(JSON.stringify([{ id: "1", url: "2" }])); when.setTextInJsonEditor(JSON.stringify([{ id: "1", url: "2" }]));
await when.click("modal:settings.name"); when.click("modal:settings.name");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sprite: [{ id: "1", url: "2" }], sprite: [{ id: "1", url: "2" }],
}); });
}); });
test("glyphs url", async () => { test("glyphs url", () => {
const glyphsUrl = "http://example.com/{fontstack}/{range}.pbf"; const glyphsUrl = "http://example.com/{fontstack}/{range}.pbf";
await when.setValue("modal:settings.glyphs", glyphsUrl); when.setValue("modal:settings.glyphs", glyphsUrl);
await when.click("modal:settings.name"); when.click("modal:settings.name");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
glyphs: glyphsUrl, glyphs: glyphsUrl,
}); });
}); });
test("maptiler access token", async () => { test("maptiler access token", () => {
const apiKey = "testing123"; const apiKey = "testing123";
await when.setValue("modal:settings.maputnik:openmaptiles_access_token", apiKey); when.setValue(
await when.click("modal:settings.name"); "modal:settings.maputnik:openmaptiles_access_token",
await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({ apiKey
);
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", async () => { test("thunderforest access token", () => {
const apiKey = "testing123"; const apiKey = "testing123";
await when.setValue("modal:settings.maputnik:thunderforest_access_token", apiKey); when.setValue(
await when.click("modal:settings.name"); "modal:settings.maputnik:thunderforest_access_token",
await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({ apiKey
"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", async () => { test("stadia access token", () => {
const apiKey = "testing123"; const apiKey = "testing123";
await when.setValue("modal:settings.maputnik:stadia_access_token", apiKey); when.setValue(
await when.click("modal:settings.name"); "modal:settings.maputnik:stadia_access_token",
await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({ apiKey
"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", async () => { test("locationiq access token", () => {
const apiKey = "testing123"; const apiKey = "testing123";
await when.setValue("modal:settings.maputnik:locationiq_access_token", apiKey); when.setValue(
await when.click("modal:settings.name"); "modal:settings.maputnik:locationiq_access_token",
await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({ apiKey
"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", async () => { test("style projection mercator", () => {
await when.select("modal:settings.projection", "mercator"); when.select("modal:settings.projection", "mercator");
await then(get.styleFromLocalStorage().then((style) => style.projection)).shouldInclude({ then(
type: "mercator", get.styleFromLocalStorage().then((style) => style.projection)
}); ).shouldInclude({ type: "mercator" });
}); });
test("style projection globe", async () => { test("style projection globe", () => {
await when.select("modal:settings.projection", "globe"); when.select("modal:settings.projection", "globe");
await then(get.styleFromLocalStorage().then((style) => style.projection)).shouldInclude({ then(
type: "globe", get.styleFromLocalStorage().then((style) => style.projection)
}); ).shouldInclude({ type: "globe" });
}); });
test("style projection vertical-perspective", async () => {
await when.select("modal:settings.projection", "vertical-perspective"); test("style projection vertical-perspective", () => {
await then(get.styleFromLocalStorage().then((style) => style.projection)).shouldInclude({ when.select("modal:settings.projection", "vertical-perspective");
type: "vertical-perspective", then(
}); get.styleFromLocalStorage().then((style) => style.projection)
).shouldInclude({ type: "vertical-perspective" });
}); });
test("style renderer", async () => { test("style renderer", () => {
await when.select("modal:settings.maputnik:renderer", "ol"); cy.on("uncaught:exception", () => false); // this is due to the fact that this is an invalid style for openlayers
await then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual("ol"); when.select("modal:settings.maputnik:renderer", "ol");
then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual(
"ol"
);
await when.click("modal:settings.name"); when.click("modal:settings.name");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
metadata: { "maputnik:renderer": "ol" }, metadata: { "maputnik:renderer": "ol" },
}); });
}); });
test("include API key when change renderer", async () => {
await when.click("modal:settings.close-modal");
await when.click("nav:open");
await when.clickByAttribute("aria-label", "MapTiler Basic");
await when.wait(1000);
await when.click("nav:settings");
await when.select("modal:settings.maputnik:renderer", "mlgljs"); test("include API key when change renderer", () => {
await then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual("mlgljs");
await when.select("modal:settings.maputnik:renderer", "ol"); when.click("modal:settings.close-modal");
await then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual("ol"); when.click("nav:open");
await given.intercept( get.elementByAttribute("aria-label", "MapTiler Basic").should("exist").click();
/https:\/\/api\.maptiler\.com\/tiles\/v3-openmaptiles\/tiles\.json\?key=.*/, when.wait(1000);
"tileRequest", when.click("nav:settings");
"GET"
when.select("modal:settings.maputnik:renderer", "mlgljs");
then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual(
"mlgljs"
); );
await when.select("modal:settings.maputnik:renderer", "mlgljs"); when.select("modal:settings.maputnik:renderer", "ol");
await then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual("mlgljs"); then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual(
"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}`);
}); });
}); });
describe("add layer", () => { describe("add layer", () => {
beforeEach(async () => { beforeEach(() => {
await when.setStyle("layer"); when.setStyle("layer");
await when.modal.open(); when.modal.open();
}); });
test("shows duplicate id error", async () => { test("shows duplicate id error", () => {
await when.setValue("add-layer.layer-id.input", "background"); when.setValue("add-layer.layer-id.input", "background");
await when.click("add-layer"); when.click("add-layer");
await then(get.elementByTestId("modal:add-layer")).shouldExist(); then(get.elementByTestId("modal:add-layer")).shouldExist();
await then(get.element(".maputnik-modal-error")).shouldContainText("Layer ID already exists"); then(get.element(".maputnik-modal-error")).shouldContainText(
"Layer ID already exists"
);
}); });
}); });
describe("sources placeholder", () => { describe("sources", () => {
test.skip("toggle", () => {}); test("toggle");
}); });
describe("global state", () => { describe("global state", () => {
beforeEach(async () => { beforeEach(() => {
await when.click("nav:global-state"); when.click("nav:global-state");
}); });
test("add variable", async () => { test("add variable", () => {
await when.wait(100); when.wait(100);
await when.click("global-state-add-variable"); when.click("global-state-add-variable");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
state: { key1: { default: "value" } }, state: { key1: { default: "value" } },
}); });
}); });
test("add multiple variables", async () => {
await when.click("global-state-add-variable"); test("add multiple variables", () => {
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.wait(100); when.click("global-state-add-variable");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ when.wait(100);
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", async () => { test("remove 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.click("global-state-add-variable");
await when.click("global-state-remove-variable", 0); when.click("global-state-remove-variable", 0);
await when.wait(100); when.wait(100);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
state: { key2: { default: "value" }, key3: { default: "value" } }, state: { key2: { default: "value" }, key3: { default: "value" } },
}); });
}); });
test("edit variable key", async () => { test("edit variable key", () => {
await when.click("global-state-add-variable"); when.click("global-state-add-variable");
await when.wait(100); when.wait(100);
await when.setValue("global-state-variable-key:0", "mykey"); when.setValue("global-state-variable-key:0", "mykey");
await when.typeKeys("{enter}"); when.typeKeys("{enter}");
await when.wait(100); when.wait(100);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
state: { mykey: { default: "value" } }, state: { mykey: { default: "value" } },
}); });
}); });
test("edit variable value", async () => { test("edit variable value", () => {
await when.click("global-state-add-variable"); when.click("global-state-add-variable");
await when.wait(100); when.wait(100);
await when.setValue("global-state-variable-value:0", "myvalue"); when.setValue("global-state-variable-value:0", "myvalue");
await when.typeKeys("{enter}"); when.typeKeys("{enter}");
await when.wait(100); when.wait(100);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
state: { key1: { default: "myvalue" } }, state: { key1: { default: "myvalue" } },
}); });
}); });
}); });
describe("error panel", () => { describe("error panel", () => {
test("not visible when no errors", async () => { test("not visible when no errors", () => {
await then(get.element("maputnik-message-panel-error")).shouldNotExist(); then(get.element("maputnik-message-panel-error")).shouldNotExist();
}); });
test("visible on style error", async () => { test("visible on style error", () => {
await when.modal.open(); when.modal.open();
await when.modal.fillLayers({ when.modal.fillLayers({
type: "circle", type: "circle",
layer: "invalid", layer: "invalid",
}); });
await then(get.element(".maputnik-message-panel-error")).shouldBeVisible(); then(get.element(".maputnik-message-panel-error")).shouldBeVisible();
}); });
}); });
describe("Handle localStorage QuotaExceededError", () => { describe("Handle localStorage QuotaExceededError", () => {
test("handles quota exceeded error when opening style from URL", async () => { test("handles quota exceeded error when opening style from URL", () => {
// Clear localStorage to start fresh // Clear localStorage to start fresh
await when.clearLocalStorage(); cy.clearLocalStorage();
await when.fillLocalStorage();
// fill localStorage until we get a QuotaExceededError
cy.window().then(win => {
let chunkSize = 1000;
const chunk = new Array(chunkSize).join("x");
let index = 0;
// Keep adding until we hit the quota
while (true) {
try {
const key = `maputnik:fill-${index++}`;
win.localStorage.setItem(key, chunk);
} catch (e: any) {
// Verify it's a quota error
if (e.name === "QuotaExceededError") {
if (chunkSize <= 1) return;
else {
chunkSize /= 2;
continue;
}
}
throw e; // Unexpected error
}
}
});
// Open the style via URL input // Open the style via URL input
await when.click("nav:open"); when.click("nav:open");
await when.setValue("modal:open.url.input", get.exampleFileUrl()); when.setValue("modal:open.url.input", get.exampleFileUrl());
await when.click("modal:open.url.button"); when.click("modal:open.url.button");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.responseBody("example-style.json")); then(get.responseBody("example-style.json")).shouldEqualToStoredStyle();
await then(get.styleFromLocalStorage()).shouldExist(); then(get.styleFromLocalStorage()).shouldExist();
}); });
}); });
}); });
-414
View File
@@ -1,414 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, type Locator, type Page, type Request } from "@playwright/test";
import { currentPage, recordCoverageChunk } from "./utils/fixtures";
const DATA_ATTRIBUTE = "data-wd-key";
const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
const isMac = process.platform === "darwin";
function testIdSelector(testId: string): string {
return `[${DATA_ATTRIBUTE}="${testId}"]`;
}
/** Retries `assertion` until it stops throwing */
async function retry(
assertion: () => Promise<void> | void,
timeout = 10000,
interval = 100
): Promise<void> {
const start = Date.now();
let lastError: unknown;
while (true) {
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.
*/
class Query<T> {
readonly __query = 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>).__query === true;
}
function isLocator(target: unknown): target is Locator {
return (
typeof target === "object" &&
target !== null &&
typeof (target as Locator).count === "function" &&
typeof (target as Locator).boundingBox === "function"
);
}
/** Asserts that every top-level key in `expected` deep-equals its counterpart in `actual`. */
function assertDeepNestedInclude(actual: any, expected: Record<string, unknown>): void {
for (const key of Object.keys(expected)) {
expect(actual?.[key], `property "${key}"`).toEqual(expected[key]);
}
}
/**
* Fluent, auto-retrying assertions over a Playwright Locator or a lazily
* evaluated value/Query. This is the generic base that the maputnik-specific
* assertable extends.
*/
export class Assertable<T> {
constructor(private readonly target: T) {}
private locator(): Locator {
if (!isLocator(this.target)) throw new Error("Expected a Locator target for this assertion");
return this.target;
}
protected async assertValue(assertion: (value: any) => void): Promise<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));
}
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",
home: "Home",
};
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (!token.startsWith("{") || !token.endsWith("}")) {
await page.keyboard.type(token);
continue;
}
const name = token.slice(1, -1).toLowerCase();
if (name === "selectall") {
await page.keyboard.press(isMac ? "Meta+a" : "Control+a");
} else if (namedKeys[name]) {
await page.keyboard.press(namedKeys[name]);
} else if (modifierMap[name]) {
const modifiers = [modifierMap[name]];
let j = i + 1;
while (j < tokens.length && /^\{(meta|ctrl|shift|alt)\}$/i.test(tokens[j])) {
modifiers.push(modifierMap[tokens[j].slice(1, -1).toLowerCase()]);
j++;
}
const key = tokens[j] ?? "";
await page.keyboard.press([...modifiers, key].join("+"));
i = j;
}
}
}
async function centerOf(locator: Locator): Promise<{ x: number; y: number }> {
const box = await locator.boundingBox();
if (!box) throw new Error("Element has no bounding box");
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
}
/**
* This is where all plywright-specific test helpers live.
* It is used by the MaputnikDriver to implement the Maputnik-specific test helpers.
*/
export class PlaywrightHelper {
private readonly recordedRequests = new Map<string, Request[]>();
private get page(): Page {
return currentPage();
}
private testId(testId: string): Locator {
return this.page.locator(testIdSelector(testId));
}
/** Reads and parses a JSON fixture from the fixtures directory. */
public readFixture(name: string): any {
return JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, name), "utf-8"));
}
/** Wraps a lazily-evaluated value so assertions on it auto-retry. */
public query<T>(getter: () => Promise<T>): Query<T> {
return new Query<T>(getter);
}
/** Entry point for fluent assertions over a Locator or a value/Query. */
public then = <T>(target: T): Assertable<T> => new Assertable(target);
public given = {
intercept: async (pattern: RegExp, alias: string, _method = "GET") => {
this.recordedRequests.set(alias, []);
await this.page.route(pattern, (route) => {
this.recordedRequests.get(alias)!.push(route.request());
route.continue();
});
},
interceptAndMockResponse: async (options: {
method?: string;
url: string | RegExp;
response: unknown | { fixture: string };
alias?: string;
}) => {
const { url, response, alias } = options;
if (alias) this.recordedRequests.set(alias, []);
await this.page.route(url, (route) => {
if (alias) this.recordedRequests.get(alias)!.push(route.request());
const body =
response && typeof response === "object" && "fixture" in (response as any)
? this.readFixture((response as { fixture: string }).fixture)
: response;
route.fulfill({ json: body });
});
},
};
public when = {
visit: async (url: string) => {
// Snapshot coverage before navigating, since a full page load resets it.
await recordCoverageChunk(this.page);
await this.page.goto(url);
},
wait: (ms: number) => this.page.waitForTimeout(ms),
tab: () => this.page.keyboard.press("Tab"),
typeKeys: (keys: string) => typeSequence(this.page, keys),
/** Types raw text into the focused element (no "{key}" sequence parsing). */
typeText: (text: string) => this.page.keyboard.type(text),
clickButtonByName: async (name: string) => {
await this.page.getByRole("button", { name }).click();
},
click: async (testId: string, index = 0) => {
// Documentation buttons are wrapped in a <label>/.maputnik-doc-target that
// Playwright treats as intercepting the click; bypass the check for them.
const force = testId.startsWith("field-doc-button-");
await this.testId(testId).nth(index).click({ force });
},
realClick: async (testId: string) => {
await this.testId(testId).click();
},
hover: async (testId: string) => {
await this.testId(testId).hover();
},
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 (parentTestId: string, value: string) => {
await this.testId(parentTestId).locator("select").selectOption(value);
},
clickWithin: async (parentTestId: string, selector: string) => {
await this.testId(parentTestId).locator(selector).first().click();
},
clickByText: async (text: string) => {
await this.page.getByText(text).click();
},
clickByAttribute: async (attribute: string, value: string) => {
await this.page.locator(`[${attribute}="${value}"]`).click();
},
scrollToBottom: async (element: Locator) => {
await element.evaluate((el) => el.scrollTo(0, el.scrollHeight));
},
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, 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);
},
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.page.waitForTimeout(100);
await this.page.mouse.up();
},
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.page.waitForTimeout(200);
await this.page.mouse.up();
},
openFileByFixture: async (fixture: string, buttonTestId: string, inputTestId: string) => {
const content = JSON.stringify(this.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),
});
}
},
dropFileByFixture: async (fixture: string, dropzoneTestId: string) => {
const content = JSON.stringify(this.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 });
},
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];
},
clearLocalStorage: () => this.page.evaluate(() => window.localStorage.clear()),
/** Writes to localStorage under `keyPrefix` until a QuotaExceededError is hit. */
fillLocalStorageUntilQuota: (keyPrefix: string) =>
this.page.evaluate((prefix) => {
let chunkSize = 1000;
const chunk = new Array(chunkSize).join("x");
let index = 0;
while (true) {
try {
window.localStorage.setItem(`${prefix}${index++}`, chunk);
} catch (e: any) {
if (e.name === "QuotaExceededError") {
if (chunkSize <= 1) return;
chunkSize /= 2;
continue;
}
throw e; // Unexpected error
}
}
}, keyPrefix),
};
public get = {
element: (selector: string) => this.page.locator(selector),
localStorageItem: (key: string) =>
this.page.evaluate((k) => window.localStorage.getItem(k), key),
elementByTestId: (testId: string) => this.testId(testId),
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),
};
}
-12
View File
@@ -1,12 +0,0 @@
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
@@ -1,23 +0,0 @@
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);
}
}
-82
View File
@@ -1,82 +0,0 @@
import { test, expect, type Page } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
let activePage: Page | undefined;
const coverageChunks: unknown[] = [];
/** The page for the currently running test. Throws if used outside a test. */
export function currentPage(): Page {
if (!activePage) {
throw new Error("No active page: a MaputnikDriver method was called outside of a running test.");
}
return activePage;
}
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.
*/
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));
}
/** Records a coverage snapshot (called before navigations, which reset __coverage__). */
export async function recordCoverageChunk(page: Page): Promise<void> {
const chunk = await readCoverage(page);
if (chunk) coverageChunks.push(chunk);
}
/**
* Auto fixture that binds the current test's page for the (page-lazy)
* MaputnikDriver, auto-accepts confirm dialogs, and writes the istanbul
* coverage collected during the test to `.nyc_output`.
*/
const extendedTest = test.extend<{ maputnikPage: void }>({
maputnikPage: [
async ({ page }, use, testInfo) => {
activePage = page;
coverageChunks.length = 0;
// Accept confirm dialogs (e.g. the "replace current style" prompt). These
// are dismissed by default, which would cancel loading a style via URL.
page.on("dialog", (dialog) => dialog.accept().catch(() => undefined));
await use();
const finalCoverage = await readCoverage(page);
if (finalCoverage) coverageChunks.push(finalCoverage);
coverageChunks.forEach((chunk, index) => writeCoverage(chunk, `${testInfo.testId}-${index}`));
coverageChunks.length = 0;
activePage = undefined;
},
{ auto: true },
],
});
const describe = extendedTest.describe;
const beforeEach = extendedTest.beforeEach;
export {
expect,
describe,
extendedTest as test,
beforeEach,
};
+2286 -214
View File
File diff suppressed because it is too large Load Diff
+9 -10
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": "playwright test", "test": "cypress run",
"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"
}, },
@@ -28,9 +28,9 @@
"dependencies": { "dependencies": {
"@codemirror/lang-json": "^6.0.2", "@codemirror/lang-json": "^6.0.2",
"@codemirror/lint": "^6.9.7", "@codemirror/lint": "^6.9.7",
"@codemirror/state": "^6.7.0", "@codemirror/state": "^6.7.1",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.5", "@codemirror/view": "^6.43.6",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@@ -98,9 +98,10 @@
} }
}, },
"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",
"@playwright/test": "^1.61.1", "@shellygo/cypress-test-utils": "^6.0.6",
"@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",
@@ -124,10 +125,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.2", "@vitejs/plugin-react": "5.2",
"@vitest/browser": "^4.1.10",
"@vitest/browser-playwright": "^4.1.10",
"@vitest/coverage-v8": "^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",
@@ -135,7 +136,6 @@
"i18next-cli": "^1.65.0", "i18next-cli": "^1.65.0",
"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,7 +147,6 @@
"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.10", "vitest": "^4.1.10"
"vitest-browser-react": "^2.2.0"
} }
} }
-46
View File
@@ -1,46 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
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({
testDir: "./e2e",
testMatch: "**/*.spec.ts",
globalSetup: "./e2e/utils/e2e-setup.ts",
globalTeardown: "./e2e/utils/e2e-teardown.ts",
fullyParallel: true,
forbidOnly: isCI,
retries: isCI ? 2 : 0,
reporter: isCI ? [["list"], ["html", { open: "never" }]] : "list",
use: {
baseURL,
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
launchOptions: {
// Allow WebGL (maplibre) to fall back to software rendering in headless.
args: [
"--disable-gpu",
"--enable-features=AllowSwiftShaderFallback,AllowSoftwareGLFallbackDueToCrashes",
"--enable-unsafe-swiftshader",
],
},
},
},
],
webServer: useExternalServer
? undefined
: {
command: "npm run start",
url: "http://localhost:8888/maputnik/",
reuseExistingServer: !isCI,
timeout: 120000,
},
});
@@ -1,24 +1,18 @@
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"];
test("filters options when typing", async () => { describe("<InputAutocomplete />", () => {
render(<InputAutocomplete aria-label="Fruit" options={fruits.map((f) => [f, f])} />); it("filters options when typing", () => {
mount(
const input = page.getByLabelText("Fruit"); <InputAutocomplete aria-label="Fruit" options={fruits.map(f => [f, f])} />
await input.click(); );
cy.get("input").focus();
const menuItems = page.getByRole("option"); cy.get(".maputnik-autocomplete-menu-item").should("have.length", 3);
await expect.element(menuItems.first()).toBeVisible(); cy.get("input").type("ch");
expect(menuItems.all()).toHaveLength(3); cy.get(".maputnik-autocomplete-menu-item").should("have.length", 1).and("contain", "cherry");
cy.get(".maputnik-autocomplete-menu-item").click();
await input.fill("ch"); cy.get("input").should("have.value", "cherry");
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");
}); });
+10 -3
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", "node"], "types": ["geojson", "@types/wicg-file-system-access"],
"module": "ESNext", "module": "ESNext",
"skipLibCheck": true, "skipLibCheck": true,
@@ -21,7 +21,14 @@
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },
"include": ["src", "e2e"], "include": ["src", "cypress/e2e", "e2e/maputnik-cypress-helper.ts"],
"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",
}
}
} }
+2 -1
View File
@@ -12,9 +12,10 @@ 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 so e2e runs can collect coverage forceBuildInstrument: true, //Instrument the source code for cypress runs
}), }),
], ],
base: mode === "desktop" ? "/" : "/maputnik/", base: mode === "desktop" ? "/" : "/maputnik/",
+4 -30
View File
@@ -1,37 +1,11 @@
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { playwright } from "@vitest/browser-playwright";
export default defineConfig({ export default defineConfig({
plugins: [react()],
test: { test: {
projects: [ // Only the unit tests that live next to the source are run by Vitest. The
{ // e2e specs (*.spec.ts) are run by their own runner, and the component
extends: true, // browser test is run separately.
test: {
name: "unit",
environment: "node",
include: ["src/**/*.test.{ts,tsx}"], include: ["src/**/*.test.{ts,tsx}"],
exclude: ["src/**/*.browser.test.{ts,tsx}"], exclude: ["**/node_modules/**", "e2e/**", "**/*.browser.test.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"],
},
}, },
}); });