From 99b5dd785d033ce192dd690cf8e33e62d73c8ea0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:51:14 +0000 Subject: [PATCH 1/6] chore(deps-dev): Bump the vitest group with 2 updates (#1991) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
Release notes

Sourced from @​vitest/coverage-v8's releases.

v4.1.10

   🐞 Bug Fixes

    View changes on GitHub
Commits

Updates `vitest` from 4.1.9 to 4.1.10
Release notes

Sourced from vitest's releases.

v4.1.10

   🐞 Bug Fixes

    View changes on GitHub
Commits

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) ---
Dependabot commands and options
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 ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore 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 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 ` 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 ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 134 +++++++++++++++++++++------------------------- package.json | 4 +- 2 files changed, 64 insertions(+), 74 deletions(-) diff --git a/package-lock.json b/package-lock.json index 297bac81..77a40ea9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -91,7 +91,7 @@ "@types/string-hash": "^1.1.3", "@types/wicg-file-system-access": "^2023.10.7", "@vitejs/plugin-react": "5.2", - "@vitest/coverage-v8": "^4.1.9", + "@vitest/coverage-v8": "^4.1.10", "cors": "^2.8.6", "cypress": "^15.18.0", "cypress-plugin-tab": "^2.0.0", @@ -113,7 +113,7 @@ "uuid": "^14.0.1", "vite": "^7.3.2", "vite-plugin-istanbul": "^9.0.1", - "vitest": "^4.1.9" + "vitest": "^4.1.10" } }, "node_modules/@angular/common": { @@ -4022,14 +4022,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", - "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.9", + "@vitest/utils": "4.1.10", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -4043,8 +4043,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.9", - "vitest": "4.1.9" + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -4053,16 +4053,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", - "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -4071,13 +4071,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", - "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.9", + "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -4097,20 +4097,10 @@ } } }, - "node_modules/@vitest/mocker/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, "node_modules/@vitest/pretty-format": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", - "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4121,13 +4111,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", - "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.9", + "@vitest/utils": "4.1.10", "pathe": "^2.0.3" }, "funding": { @@ -4135,14 +4125,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", - "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -4151,9 +4141,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", - "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", "funding": { @@ -4161,13 +4151,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", - "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", + "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -4539,16 +4529,6 @@ "js-tokens": "^10.0.0" } }, - "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -6712,6 +6692,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -15564,19 +15554,19 @@ } }, "node_modules/vitest": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", - "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.9", - "@vitest/mocker": "4.1.9", - "@vitest/pretty-format": "4.1.9", - "@vitest/runner": "4.1.9", - "@vitest/snapshot": "4.1.9", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -15604,12 +15594,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.9", - "@vitest/browser-preview": "4.1.9", - "@vitest/browser-webdriverio": "4.1.9", - "@vitest/coverage-istanbul": "4.1.9", - "@vitest/coverage-v8": "4.1.9", - "@vitest/ui": "4.1.9", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index ef3c66af..0cc11f0f 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,7 @@ "@types/string-hash": "^1.1.3", "@types/wicg-file-system-access": "^2023.10.7", "@vitejs/plugin-react": "5.2", - "@vitest/coverage-v8": "^4.1.9", + "@vitest/coverage-v8": "^4.1.10", "cors": "^2.8.6", "cypress": "^15.18.0", "cypress-plugin-tab": "^2.0.0", @@ -147,6 +147,6 @@ "uuid": "^14.0.1", "vite": "^7.3.2", "vite-plugin-istanbul": "^9.0.1", - "vitest": "^4.1.9" + "vitest": "^4.1.10" } } From 1047286d9e68ef461c8ee65ef24adbab089b36e8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:51:55 +0000 Subject: [PATCH 2/6] 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.
Commits

[![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) ---
Dependabot commands and options
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 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)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 77a40ea9..883fb496 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@codemirror/lang-json": "^6.0.2", "@codemirror/lint": "^6.9.7", - "@codemirror/state": "^6.7.0", + "@codemirror/state": "^6.7.1", "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.43.5", "@dnd-kit/core": "^6.3.1", @@ -608,9 +608,9 @@ } }, "node_modules/@codemirror/state": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.0.tgz", - "integrity": "sha512-Zbl9NyscLMZkfXPQnNAIIAFftidrA1UbcJEIMp24C0Bukc2I5T8wJS0wsXYsnDOqCFJUeJ1BITGNs5CqPDSmSg==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", + "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", "license": "MIT", "dependencies": { "@marijn/find-cluster-break": "^1.0.0" diff --git a/package.json b/package.json index 0cc11f0f..6bc7ab77 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dependencies": { "@codemirror/lang-json": "^6.0.2", "@codemirror/lint": "^6.9.7", - "@codemirror/state": "^6.7.0", + "@codemirror/state": "^6.7.1", "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.43.5", "@dnd-kit/core": "^6.3.1", From c24049cb88038ac21986ab3c8631a257691c59fd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:52:29 +0000 Subject: [PATCH 3/6] 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.
Commits

[![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) ---
Dependabot commands and options
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 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)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 883fb496..38fe61a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@codemirror/lint": "^6.9.7", "@codemirror/state": "^6.7.1", "@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/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -629,9 +629,9 @@ } }, "node_modules/@codemirror/view": { - "version": "6.43.5", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.5.tgz", - "integrity": "sha512-7uT/vUgH6dfXWn3WqOe23KneILMvGy5wQjNMEcRXLKzziJ9NOktpW6tGoyQpwVkBgE5Gj6hKkCcsddbnkaWrOQ==", + "version": "6.43.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz", + "integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.7.0", diff --git a/package.json b/package.json index 6bc7ab77..f17dedc4 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@codemirror/lint": "^6.9.7", "@codemirror/state": "^6.7.1", "@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/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", From 2e0b7cdb8c3e67ec35268f9300d016c4d3c137d5 Mon Sep 17 00:00:00 2001 From: Harel M Date: Fri, 10 Jul 2026 21:18:48 +0300 Subject: [PATCH 4/6] Replace cypress with playwright (#1988) ## Launch Checklist This PR replaces cypress with playwright. image I hope this will make the end-to-end debugging and fixing easier due to the async await support of playwright which is missing in cypress. This is a pure refactoring change. - [x] Briefly describe the changes in this PR. - [x] Add an entry to `CHANGELOG.md` under the `## main` section. --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 48 +- .gitignore | 6 +- .nycrc.json | 9 +- AGENTS.md | 12 +- CHANGELOG.md | 1 + README.md | 41 +- cypress.config.ts | 42 - cypress/support/commands.ts | 37 - cypress/support/component-index.html | 12 - cypress/support/component.ts | 37 - cypress/support/e2e.ts | 22 - e2e/accessibility.spec.ts | 56 +- e2e/code-editor.spec.ts | 27 +- .../fixtures/example-layer-style.json | 0 .../fixtures/example-style-with-fonts.json | 0 ...le-style-with-zoom-5-and-center-50-50.json | 0 ...ple-style-with-zoom-7-and-center-0-51.json | 0 {cypress => e2e}/fixtures/example-style.json | 0 .../fixtures/geojson-raster-style.json | 0 {cypress => e2e}/fixtures/geojson-style.json | 0 {cypress => e2e}/fixtures/raster-style.json | 0 .../fixtures/rectangles-style.json | 0 e2e/history.spec.ts | 124 +- e2e/i18n.spec.ts | 39 +- e2e/keyboard.spec.ts | 75 +- e2e/layer-editor.spec.ts | 292 +- e2e/layers-list.spec.ts | 581 +-- e2e/map.spec.ts | 84 +- e2e/maputnik-cypress-helper.ts | 56 - e2e/maputnik-driver.ts | 350 +- e2e/modal-driver.ts | 48 +- e2e/modals.spec.ts | 490 ++- e2e/playwright-helper.ts | 414 ++ e2e/utils/e2e-setup.ts | 12 + e2e/utils/e2e-teardown.ts | 23 + e2e/utils/fixtures.ts | 82 + package-lock.json | 3379 ++++------------- package.json | 15 +- playwright.config.ts | 46 + .../InputAutocomplete.browser.test.tsx | 32 +- tsconfig.json | 13 +- vite.config.ts | 3 +- vitest.config.ts | 36 +- 43 files changed, 2282 insertions(+), 4262 deletions(-) delete mode 100644 cypress.config.ts delete mode 100644 cypress/support/commands.ts delete mode 100644 cypress/support/component-index.html delete mode 100644 cypress/support/component.ts delete mode 100644 cypress/support/e2e.ts rename {cypress => e2e}/fixtures/example-layer-style.json (100%) rename {cypress => e2e}/fixtures/example-style-with-fonts.json (100%) rename {cypress => e2e}/fixtures/example-style-with-zoom-5-and-center-50-50.json (100%) rename {cypress => e2e}/fixtures/example-style-with-zoom-7-and-center-0-51.json (100%) rename {cypress => e2e}/fixtures/example-style.json (100%) rename {cypress => e2e}/fixtures/geojson-raster-style.json (100%) rename {cypress => e2e}/fixtures/geojson-style.json (100%) rename {cypress => e2e}/fixtures/raster-style.json (100%) rename {cypress => e2e}/fixtures/rectangles-style.json (100%) delete mode 100644 e2e/maputnik-cypress-helper.ts create mode 100644 e2e/playwright-helper.ts create mode 100644 e2e/utils/e2e-setup.ts create mode 100644 e2e/utils/e2e-teardown.ts create mode 100644 e2e/utils/fixtures.ts create mode 100644 playwright.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3044089b..4a7ccfef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,6 +96,7 @@ jobs: with: node-version-file: '.nvmrc' - run: npm ci + - run: npx playwright install --with-deps chromium - run: npm run test-unit-ci - name: Upload coverage reports to Codecov uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 @@ -117,17 +118,21 @@ jobs: with: node-version-file: '.nvmrc' - run: npm ci - - name: Cypress run - uses: cypress-io/github-action@fa4a118725a8f001170d49631ea89e5d66fee626 # v7.4.1 - with: - build: npm run build - start: npm run start - browser: chrome + - run: npx playwright install --with-deps chromium + - name: Playwright run + run: npm run test-e2e - name: Upload coverage reports to Codecov uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: - files: ${{ github.workspace }}/.nyc_output/out.json + files: ${{ github.workspace }}/coverage/coverage-final.json verbose: true + - name: Upload Playwright report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 e2e-tests-docker: name: "E2E tests using chrome and docker" @@ -141,14 +146,21 @@ jobs: with: node-version-file: '.nvmrc' - run: npm ci - - name: Cypress run - uses: cypress-io/github-action@fa4a118725a8f001170d49631ea89e5d66fee626 # v7.4.1 - with: - build: docker build -t maputnik . - start: docker run --rm --network host maputnik --port=8888 - browser: chrome - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 - with: - files: ${{ github.workspace }}/.nyc_output/out.json - verbose: true + - run: npx playwright install --with-deps chromium + - name: Build docker image + run: docker build -t maputnik . + - name: Start maputnik container + run: docker run -d --network host --name maputnik maputnik --port=8888 + - name: Wait for maputnik to be ready + run: | + for i in $(seq 1 60); do + if curl -sSf http://localhost:8888/ > /dev/null; then + echo "maputnik is up"; exit 0 + fi + sleep 1 + done + echo "maputnik did not start in time"; docker logs maputnik; exit 1 + - name: Playwright run + run: npm run test-e2e + env: + E2E_NO_WEBSERVER: "1" diff --git a/.gitignore b/.gitignore index dba2935c..92d15c11 100644 --- a/.gitignore +++ b/.gitignore @@ -33,10 +33,14 @@ node_modules public /errorShots /old -/cypress/screenshots /dist/ /desktop/version.go +# Playwright +/test-results/ +/playwright-report/ +/playwright/.cache/ + # IDE .vscode/ .idea/ diff --git a/.nycrc.json b/.nycrc.json index dfb4729b..b068c670 100644 --- a/.nycrc.json +++ b/.nycrc.json @@ -4,12 +4,13 @@ "check-coverage": false, "include": ["src/**/*.ts", "src/**/*.tsx"], "exclude": [ - "cypress/**/*.*", + "e2e/**/*.*", "**/*.d.ts", - "**/*.cy.tsx", - "**/*.cy.ts", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.browser.test.tsx", "./coverage/**", - "./cypress/**", + "./e2e/**", "./dist/**", "node_modules" ], diff --git a/AGENTS.md b/AGENTS.md index 2f1f556c..501d2edb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,22 +24,22 @@ The project type checked and built with: npm run build ``` -To run the tests make sure that xvfb is installed: +Install the Playwright browser (first time only): ``` -apt install xvfb +npx playwright install --with-deps chromium ``` -Run the development server in the background with Vite: +Then run the end-to-end tests (Playwright starts the dev server automatically): ``` -nohup npm run start & +npm run test ``` -Then start the Cypress tests with: +Run the unit and component tests with Vitest: ``` -xvfb-run -a npm run test +npm run test-unit ``` ## Pull Requests diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a4813ae..5d7a6bff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## main ### ✨ Features and improvements +- Replace Cypress with Playwright for end-to-end tests and Vitest browser mode (Playwright provider) for component tests; drop the `@shellygo/cypress-test-utils` helper in favour of a `MaputnikDriver` page object - _...Add new stuff here..._ ### 🐞 Bug fixes diff --git a/README.md b/README.md index a62383b7..ea69e7e9 100644 --- a/README.md +++ b/README.md @@ -79,26 +79,45 @@ npm run sort-styles ``` ## Tests -For E2E testing we use [Cypress](https://www.cypress.io/) - [Cypress](https://www.cypress.io/) doesn't start a server so you'll need to start one manually by running `npm run start`. +### End-to-end tests -Now open a terminal and run the following using *chrome*: +For E2E testing we use [Playwright](https://playwright.dev/). The tests live in the [`e2e`](/e2e) directory and drive the app through the `MaputnikDriver` page object. + +The first time you run the tests, install the browser: + +``` +npx playwright install chromium +``` + +Playwright automatically starts the dev server (`npm run start`) for you, so you can just run: ``` npm run test ``` -or *firefox*: -``` -npm run test -- --browser firefox -``` -See the following docs for more info: (Launching Browsers)[https://docs.cypress.io/guides/guides/launching-browsers] - -You can also see the tests as they run or select which suites to run by executing: +Some useful options: ``` -npm run cy:open +# see the tests run in a headed browser +npm run test -- --headed + +# run a single spec / filter by title +npm run test -- e2e/map.spec.ts +npm run test -- -g "zoom level" + +# open the interactive UI mode +npx playwright test --ui +``` + +Running the E2E tests also produces a code-coverage report in `coverage/` (collected via istanbul instrumentation of the dev server). + +### Unit & component tests + +Unit tests and component tests run with [Vitest](https://vitest.dev/); component tests (`*.browser.test.tsx`) use Vitest's browser mode with the Playwright provider. + +``` +npm run test-unit ``` ## Release process diff --git a/cypress.config.ts b/cypress.config.ts deleted file mode 100644 index 6c47f29b..00000000 --- a/cypress.config.ts +++ /dev/null @@ -1,42 +0,0 @@ -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", - }, - }, -}); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts deleted file mode 100644 index 95857aea..00000000 --- a/cypress/support/commands.ts +++ /dev/null @@ -1,37 +0,0 @@ -/// -// *********************************************** -// This example commands.ts shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add('login', (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) -// -// declare global { -// namespace Cypress { -// interface Chainable { -// login(email: string, password: string): Chainable -// drag(subject: string, options?: Partial): Chainable -// dismiss(subject: string, options?: Partial): Chainable -// visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable -// } -// } -// } diff --git a/cypress/support/component-index.html b/cypress/support/component-index.html deleted file mode 100644 index 5f9622ae..00000000 --- a/cypress/support/component-index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - Components App - - -
- - diff --git a/cypress/support/component.ts b/cypress/support/component.ts deleted file mode 100644 index 1b940dc5..00000000 --- a/cypress/support/component.ts +++ /dev/null @@ -1,37 +0,0 @@ -// *********************************************************** -// This example support/component.ts is processed and -// loaded automatically before your test files. -// -// This is a great place to put global configuration and -// behavior that modifies Cypress. -// -// You can change the location of this file or turn off -// automatically serving support files with the -// 'supportFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/configuration -// *********************************************************** - -// Import commands.js using ES2015 syntax: -import "./commands"; - -import { mount } from "cypress/react"; - -// Augment the Cypress namespace to include type definitions for -// your custom command. -// Alternatively, can be defined in cypress/support/component.d.ts -// with a at the top of your spec. -declare global { - /* eslint-disable @typescript-eslint/no-namespace */ - namespace Cypress { - interface Chainable { - mount: typeof mount - } - } -} - -Cypress.Commands.add("mount", mount); - -// Example use: -// cy.mount() diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts deleted file mode 100644 index 1aa9aa2b..00000000 --- a/cypress/support/e2e.ts +++ /dev/null @@ -1,22 +0,0 @@ -// *********************************************************** -// This example support/e2e.ts is processed and -// loaded automatically before your test files. -// -// This is a great place to put global configuration and -// behavior that modifies Cypress. -// -// You can change the location of this file or turn off -// automatically serving support files with the -// 'supportFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/configuration -// *********************************************************** - -// Import commands.js using ES2015 syntax: -import "@cypress/code-coverage/support"; -import "cypress-plugin-tab"; -import "./commands"; - -// Alternatively you can use CommonJS syntax: -// require('./commands') diff --git a/e2e/accessibility.spec.ts b/e2e/accessibility.spec.ts index edbd3a10..0f1f9995 100644 --- a/e2e/accessibility.spec.ts +++ b/e2e/accessibility.spec.ts @@ -1,42 +1,48 @@ +import { test, describe, beforeEach } from "./utils/fixtures"; import { MaputnikDriver } from "./maputnik-driver"; -const test = it; - describe("accessibility", () => { - const { beforeAndAfter, get, when, then } = new MaputnikDriver(); - beforeAndAfter(); + const { given, get, when, then } = new MaputnikDriver(); + + beforeEach(async () => { + await given.setupMockBackedResponses(); + await when.setStyle("both"); + }); describe("skip links", () => { - beforeEach(() => { - when.setStyle("layer"); + beforeEach(async () => { + await when.setStyle("layer"); }); - test("skip link to layer list", () => { + test("skip link to layer list", async () => { const selector = "root:skip:layer-list"; - then(get.elementByTestId(selector)).shouldExist(); - when.tab(); - then(get.elementByTestId(selector)).shouldBeFocused(); - when.click(selector); - then(get.skipTargetLayerList()).shouldBeFocused(); + await then(get.elementByTestId(selector)).shouldExist(); + await when.tab(); + await then(get.elementByTestId(selector)).shouldBeFocused(); + await when.click(selector); + await then(get.skipTargetLayerList()).shouldBeFocused(); }); - test("skip link to layer editor", () => { + test("skip link to layer editor", async () => { const selector = "root:skip:layer-editor"; - then(get.elementByTestId(selector)).shouldExist(); - then(get.elementByTestId("skip-target-layer-editor")).shouldExist(); - when.tab().tab(); - then(get.elementByTestId(selector)).shouldBeFocused(); - when.click(selector); - then(get.skipTargetLayerEditor()).shouldBeFocused(); + await then(get.elementByTestId(selector)).shouldExist(); + await then(get.elementByTestId("skip-target-layer-editor")).shouldExist(); + await when.tab(); + await when.tab(); + await then(get.elementByTestId(selector)).shouldBeFocused(); + await when.click(selector); + await then(get.skipTargetLayerEditor()).shouldBeFocused(); }); - test("skip link to map view", () => { + test("skip link to map view", async () => { const selector = "root:skip:map-view"; - then(get.elementByTestId(selector)).shouldExist(); - when.tab().tab().tab(); - then(get.elementByTestId(selector)).shouldBeFocused(); - when.click(selector); - then(get.canvas()).shouldBeFocused(); + await then(get.elementByTestId(selector)).shouldExist(); + await when.tab(); + await when.tab(); + await when.tab(); + await then(get.elementByTestId(selector)).shouldBeFocused(); + await when.click(selector); + await then(get.canvas()).shouldBeFocused(); }); }); }); diff --git a/e2e/code-editor.spec.ts b/e2e/code-editor.spec.ts index 70a6b685..131e969f 100644 --- a/e2e/code-editor.spec.ts +++ b/e2e/code-editor.spec.ts @@ -1,20 +1,23 @@ +import { beforeEach, describe, test } from "./utils/fixtures"; import { MaputnikDriver } from "./maputnik-driver"; -const test = it; - describe("code editor", () => { - const { beforeAndAfter, when, get, then } = new MaputnikDriver(); - beforeAndAfter(); + const { given, get, when, then } = new MaputnikDriver(); - test("open code editor", () => { - when.click("nav:code-editor"); - then(get.element(".maputnik-code-editor")).shouldExist(); + beforeEach(async () => { + await given.setupMockBackedResponses(); + await when.setStyle("both"); }); - test("closes code editor", () => { - when.click("nav:code-editor"); - then(get.element(".maputnik-code-editor")).shouldExist(); - when.click("nav:code-editor"); - then(get.element(".maputnik-code-editor")).shouldNotExist(); + test("open code editor", async () => { + await when.click("nav:code-editor"); + await then(get.element(".maputnik-code-editor")).shouldExist(); + }); + + 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(); }); }); diff --git a/cypress/fixtures/example-layer-style.json b/e2e/fixtures/example-layer-style.json similarity index 100% rename from cypress/fixtures/example-layer-style.json rename to e2e/fixtures/example-layer-style.json diff --git a/cypress/fixtures/example-style-with-fonts.json b/e2e/fixtures/example-style-with-fonts.json similarity index 100% rename from cypress/fixtures/example-style-with-fonts.json rename to e2e/fixtures/example-style-with-fonts.json diff --git a/cypress/fixtures/example-style-with-zoom-5-and-center-50-50.json b/e2e/fixtures/example-style-with-zoom-5-and-center-50-50.json similarity index 100% rename from cypress/fixtures/example-style-with-zoom-5-and-center-50-50.json rename to e2e/fixtures/example-style-with-zoom-5-and-center-50-50.json diff --git a/cypress/fixtures/example-style-with-zoom-7-and-center-0-51.json b/e2e/fixtures/example-style-with-zoom-7-and-center-0-51.json similarity index 100% rename from cypress/fixtures/example-style-with-zoom-7-and-center-0-51.json rename to e2e/fixtures/example-style-with-zoom-7-and-center-0-51.json diff --git a/cypress/fixtures/example-style.json b/e2e/fixtures/example-style.json similarity index 100% rename from cypress/fixtures/example-style.json rename to e2e/fixtures/example-style.json diff --git a/cypress/fixtures/geojson-raster-style.json b/e2e/fixtures/geojson-raster-style.json similarity index 100% rename from cypress/fixtures/geojson-raster-style.json rename to e2e/fixtures/geojson-raster-style.json diff --git a/cypress/fixtures/geojson-style.json b/e2e/fixtures/geojson-style.json similarity index 100% rename from cypress/fixtures/geojson-style.json rename to e2e/fixtures/geojson-style.json diff --git a/cypress/fixtures/raster-style.json b/e2e/fixtures/raster-style.json similarity index 100% rename from cypress/fixtures/raster-style.json rename to e2e/fixtures/raster-style.json diff --git a/cypress/fixtures/rectangles-style.json b/e2e/fixtures/rectangles-style.json similarity index 100% rename from cypress/fixtures/rectangles-style.json rename to e2e/fixtures/rectangles-style.json diff --git a/e2e/history.spec.ts b/e2e/history.spec.ts index 811fc3f9..aa36e194 100644 --- a/e2e/history.spec.ts +++ b/e2e/history.spec.ts @@ -1,126 +1,90 @@ +import { beforeEach, describe, test } from "./utils/fixtures"; import { MaputnikDriver } from "./maputnik-driver"; -const test = it; - describe("history", () => { - const { beforeAndAfter, when, get, then } = new MaputnikDriver(); - beforeAndAfter(); + const { given, get, when, then } = new MaputnikDriver(); - let undoKeyCombo: string; - let redoKeyCombo: string; + const undoKeyCombo = process.platform === "darwin" ? "{meta}z" : "{ctrl}z"; + const redoKeyCombo = process.platform === "darwin" ? "{meta}{shift}z" : "{ctrl}y"; - before(() => { - const isMac = get.isMac(); - undoKeyCombo = isMac ? "{meta}z" : "{ctrl}z"; - redoKeyCombo = isMac ? "{meta}{shift}z" : "{ctrl}y"; + beforeEach(async () => { + await given.setupMockBackedResponses(); + await when.setStyle("both"); }); - test("undo/redo", () => { - when.setStyle("geojson"); - when.modal.open(); + test("undo/redo", async () => { + await when.setStyle("geojson"); + await when.modal.open(); - when.modal.fillLayers({ + await when.modal.fillLayers({ id: "step 1", type: "background", }); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: "step 1", - type: "background", - }, - ], + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id: "step 1", type: "background" }], }); - when.modal.open(); - when.modal.fillLayers({ + await when.modal.open(); + await when.modal.fillLayers({ id: "step 2", type: "background", }); - - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [ - { - id: "step 1", - type: "background", - }, - { - id: "step 2", - type: "background", - }, + { id: "step 1", type: "background" }, + { id: "step 2", type: "background" }, ], }); - when.typeKeys(undoKeyCombo); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: "step 1", - type: "background", - }, - ], + await when.typeKeys(undoKeyCombo); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id: "step 1", type: "background" }], }); - when.typeKeys(undoKeyCombo); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [] }); + await when.typeKeys(undoKeyCombo); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [] }); - when.typeKeys(redoKeyCombo); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: "step 1", - type: "background", - }, - ], + await when.typeKeys(redoKeyCombo); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id: "step 1", type: "background" }], }); - when.typeKeys(redoKeyCombo); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + await when.typeKeys(redoKeyCombo); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [ - { - id: "step 1", - type: "background", - }, - { - id: "step 2", - type: "background", - }, + { id: "step 1", type: "background" }, + { id: "step 2", type: "background" }, ], }); }); - test("should not redo after undo and value change", () => { - when.setStyle("geojson"); - when.modal.open(); - when.modal.fillLayers({ + test("should not redo after undo and value change", async () => { + await when.setStyle("geojson"); + await when.modal.open(); + await when.modal.fillLayers({ id: "step 1", type: "background", }); - when.modal.open(); - when.modal.fillLayers({ + await when.modal.open(); + await when.modal.fillLayers({ id: "step 2", type: "background", }); - when.typeKeys(undoKeyCombo); - when.typeKeys(undoKeyCombo); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [] }); + await when.typeKeys(undoKeyCombo); + await when.typeKeys(undoKeyCombo); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [] }); - when.modal.open(); - when.modal.fillLayers({ + await when.modal.open(); + await when.modal.fillLayers({ id: "step 3", type: "background", }); - when.typeKeys(redoKeyCombo); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: "step 3", - type: "background", - }, - ], + await when.typeKeys(redoKeyCombo); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id: "step 3", type: "background" }], }); }); }); diff --git a/e2e/i18n.spec.ts b/e2e/i18n.spec.ts index 33d8f3c6..adf02878 100644 --- a/e2e/i18n.spec.ts +++ b/e2e/i18n.spec.ts @@ -1,37 +1,38 @@ +import { beforeEach, describe, test } from "./utils/fixtures"; import { MaputnikDriver } from "./maputnik-driver"; -const test = it; - describe("i18n", () => { - const { beforeAndAfter, get, when, then } = new MaputnikDriver(); - beforeAndAfter(); + const { given, get, when, then } = new MaputnikDriver(); + + beforeEach(async () => { + await given.setupMockBackedResponses(); + await when.setStyle("both"); + }); describe("language detector", () => { - test("English", () => { - const url = "?lng=en"; - when.visit(url); - then(get.elementByTestId("maputnik-lang-select")).shouldHaveValue("en"); + test("English", async () => { + await when.visit("?lng=en"); + await then(get.elementByTestId("maputnik-lang-select")).shouldHaveValue("en"); }); - test("Japanese", () => { - const url = "?lng=ja"; - when.visit(url); - then(get.elementByTestId("maputnik-lang-select")).shouldHaveValue("ja"); + test("Japanese", async () => { + await when.visit("?lng=ja"); + await then(get.elementByTestId("maputnik-lang-select")).shouldHaveValue("ja"); }); }); describe("language switcher", () => { - beforeEach(() => { - when.setStyle("layer"); + beforeEach(async () => { + await when.setStyle("layer"); }); - test("the language switcher switches to Japanese", () => { + test("the language switcher switches to Japanese", async () => { const selector = "maputnik-lang-select"; - then(get.elementByTestId(selector)).shouldExist(); - when.select(selector, "ja"); - then(get.elementByTestId(selector)).shouldHaveValue("ja"); + await then(get.elementByTestId(selector)).shouldExist(); + await when.select(selector, "ja"); + await then(get.elementByTestId(selector)).shouldHaveValue("ja"); - then(get.elementByTestId("nav:settings")).shouldHaveText("スタイル設定"); + await then(get.elementByTestId("nav:settings")).shouldHaveText("スタイル設定"); }); }); }); diff --git a/e2e/keyboard.spec.ts b/e2e/keyboard.spec.ts index baf9595d..8b67f12f 100644 --- a/e2e/keyboard.spec.ts +++ b/e2e/keyboard.spec.ts @@ -1,61 +1,66 @@ +import { beforeEach, describe, test } from "./utils/fixtures"; import { MaputnikDriver } from "./maputnik-driver"; -const test = it; describe("keyboard", () => { - const { beforeAndAfter, given, when, get, then } = new MaputnikDriver(); - beforeAndAfter(); + const { given, get, when, then } = new MaputnikDriver(); + + beforeEach(async () => { + await given.setupMockBackedResponses(); + await when.setStyle("both"); + }); + describe("shortcuts", () => { - beforeEach(() => { - given.setupMockBackedResponses(); - when.setStyle(""); + beforeEach(async () => { + await given.setupMockBackedResponses(); + await when.setStyle(""); }); - test("ESC should unfocus", () => { + test("ESC should unfocus", async () => { const targetSelector = "maputnik-select"; - when.focus(targetSelector); - then(get.elementByTestId(targetSelector)).shouldBeFocused(); - when.typeKeys("{esc}"); - then(get.elementByTestId(targetSelector)).shouldNotBeFocused(); + await when.focus(targetSelector); + await then(get.elementByTestId(targetSelector)).shouldBeFocused(); + await when.typeKeys("{esc}"); + await then(get.elementByTestId(targetSelector)).shouldNotBeFocused(); }); - test("'?' should show shortcuts modal", () => { - when.typeKeys("?"); - then(get.elementByTestId("modal:shortcuts")).shouldBeVisible(); + test("'?' should show shortcuts modal", async () => { + await when.typeKeys("?"); + await then(get.elementByTestId("modal:shortcuts")).shouldBeVisible(); }); - test("'o' should show open modal", () => { - when.typeKeys("o"); - then(get.elementByTestId("modal:open")).shouldBeVisible(); + test("'o' should show open modal", async () => { + await when.typeKeys("o"); + await then(get.elementByTestId("modal:open")).shouldBeVisible(); }); - test("'e' should show export modal", () => { - when.typeKeys("e"); - then(get.elementByTestId("modal:export")).shouldBeVisible(); + test("'e' should show export modal", async () => { + await when.typeKeys("e"); + await then(get.elementByTestId("modal:export")).shouldBeVisible(); }); - test("'d' should show sources modal", () => { - when.typeKeys("d"); - then(get.elementByTestId("modal:sources")).shouldBeVisible(); + test("'d' should show sources modal", async () => { + await when.typeKeys("d"); + await then(get.elementByTestId("modal:sources")).shouldBeVisible(); }); - test("'s' should show settings modal", () => { - when.typeKeys("s"); - then(get.elementByTestId("modal:settings")).shouldBeVisible(); + test("'s' should show settings modal", async () => { + await when.typeKeys("s"); + await then(get.elementByTestId("modal:settings")).shouldBeVisible(); }); - test("'i' should change map to inspect mode", () => { - when.typeKeys("i"); - then(get.inputValue("maputnik-select")).shouldEqual("inspect"); + test("'i' should change map to inspect mode", async () => { + await when.typeKeys("i"); + await then(get.inputValue("maputnik-select")).shouldEqual("inspect"); }); - test("'m' should focus map", () => { - when.typeKeys("m"); - then(get.canvas()).shouldBeFocused(); + test("'m' should focus map", async () => { + await when.typeKeys("m"); + await then(get.canvas()).shouldBeFocused(); }); - test("'!' should show debug modal", () => { - when.typeKeys("!"); - then(get.elementByTestId("modal:debug")).shouldBeVisible(); + test("'!' should show debug modal", async () => { + await when.typeKeys("!"); + await then(get.elementByTestId("modal:debug")).shouldBeVisible(); }); }); }); diff --git a/e2e/layer-editor.spec.ts b/e2e/layer-editor.spec.ts index 1b3b074d..c510315b 100644 --- a/e2e/layer-editor.spec.ts +++ b/e2e/layer-editor.spec.ts @@ -1,98 +1,79 @@ -import { MaputnikDriver } from "./maputnik-driver"; import { v1 as uuid } from "uuid"; - -const test = it; +import { beforeEach, describe, test } from "./utils/fixtures"; +import { MaputnikDriver } from "./maputnik-driver"; describe("layer editor", () => { - const { beforeAndAfter, get, when, then } = new MaputnikDriver(); - beforeAndAfter(); - beforeEach(() => { - when.setStyle("both"); - when.modal.open(); + const { given, get, when, then } = new MaputnikDriver(); + + beforeEach(async () => { + await given.setupMockBackedResponses(); + await when.setStyle("both"); + await when.modal.open(); }); - function createBackground() { + async function createBackground() { const id = uuid(); - when.selectWithin("add-layer.layer-type", "background"); - when.setValue("add-layer.layer-id.input", "background:" + id); + await when.selectWithin("add-layer.layer-type", "background"); + await when.setValue("add-layer.layer-id.input", "background:" + id); - when.click("add-layer"); + await when.click("add-layer"); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: "background:" + id, - type: "background", - }, - ], + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id: "background:" + id, type: "background" }], }); return id; } - test("expand/collapse"); - test("id", () => { - const bgId = createBackground(); + test.skip("expand/collapse", () => {}); - when.click("layer-list-item:background:" + bgId); + test("id", async () => { + const bgId = await createBackground(); + + await when.click("layer-list-item:background:" + bgId); const id = uuid(); - when.setValue("layer-editor.layer-id.input", "foobar:" + id); - when.click("min-zoom"); + await when.setValue("layer-editor.layer-id.input", "foobar:" + id); + await when.click("min-zoom"); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: "foobar:" + id, - type: "background", - }, - ], + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id: "foobar:" + id, type: "background" }], }); }); describe("source", () => { - test("should show error when the source is invalid", () => { - when.modal.fillLayers({ + test("should show error when the source is invalid", async () => { + await when.modal.fillLayers({ type: "circle", layer: "invalid", }); - then(get.element(".maputnik-input-block--error .maputnik-input-block-label")).shouldHaveCss("color", "rgb(207, 74, 74)"); + await then( + get.element(".maputnik-input-block--error .maputnik-input-block-label") + ).shouldHaveCss("color", "rgb(207, 74, 74)"); }); }); describe("min-zoom", () => { let bgId: string; - beforeEach(() => { - bgId = createBackground(); - when.click("layer-list-item:background:" + bgId); - when.setValue("min-zoom.input-text", "1"); - when.click("layer-editor.layer-id"); + beforeEach(async () => { + bgId = await createBackground(); + await when.click("layer-list-item:background:" + bgId); + await when.setValue("min-zoom.input-text", "1"); + await when.click("layer-editor.layer-id"); }); - test("should update min-zoom in local storage", () => { - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: "background:" + bgId, - type: "background", - minzoom: 1, - }, - ], + test("should update min-zoom in local storage", async () => { + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id: "background:" + bgId, type: "background", minzoom: 1 }], }); }); - it("when clicking next layer should update style on local storage", () => { - when.type("min-zoom.input-text", "{backspace}"); - when.click("max-zoom.input-text"); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: "background:" + bgId, - type: "background", - minzoom: 1, - }, - ], + test("when clicking next layer should update style on local storage", async () => { + await when.type("min-zoom.input-text", "{backspace}"); + await when.click("max-zoom.input-text"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id: "background:" + bgId, type: "background", minzoom: 1 }], }); }); }); @@ -100,22 +81,16 @@ describe("layer editor", () => { describe("max-zoom", () => { let bgId: string; - beforeEach(() => { - bgId = createBackground(); - when.click("layer-list-item:background:" + bgId); - when.setValue("max-zoom.input-text", "1"); - when.click("layer-editor.layer-id"); + beforeEach(async () => { + bgId = await createBackground(); + await when.click("layer-list-item:background:" + bgId); + await when.setValue("max-zoom.input-text", "1"); + await when.click("layer-editor.layer-id"); }); - test("should update style in local storage", () => { - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: "background:" + bgId, - type: "background", - maxzoom: 1, - }, - ], + test("should update style in local storage", async () => { + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id: "background:" + bgId, type: "background", maxzoom: 1 }], }); }); }); @@ -124,41 +99,34 @@ describe("layer editor", () => { let bgId: string; const comment = "42"; - beforeEach(() => { - bgId = createBackground(); - when.click("layer-list-item:background:" + bgId); - when.setValue("layer-comment.input", comment); - when.click("layer-editor.layer-id"); + beforeEach(async () => { + bgId = await createBackground(); + await when.click("layer-list-item:background:" + bgId); + await when.setValue("layer-comment.input", comment); + await when.click("layer-editor.layer-id"); }); - test("should update style in local storage", () => { - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + test("should update style in local storage", async () => { + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [ { id: "background:" + bgId, type: "background", - metadata: { - "maputnik:comment": comment, - }, + metadata: { "maputnik:comment": comment }, }, ], }); }); describe("when unsetting", () => { - beforeEach(() => { - when.clear("layer-comment.input"); - when.click("min-zoom.input-text"); + beforeEach(async () => { + await when.clear("layer-comment.input"); + await when.click("min-zoom.input-text"); }); - test("should update style in local storage", () => { - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: "background:" + bgId, - type: "background", - }, - ], + test("should update style in local storage", async () => { + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id: "background:" + bgId, type: "background" }], }); }); }); @@ -166,131 +134,113 @@ describe("layer editor", () => { describe("color", () => { let bgId: string; - beforeEach(() => { - bgId = createBackground(); - when.click("layer-list-item:background:" + bgId); - when.click("spec-field:background-color"); + beforeEach(async () => { + bgId = await createBackground(); + await when.click("layer-list-item:background:" + bgId); + await when.click("spec-field:background-color"); }); - test("should update style in local storage", () => { - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: "background:" + bgId, - type: "background", - }, - ], + test("should update style in local storage", async () => { + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id: "background:" + bgId, type: "background" }], }); }); }); describe("opacity", () => { let bgId: string; - beforeEach(() => { - bgId = createBackground(); - when.click("layer-list-item:background:" + bgId); - when.type("spec-field-input:background-opacity", "0."); + beforeEach(async () => { + bgId = await createBackground(); + await when.click("layer-list-item:background:" + bgId); + await when.type("spec-field-input:background-opacity", "0."); }); - test("should keep '.' in the input field", () => { - then(get.elementByTestId("spec-field-input:background-opacity")).shouldHaveValue("0."); + test("should keep '.' in the input field", async () => { + await then(get.elementByTestId("spec-field-input:background-opacity")).shouldHaveValue("0."); }); - test("should revert to a valid value when focus out", () => { - when.click("layer-list-item:background:" + bgId); - then(get.elementByTestId("spec-field-input:background-opacity")).shouldHaveValue("0"); + test("should revert to a valid value when focus out", async () => { + await when.click("layer-list-item:background:" + bgId); + await then(get.elementByTestId("spec-field-input:background-opacity")).shouldHaveValue("0"); }); }); - - describe("filter", () => { - test("expand/collapse"); - test("compound filter"); + test.skip("expand/collapse", () => {}); + test.skip("compound filter", () => {}); }); describe("layout", () => { - test("text-font", () => { - when.setStyle("font"); - when.collapseGroupInLayerEditor(); - when.collapseGroupInLayerEditor(1); - when.collapseGroupInLayerEditor(2); - when.doWithin("spec-field:text-font", () => { - get.element(".maputnik-autocomplete input").first().click(); - }); - then(get.element(".maputnik-autocomplete-menu-item")).shouldBeVisible(); - then(get.element(".maputnik-autocomplete-menu-item")).shouldHaveLength(3); + test("text-font", async () => { + await when.setStyle("font"); + await when.collapseGroupInLayerEditor(); + await when.collapseGroupInLayerEditor(1); + await when.collapseGroupInLayerEditor(2); + await when.clickWithin("spec-field:text-font", ".maputnik-autocomplete input"); + await then(get.element(".maputnik-autocomplete-menu-item")).shouldBeVisible(); + await then(get.element(".maputnik-autocomplete-menu-item")).shouldHaveLength(3); }); }); describe("paint", () => { - test("expand/collapse"); - test("color"); - test("pattern"); - test("opacity"); + test.skip("expand/collapse", () => {}); + test.skip("color", () => {}); + test.skip("pattern", () => {}); + test.skip("opacity", () => {}); }); describe("json-editor", () => { - test("add", () => { - const id = when.modal.fillLayers({ + test("add", async () => { + const id = await when.modal.fillLayers({ type: "circle", layer: "example", }); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: id, - type: "circle", - source: "example", - }, - ], + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "circle", source: "example" }], }); - const sourceText = get.elementByText('"source"'); + await when.clickByText('"source"'); + await when.typeKeys('"'); - sourceText.click(); - sourceText.type("\""); - - then(get.element(".cm-lint-marker-error")).shouldExist(); + await then(get.element(".cm-lint-marker-error")).shouldExist(); }); + test.skip("expand/collapse", () => {}); + test.skip("modify", () => {}); - test("expand/collapse"); - test("modify"); + test("parse error", async () => { + const bgId = await createBackground(); - test("parse error", () => { - const bgId = createBackground(); + await when.click("layer-list-item:background:" + bgId); + await when.collapseGroupInLayerEditor(); + await when.collapseGroupInLayerEditor(1); + await then(get.element(".cm-lint-marker-error")).shouldNotExist(); - when.click("layer-list-item:background:" + bgId); - when.collapseGroupInLayerEditor(); - when.collapseGroupInLayerEditor(1); - then(get.element(".cm-lint-marker-error")).shouldNotExist(); - - when.appendTextInJsonEditor( - "\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013 {" - ); - then(get.element(".cm-lint-marker-error")).shouldExist(); + // Inject an invalid token (CodeMirror auto-closes brackets/quotes, so a + // bare word reliably breaks the JSON) and expect a lint error. + await when.appendTextInJsonEditor("zzz"); + await then(get.element(".cm-lint-marker-error")).shouldExist(); }); }); describe("sticky header", () => { - test("should keep layer header visible when scrolling properties", () => { - // Setup: Create a layer with many properties (e.g., symbol layer) - when.modal.fillLayers({ + test("should keep layer header visible when scrolling properties", async () => { + // Setup: Create a layer with many properties (e.g. symbol layer) + await when.modal.fillLayers({ type: "symbol", layer: "example", }); - when.wait(500); + await when.wait(500); const header = get.elementByTestId("layer-editor.header"); - then(header).shouldBeVisible(); + await then(header).shouldBeVisible(); - get.element(".maputnik-scroll-container").scrollTo("bottom", { ensureScrollable: false }); - when.wait(200); + await when.scrollToBottom(get.element(".maputnik-scroll-container")); + await when.wait(200); - then(header).shouldBeVisible(); - then(get.elementByTestId("skip-target-layer-editor")).shouldBeVisible(); + await then(header).shouldBeVisible(); + await then(get.elementByTestId("skip-target-layer-editor")).shouldBeVisible(); }); }); }); diff --git a/e2e/layers-list.spec.ts b/e2e/layers-list.spec.ts index 41e6705e..786a1b3d 100644 --- a/e2e/layers-list.spec.ts +++ b/e2e/layers-list.spec.ts @@ -1,412 +1,265 @@ +import { beforeEach, describe, test } from "./utils/fixtures"; import { MaputnikDriver } from "./maputnik-driver"; -const test = it; describe("layers list", () => { - const { beforeAndAfter, get, when, then } = new MaputnikDriver(); - beforeAndAfter(); - beforeEach(() => { - when.setStyle("both"); - when.modal.open(); + const { given, get, when, then } = new MaputnikDriver(); + + beforeEach(async () => { + await given.setupMockBackedResponses(); + await when.setStyle("both"); + await when.modal.open(); }); describe("ops", () => { let id: string; - beforeEach(() => { - id = when.modal.fillLayers({ - type: "background", - }); + beforeEach(async () => { + id = await when.modal.fillLayers({ type: "background" }); }); - test("should update layers in local storage", () => { - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: id, - type: "background", - }, - ], + test("should update layers in local storage", async () => { + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "background" }], }); }); describe("when clicking delete", () => { - beforeEach(() => { - when.click("layer-list-item:" + id + ":delete"); + beforeEach(async () => { + await when.click("layer-list-item:" + id + ":delete"); }); - test("should empty layers in local storage", () => { - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + test("should empty layers in local storage", async () => { + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [], }); }); }); describe("when clicking duplicate", () => { - beforeEach(() => { - when.click("layer-list-item:" + id + ":copy"); + beforeEach(async () => { + await when.click("layer-list-item:" + id + ":copy"); }); - test("should add copy layer in local storage", () => { - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + test("should add copy layer in local storage", async () => { + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [ - { - id: id + "-copy", - type: "background", - }, - { - id: id, - type: "background", - }, + { id: id + "-copy", type: "background" }, + { id, type: "background" }, ], }); }); }); describe("when clicking hide", () => { - beforeEach(() => { - when.click("layer-list-item:" + id + ":toggle-visibility"); + beforeEach(async () => { + await when.click("layer-list-item:" + id + ":toggle-visibility"); }); - test("should update visibility to none in local storage", () => { - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: id, - type: "background", - layout: { - visibility: "none", - }, - }, - ], + test("should update visibility to none in local storage", async () => { + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "background", layout: { visibility: "none" } }], }); }); describe("when clicking show", () => { - beforeEach(() => { - when.click("layer-list-item:" + id + ":toggle-visibility"); + beforeEach(async () => { + await when.click("layer-list-item:" + id + ":toggle-visibility"); }); - test("should update visibility to visible in local storage", () => { - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: id, - type: "background", - layout: { - visibility: "visible", - }, - }, - ], + test("should update visibility to visible in local storage", async () => { + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "background", layout: { visibility: "visible" } }], }); }); }); describe("when selecting a layer", () => { let secondId: string; - beforeEach(() => { - when.modal.open(); - secondId = when.modal.fillLayers({ + beforeEach(async () => { + await when.modal.open(); + secondId = await when.modal.fillLayers({ id: "second-layer", type: "background", }); }); - test("should show the selected layer in the editor", () => { - when.realClick("layer-list-item:" + secondId); - then(get.elementByTestId("layer-editor.layer-id.input")).shouldHaveValue(secondId); - when.realClick("layer-list-item:" + id); - then(get.elementByTestId("layer-editor.layer-id.input")).shouldHaveValue(id); + test("should show the selected layer in the editor", async () => { + await when.realClick("layer-list-item:" + secondId); + await then(get.elementByTestId("layer-editor.layer-id.input")).shouldHaveValue(secondId); + await when.realClick("layer-list-item:" + id); + await then(get.elementByTestId("layer-editor.layer-id.input")).shouldHaveValue(id); }); }); }); }); describe("background", () => { - test("add", () => { - const id = when.modal.fillLayers({ - type: "background", - }); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: id, - type: "background", - }, - ], + test("add", async () => { + const id = await when.modal.fillLayers({ type: "background" }); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "background" }], }); }); - describe("modify", () => {}); + test.skip("modify", () => {}); }); describe("fill", () => { - test("add", () => { - const id = when.modal.fillLayers({ - type: "fill", - layer: "example", - }); - - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: id, - type: "fill", - source: "example", - }, - ], + test("add", async () => { + const id = await when.modal.fillLayers({ type: "fill", layer: "example" }); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "fill", source: "example" }], }); }); // TODO: Change source - test("change source"); + test.skip("change source", () => {}); }); describe("line", () => { - test("add", () => { - const id = when.modal.fillLayers({ - type: "line", - layer: "example", - }); - - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: id, - type: "line", - source: "example", - }, - ], + test("add", async () => { + const id = await when.modal.fillLayers({ type: "line", layer: "example" }); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "line", source: "example" }], }); }); - test("groups", () => { - when.modal.open(); - const id1 = when.modal.fillLayers({ - id: "aa", - type: "line", - layer: "example", - }); + test("groups", async () => { + const id1 = await when.modal.fillLayers({ id: "aa", type: "line", layer: "example" }); - when.modal.open(); - const id2 = when.modal.fillLayers({ - id: "aa-2", - type: "line", - layer: "example", - }); + await when.modal.open(); + const id2 = await when.modal.fillLayers({ id: "aa-2", type: "line", layer: "example" }); - when.modal.open(); - const id3 = when.modal.fillLayers({ - id: "b", - type: "line", - layer: "example", - }); + await when.modal.open(); + const id3 = await when.modal.fillLayers({ id: "b", type: "line", layer: "example" }); - then(get.elementByTestId("layer-list-item:" + id1)).shouldBeVisible(); - then(get.elementByTestId("layer-list-item:" + id2)).shouldNotBeVisible(); - then(get.elementByTestId("layer-list-item:" + id3)).shouldBeVisible(); - when.click("layer-list-group:aa-0"); - then(get.elementByTestId("layer-list-item:" + id1)).shouldBeVisible(); - then(get.elementByTestId("layer-list-item:" + id2)).shouldBeVisible(); - then(get.elementByTestId("layer-list-item:" + id3)).shouldBeVisible(); - when.click("layer-list-item:" + id2); - when.click("skip-target-layer-editor"); - when.click("menu-move-layer-down"); - then(get.elementByTestId("layer-list-group:aa-0")).shouldNotExist(); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + await then(get.elementByTestId("layer-list-item:" + id1)).shouldBeVisible(); + await then(get.elementByTestId("layer-list-item:" + id2)).shouldNotBeVisible(); + await then(get.elementByTestId("layer-list-item:" + id3)).shouldBeVisible(); + await when.click("layer-list-group:aa-0"); + await then(get.elementByTestId("layer-list-item:" + id1)).shouldBeVisible(); + await then(get.elementByTestId("layer-list-item:" + id2)).shouldBeVisible(); + await then(get.elementByTestId("layer-list-item:" + id3)).shouldBeVisible(); + await when.click("layer-list-item:" + id2); + await when.click("skip-target-layer-editor"); + await when.click("menu-move-layer-down"); + await then(get.elementByTestId("layer-list-group:aa-0")).shouldNotExist(); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [ - { - id: "aa", - type: "line", - source: "example", - }, - { - id: "b", - type: "line", - source: "example", - }, - { - id: "aa-2", - type: "line", - source: "example", - }, + { id: "aa", type: "line", source: "example" }, + { id: "b", type: "line", source: "example" }, + { id: "aa-2", type: "line", source: "example" }, ], }); }); }); describe("symbol", () => { - test("add", () => { - const id = when.modal.fillLayers({ - type: "symbol", - layer: "example", - }); - - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: id, - type: "symbol", - source: "example", - }, - ], + test("add", async () => { + const id = await when.modal.fillLayers({ type: "symbol", layer: "example" }); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "symbol", source: "example" }], }); }); - test("should show spec info when hovering and clicking single line property", () => { - when.modal.fillLayers({ - type: "symbol", - layer: "example", - }); + test("should show spec info when hovering and clicking single line property", async () => { + await when.modal.fillLayers({ type: "symbol", layer: "example" }); - when.hover("spec-field-container:text-rotate"); - then(get.elementByTestId("field-doc-button-Rotate")).shouldBeVisible(); - when.click("field-doc-button-Rotate", 0); - then(get.elementByTestId("spec-field-doc")).shouldContainText("Rotates the "); + await when.hover("spec-field-container:text-rotate"); + await then(get.elementByTestId("field-doc-button-Rotate")).shouldBeVisible(); + await when.click("field-doc-button-Rotate", 0); + await then(get.elementByTestId("spec-field-doc")).shouldContainText("Rotates the "); }); - test("should show spec info when hovering and clicking multi line property", () => { - when.modal.fillLayers({ - type: "symbol", - layer: "example", - }); + test("should show spec info when hovering and clicking multi line property", async () => { + await when.modal.fillLayers({ type: "symbol", layer: "example" }); - when.hover("spec-field-container:text-offset"); - then(get.elementByTestId("field-doc-button-Offset")).shouldBeVisible(); - when.click("field-doc-button-Offset", 0); - then(get.elementByTestId("spec-field-doc")).shouldContainText("Offset distance"); + await when.hover("spec-field-container:text-offset"); + await then(get.elementByTestId("field-doc-button-Offset")).shouldBeVisible(); + await when.click("field-doc-button-Offset", 0); + await then(get.elementByTestId("spec-field-doc")).shouldContainText("Offset distance"); }); - test("should hide spec info when clicking a second time", () => { - when.modal.fillLayers({ - type: "symbol", - layer: "example", - }); + test("should hide spec info when clicking a second time", async () => { + await when.modal.fillLayers({ type: "symbol", layer: "example" }); - when.hover("spec-field-container:text-rotate"); - then(get.elementByTestId("field-doc-button-Rotate")).shouldBeVisible(); - when.click("field-doc-button-Rotate", 0); - when.wait(200); - when.click("field-doc-button-Rotate", 0); - then(get.elementByTestId("spec-field-doc")).shouldNotBeVisible(); + await when.hover("spec-field-container:text-rotate"); + await then(get.elementByTestId("field-doc-button-Rotate")).shouldBeVisible(); + await when.click("field-doc-button-Rotate", 0); + await when.wait(200); + await when.click("field-doc-button-Rotate", 0); + await then(get.elementByTestId("spec-field-doc")).shouldNotBeVisible(); }); }); describe("raster", () => { - test("add", () => { - const id = when.modal.fillLayers({ - type: "raster", - layer: "raster", - }); - - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: id, - type: "raster", - source: "raster", - }, - ], + test("add", async () => { + 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", () => { - const id = when.modal.fillLayers({ - type: "circle", - layer: "example", - }); - - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: id, - type: "circle", - source: "example", - }, - ], + 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", () => { - const id = when.modal.fillLayers({ - type: "fill-extrusion", - layer: "example", - }); - - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: id, - type: "fill-extrusion", - source: "example", - }, - ], + test("add", async () => { + 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", () => { - const id = when.modal.fillLayers({ - type: "hillshade", - layer: "example", + test("add", async () => { + const id = await when.modal.fillLayers({ type: "hillshade", layer: "example" }); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "hillshade", source: "example" }], }); + }); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + test("set hillshade illumination direction array", async () => { + const id = await when.modal.fillLayers({ type: "hillshade", layer: "example" }); + await when.collapseGroupInLayerEditor(); + await when.collapseGroupInLayerEditor(1); + await when.setValueToPropertyArray("spec-field:hillshade-illumination-direction", "1"); + await when.addValueToPropertyArray("spec-field:hillshade-illumination-direction", "2"); + await when.addValueToPropertyArray("spec-field:hillshade-illumination-direction", "3"); + await when.addValueToPropertyArray("spec-field:hillshade-illumination-direction", "4"); + + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [ { - id: id, + id, type: "hillshade", source: "example", + paint: { "hillshade-illumination-direction": [1, 2, 3, 4] }, }, ], }); }); - test("set hillshade illumination direction array", () => { - const id = when.modal.fillLayers({ - type: "hillshade", - layer: "example", - }); - when.collapseGroupInLayerEditor(); - when.collapseGroupInLayerEditor(1); - when.setValueToPropertyArray("spec-field:hillshade-illumination-direction", "1"); - when.addValueToPropertyArray("spec-field:hillshade-illumination-direction", "2"); - when.addValueToPropertyArray("spec-field:hillshade-illumination-direction", "3"); - when.addValueToPropertyArray("spec-field:hillshade-illumination-direction", "4"); + test("set hillshade highlight color array", async () => { + const id = await when.modal.fillLayers({ type: "hillshade", layer: "example" }); + await when.collapseGroupInLayerEditor(); + await when.setValueToPropertyArray("spec-field:hillshade-highlight-color", "blue"); + await when.addValueToPropertyArray("spec-field:hillshade-highlight-color", "#00ff00"); + await when.addValueToPropertyArray("spec-field:hillshade-highlight-color", "rgba(255, 255, 0, 1)"); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [ { - id: id, + id, type: "hillshade", source: "example", paint: { - "hillshade-illumination-direction": [ 1, 2, 3, 4 ] - } - }, - ], - }); - }); - - test("set hillshade highlight color array", () => { - const id = when.modal.fillLayers({ - type: "hillshade", - layer: "example", - }); - when.collapseGroupInLayerEditor(); - when.setValueToPropertyArray("spec-field:hillshade-highlight-color", "blue"); - when.addValueToPropertyArray("spec-field:hillshade-highlight-color", "#00ff00"); - when.addValueToPropertyArray("spec-field:hillshade-highlight-color", "rgba(255, 255, 0, 1)"); - - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: id, - type: "hillshade", - source: "example", - paint: { - "hillshade-highlight-color": [ "blue", "#00ff00", "rgba(255, 255, 0, 1)" ] - } + "hillshade-highlight-color": ["blue", "#00ff00", "rgba(255, 255, 0, 1)"], + }, }, ], }); @@ -414,129 +267,85 @@ describe("layers list", () => { }); describe("color-relief", () => { - test("add", () => { - const id = when.modal.fillLayers({ - type: "color-relief", - layer: "example", - }); - - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ - layers: [ - { - id: id, - type: "color-relief", - source: "example", - }, - ], + test("add", async () => { + const id = await when.modal.fillLayers({ type: "color-relief", layer: "example" }); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + layers: [{ id, type: "color-relief", source: "example" }], }); }); - test("adds elevation expression when clicking the elevation button", () => { - when.modal.fillLayers({ - type: "color-relief", - layer: "example", - }); - when.collapseGroupInLayerEditor(); - when.click("make-elevation-function"); - then(get.element("[data-wd-key='spec-field-container:color-relief-color'] .cm-line")).shouldBeVisible(); - }); - }); - - describe("groups", () => { - test("simple", () => { - when.setStyle("geojson"); - - when.modal.open(); - when.modal.fillLayers({ - id: "foo", - type: "background", - }); - - when.modal.open(); - when.modal.fillLayers({ - id: "foo_bar", - type: "background", - }); - - when.modal.open(); - when.modal.fillLayers({ - id: "foo_bar_baz", - type: "background", - }); - - then(get.elementByTestId("layer-list-item:foo")).shouldBeVisible(); - then(get.elementByTestId("layer-list-item:foo_bar")).shouldNotBeVisible(); - then( - get.elementByTestId("layer-list-item:foo_bar_baz") - ).shouldNotBeVisible(); - when.click("layer-list-group:foo-0"); - then(get.elementByTestId("layer-list-item:foo")).shouldBeVisible(); - then(get.elementByTestId("layer-list-item:foo_bar")).shouldBeVisible(); - then( - get.elementByTestId("layer-list-item:foo_bar_baz") + test("adds elevation expression when clicking the elevation button", async () => { + await when.modal.fillLayers({ type: "color-relief", layer: "example" }); + await when.collapseGroupInLayerEditor(); + await when.click("make-elevation-function"); + await then( + get.element("[data-wd-key='spec-field-container:color-relief-color'] .cm-line") ).shouldBeVisible(); }); }); + describe("groups", () => { + test("simple", async () => { + await when.setStyle("geojson"); + + await when.modal.open(); + await when.modal.fillLayers({ id: "foo", type: "background" }); + + await when.modal.open(); + await when.modal.fillLayers({ id: "foo_bar", type: "background" }); + + await when.modal.open(); + await when.modal.fillLayers({ id: "foo_bar_baz", type: "background" }); + + await then(get.elementByTestId("layer-list-item:foo")).shouldBeVisible(); + await then(get.elementByTestId("layer-list-item:foo_bar")).shouldNotBeVisible(); + await then(get.elementByTestId("layer-list-item:foo_bar_baz")).shouldNotBeVisible(); + await when.click("layer-list-group:foo-0"); + await then(get.elementByTestId("layer-list-item:foo")).shouldBeVisible(); + await then(get.elementByTestId("layer-list-item:foo_bar")).shouldBeVisible(); + await then(get.elementByTestId("layer-list-item:foo_bar_baz")).shouldBeVisible(); + }); + }); + describe("drag and drop", () => { - test("move layer should update local storage", () => { - when.modal.open(); - const firstId = when.modal.fillLayers({ - id: "a", - type: "background", - }); - when.modal.open(); - const secondId = when.modal.fillLayers({ - id: "b", - type: "background", - }); - when.modal.open(); - const thirdId = when.modal.fillLayers({ - id: "c", - type: "background", - }); + test("move layer should update local storage", async () => { + const firstId = await when.modal.fillLayers({ id: "a", type: "background" }); + await when.modal.open(); + const secondId = await when.modal.fillLayers({ id: "b", type: "background" }); + await when.modal.open(); + const thirdId = await when.modal.fillLayers({ id: "c", type: "background" }); - when.dragAndDropWithWait("layer-list-item:" + firstId, "layer-list-item:" + thirdId); + await when.dragAndDropWithWait("layer-list-item:" + firstId, "layer-list-item:" + thirdId); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [ - { - id: secondId, - type: "background", - }, - { - id: thirdId, - type: "background", - }, - { - id: firstId, - type: "background", - }, + { id: secondId, type: "background" }, + { id: thirdId, type: "background" }, + { id: firstId, type: "background" }, ], }); }); }); describe("sticky header", () => { - test("should keep header visible when scrolling layer list", () => { + test("should keep header visible when scrolling layer list", async () => { // Setup: Create multiple layers to enable scrolling - for (let i = 0; i < 20; i++) { - when.modal.open(); - when.modal.fillLayers({ - id: `layer-${i}`, - type: "background", - }); + // The modal is already open (beforeEach) for the first layer. + await when.modal.fillLayers({ id: "layer-0", type: "background" }); + for (let i = 1; i < 20; i++) { + await when.modal.open(); + await when.modal.fillLayers({ id: `layer-${i}`, type: "background" }); } - when.wait(500); + await when.wait(500); const header = get.elementByTestId("layer-list.header"); - then(header).shouldBeVisible(); + await then(header).shouldBeVisible(); - // Scroll the layer list container (use ensureScrollable: false to avoid flakiness) - get.elementByTestId("layer-list").scrollTo("bottom", { ensureScrollable: false }); - when.wait(200); - then(header).shouldBeVisible(); - then(get.elementByTestId("layer-list:add-layer")).shouldBeVisible(); + // Scroll the layer list container + await when.scrollToBottom(get.elementByTestId("layer-list")); + await when.wait(200); + await then(header).shouldBeVisible(); + await then(get.elementByTestId("layer-list:add-layer")).shouldBeVisible(); }); }); }); diff --git a/e2e/map.spec.ts b/e2e/map.spec.ts index 046620f7..b33fdc66 100644 --- a/e2e/map.spec.ts +++ b/e2e/map.spec.ts @@ -1,69 +1,67 @@ +import { beforeEach, describe, test } from "./utils/fixtures"; import { MaputnikDriver } from "./maputnik-driver"; -const test = it; - describe("map", () => { - const { beforeAndAfter, get, when, then } = new MaputnikDriver(); - beforeAndAfter(); + const { given, get, when, then } = new MaputnikDriver(); + + beforeEach(async () => { + await given.setupMockBackedResponses(); + await when.setStyle("both"); + }); + describe("zoom level", () => { - test("via url", () => { + test("via url", async () => { const zoomLevel = 12.37; - when.setStyle("geojson", zoomLevel); - then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible(); - then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText( - "Zoom: " + zoomLevel - ); + await when.setStyle("geojson", zoomLevel); + await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible(); + await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText("Zoom: " + zoomLevel); }); - test("via map controls", () => { + test("via map controls", async () => { const zoomLevel = 12.37; - when.setStyle("geojson", zoomLevel); - then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible(); - when.clickZoomIn(); - then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText( - "Zoom: " + (zoomLevel + 1) - ); + await when.setStyle("geojson", zoomLevel); + await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible(); + await when.clickZoomIn(); + await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText("Zoom: " + (zoomLevel + 1)); }); - test("via style file definition", () => { - when.setStyle("zoom_7_center_0_51"); - then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible(); - then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText( - "Zoom: " + (7) - ); - then(get.locationHash().should("contain", "#7/51/0")); + test("via style file definition", async () => { + await when.setStyle("zoom_7_center_0_51"); + await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible(); + await then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText("Zoom: " + 7); + await then(get.locationHash()).shouldInclude("#7/51/0"); // opening another stylefile does not update the map view again // as discussed in https://github.com/maplibre/maputnik/issues/1546 - when.openASecondStyleWithDifferentZoomAndCenter(); - then(get.locationHash().should("contain", "#7/51/0")); - + await when.openASecondStyleWithDifferentZoomAndCenter(); + await then(get.locationHash()).shouldInclude("#7/51/0"); }); }); describe("search", () => { - test("should exist", () => { - then(get.searchControl()).shouldBeVisible(); + test("should exist", async () => { + await then(get.searchControl()).shouldBeVisible(); }); }); describe("popup", () => { - beforeEach(() => { - when.setStyle("rectangles"); - then(get.locationHash().should("exist")); - }); - test("should open on feature click", () => { - when.clickCenter("maplibre:map"); - then(get.elementByTestId("feature-layer-popup")).shouldBeVisible(); + beforeEach(async () => { + await when.setStyle("rectangles"); + await then(get.locationHash()).shouldExist(); }); - test("should open a second feature after closing popup", () => { - when.clickCenter("maplibre:map"); - then(get.elementByTestId("feature-layer-popup")).shouldBeVisible(); - when.closePopup(); - then(get.elementByTestId("feature-layer-popup")).shouldNotExist(); - when.clickCenter("maplibre:map"); - then(get.elementByTestId("feature-layer-popup")).shouldBeVisible(); + test("should open on feature click", async () => { + await when.clickCenter("maplibre:map"); + await then(get.elementByTestId("feature-layer-popup")).shouldBeVisible(); + }); + + test("should open a second feature after closing popup", async () => { + await when.clickCenter("maplibre:map"); + await then(get.elementByTestId("feature-layer-popup")).shouldBeVisible(); + await when.closePopup(); + await then(get.elementByTestId("feature-layer-popup")).shouldNotExist(); + await when.clickCenter("maplibre:map"); + await then(get.elementByTestId("feature-layer-popup")).shouldBeVisible(); }); }); }); diff --git a/e2e/maputnik-cypress-helper.ts b/e2e/maputnik-cypress-helper.ts deleted file mode 100644 index 90467828..00000000 --- a/e2e/maputnik-cypress-helper.ts +++ /dev/null @@ -1,56 +0,0 @@ -/// -import { CypressHelper } from "@shellygo/cypress-test-utils"; -import "cypress-real-events/support"; - -export default class MaputnikCypressHelper { - private helper = new CypressHelper({ defaultDataAttribute: "data-wd-key" }); - - public given = { - ...this.helper.given, - }; - - public get = { - locationHash: (): Cypress.Chainable => cy.location("hash"), - ...this.helper.get, - }; - - public when = { - dragAndDropWithWait: (element: string, targetElement: string) => { - this.helper.get.elementByTestId(element).realMouseDown({ button: "left", position: "center" }); - this.helper.get.elementByTestId(element).realMouseMove(0, 10, { position: "center" }); - this.helper.get.elementByTestId(targetElement).realMouseMove(0, 0, { position: "center" }); - this.helper.when.wait(1); - this.helper.get.elementByTestId(targetElement).realMouseUp(); - }, - clickCenter: (element: string) => { - this.helper.get.elementByTestId(element).realMouseDown({ button: "left", position: "center" }); - this.helper.when.wait(200); - this.helper.get.elementByTestId(element).realMouseUp(); - }, - openFileByFixture: (fixture: string, buttonTestId: string, inputTestId: string) => { - cy.window().then((win) => { - const file = { - text: cy.stub().resolves(cy.fixture(fixture).then(JSON.stringify)), - }; - const fileHandle = { - getFile: cy.stub().resolves(file), - }; - if (!win.showOpenFilePicker) { - this.helper.get.elementByTestId(inputTestId).selectFile("cypress/fixtures/" + fixture, { force: true }); - } else { - cy.stub(win, "showOpenFilePicker").resolves([fileHandle]); - this.helper.get.elementByTestId(buttonTestId).click(); - } - }); - }, - dropFileByFixture: (fixture: string, dropzoneTestId: string) => { - this.helper.get.elementByTestId(dropzoneTestId).selectFile("cypress/fixtures/" + fixture, { - action: "drag-drop", - force: true, - }); - }, - ...this.helper.when, - }; - - public beforeAndAfter = this.helper.beforeAndAfter; -} diff --git a/e2e/maputnik-driver.ts b/e2e/maputnik-driver.ts index 692b038d..ad594a9e 100644 --- a/e2e/maputnik-driver.ts +++ b/e2e/maputnik-driver.ts @@ -1,122 +1,55 @@ -/// +import { PlaywrightHelper } from "./playwright-helper"; +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 isMac = process.platform === "darwin"; -const styleFromWindow = (win: Window) => { - const styleId = win.localStorage.getItem("maputnik:latest_style"); - const styleItemKey = `maputnik:style:${styleId}`; - const styleItem = win.localStorage.getItem(styleItemKey); - if (!styleItem) throw new Error("Could not get styleItem from localStorage"); - const obj = JSON.parse(styleItem); - return obj; -}; - -export class MaputnikAssertable extends Assertable { - shouldEqualToStoredStyle = () => - then( - new CypressHelper().get.window().then((win: Window) => { - const style = styleFromWindow(win); - then(this.chainable).shouldDeepNestedInclude(style); - }) - ); -} - +/** + * The maputnik-specific driver. It builds on the generic {@link PlaywrightHelper} + * — spreading its `given`/`when`/`get` primitives and adding domain concepts + * (loading a style, the add-layer modal, the JSON editor, …). All Playwright + * access goes through the helper; the driver never touches `page` directly. + */ export class MaputnikDriver { - private helper = new MaputnikCypressHelper(); - private modalDriver = new ModalDriver(); + private readonly helper = new PlaywrightHelper(); + private readonly modalDriver = new ModalDriver(); - public beforeAndAfter = () => { - beforeEach(() => { - this.given.setupMockBackedResponses(); - this.when.setStyle("both"); - }); - }; + then = this.helper.then; - public then = (chainable: Cypress.Chainable) => - new MaputnikAssertable(chainable); + /** Reads the maputnik style currently persisted in localStorage. */ + private async readStoredStyle(): Promise { + 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"); + return JSON.parse(styleItem); + } public given = { ...this.helper.given, - setupMockBackedResponses: () => { - this.helper.given.interceptAndMockResponse({ - method: "GET", - url: baseUrl + "example-style.json", - response: { - fixture: "example-style.json", - }, - alias: "example-style.json", - }); - this.helper.given.interceptAndMockResponse({ - method: "GET", - url: baseUrl + "example-layer-style.json", - 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({ + + setupMockBackedResponses: async () => { + const styleFixtures = [ + "example-style.json", + "example-layer-style.json", + "geojson-style.json", + "raster-style.json", + "geojson-raster-style.json", + "rectangles-style.json", + "example-style-with-fonts.json", + "example-style-with-zoom-7-and-center-0-51.json", + "example-style-with-zoom-5-and-center-50-50.json", + ]; + for (const fixture of styleFixtures) { + await this.helper.given.interceptAndMockResponse({ + method: "GET", + url: baseUrl + fixture, + response: { fixture }, + alias: fixture === "example-style.json" ? "example-style.json" : undefined, + }); + } + await this.helper.given.interceptAndMockResponse({ method: "GET", url: /example\.local\//, response: [] }); + await this.helper.given.interceptAndMockResponse({ method: "GET", url: /example\.com\//, response: [] }); + await this.helper.given.interceptAndMockResponse({ method: "GET", url: "https://www.glyph-server.com/*", response: ["Font 1", "Font 2", "Font 3"], @@ -126,147 +59,134 @@ export class MaputnikDriver { public when = { ...this.helper.when, + modal: this.modalDriver.when, - doWithin: (selector: string, fn: () => void) => { - this.helper.when.doWithin(fn, selector); - }, - tab: () => this.helper.get.element("body").tab(), - waitForExampleFileResponse: () => { - this.helper.when.waitForResponse("example-style.json"); - }, - openASecondStyleWithDifferentZoomAndCenter: () => { - cy.contains("button", "Open").click(); - cy.get('[data-wd-key="modal:open.url.input"]') - .should("be.enabled") - .clear() - .type("http://localhost:8888/example-style-with-zoom-5-and-center-50-50.json{enter}"); - }, - 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" | "", + + setStyle: async ( + styleProperties: + | "geojson" + | "raster" + | "both" + | "layer" + | "rectangles" + | "font" + | "zoom_7_center_0_51" + | "", zoom?: number ) => { - const url = new URL(baseUrl); - switch (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; - } + const styleFileByKey: Record = { + 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); + if (styleProperties && styleFileByKey[styleProperties]) { + url.searchParams.set("style", baseUrl + styleFileByKey[styleProperties]); + } if (zoom) { url.hash = `${zoom}/41.3805/2.1635`; } - this.helper.when.visit(url.toString()); - if (styleProperties) { - this.helper.when.acceptConfirm(); - } - // when methods should not include assertions + + await this.helper.when.visit(url.toString()); + const toolbarLink = this.helper.get.elementByTestId("toolbar:link"); - toolbarLink.scrollIntoView(); - toolbarLink.should("be.visible"); + await toolbarLink.scrollIntoViewIfNeeded(); + await this.then(toolbarLink).shouldBeVisible(); }, - typeKeys: (keys: string) => this.helper.get.element("body").type(keys), - - clickZoomIn: () => { - this.helper.get.element(".maplibregl-ctrl-zoom-in").click(); + openASecondStyleWithDifferentZoomAndCenter: async () => { + await this.helper.when.clickButtonByName("Open"); + const input = this.helper.get.elementByTestId("modal:open.url.input"); + await input.fill("http://localhost:8888/example-style-with-zoom-5-and-center-50-50.json"); + await input.press("Enter"); }, - selectWithin: (selector: string, value: string) => { - this.when.doWithin(selector, () => { - this.helper.get.element("select").select(value); - }); + chooseExampleFile: async () => { + await this.helper.when.openFileByFixture("example-style.json", "modal:open.dropzone", "modal:open.file.input"); + await this.helper.when.wait(200); }, - select: (selector: string, value: string) => { - this.helper.get.elementByTestId(selector).select(value); + dropExampleFile: async () => { + await this.helper.when.dropFileByFixture("example-style.json", "modal:open.dropzone"); + await this.helper.when.wait(200); }, - focus: (selector: string) => { - this.helper.when.focus(selector); + clickZoomIn: async () => { + await this.helper.get.element(".maplibregl-ctrl-zoom-in").click(); }, - setValue: (selector: string, text: string) => { - this.helper.get - .elementByTestId(selector) - .clear() - .type(text, { parseSpecialCharSequences: false }); + closePopup: async () => { + await this.helper.get.element(".maplibregl-popup-close-button").click(); }, - setValueToPropertyArray: (selector: string, value: string) => { - this.when.doWithin(selector, () => { - this.helper.get.element(".maputnik-array-block-content input").last().type("{selectall}"+value, {force: true }); - }); + collapseGroupInLayerEditor: async (index = 0) => { + await this.helper.get.element(".maputnik-layer-editor-group__button").nth(index).click(); }, - addValueToPropertyArray: (selector: string, value: string) => { - this.when.doWithin(selector, () => { - this.helper.get.element(".maputnik-array-add-value").click({ force: true }); - this.helper.get.element(".maputnik-array-block-content input").last().type("{selectall}"+value, {force: true }); - }); + appendTextInJsonEditor: async (text: string) => { + await this.helper.get.element(".cm-line").first().click(); + // Move to the very start of the document so the inserted text breaks the + // root JSON structure (CodeMirror auto-closes brackets otherwise). + await this.helper.when.typeKeys("{home}"); + await this.helper.when.typeText(text); }, - closePopup: () => { - this.helper.get.element(".maplibregl-popup-close-button").click(); + setTextInJsonEditor: async (text: string) => { + await this.helper.get.element(".cm-line").first().click(); + await this.helper.when.typeKeys("{selectall}"); + await this.helper.when.typeText(text); }, - collapseGroupInLayerEditor: (index = 0) => { - this.helper.get.element(".maputnik-layer-editor-group__button").eq(index).realClick(); + setValueToPropertyArray: async (selector: string, value: string) => { + const input = this.helper.get.elementByTestId(selector).locator(".maputnik-array-block-content input").last(); + await input.focus(); + await this.helper.when.typeKeys("{selectall}"); + await this.helper.when.typeText(value); }, - appendTextInJsonEditor: (text: string) => { - this.helper.get.element(".cm-line").first().click().type(text, { parseSpecialCharSequences: false }); + addValueToPropertyArray: async (selector: string, value: string) => { + const block = this.helper.get.elementByTestId(selector); + 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); }, - setTextInJsonEditor: (text: string) => { - this.helper.get.element(".cm-line").first().click().clear().type(text, { parseSpecialCharSequences: false }); - } + waitForExampleFileResponse: () => this.helper.when.waitForResponse("example-style.json"), + + /** Fill localStorage until we get a QuotaExceededError. */ + fillLocalStorage: () => this.helper.when.fillLocalStorageUntilQuota("maputnik:fill-"), }; public get = { ...this.helper.get, - isMac: () => { - return Cypress.platform === "darwin"; - }, - styleFromLocalStorage: () => - this.helper.get.window().then((win) => styleFromWindow(win)), + isMac: () => isMac, - 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") + + 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", }; } diff --git a/e2e/modal-driver.ts b/e2e/modal-driver.ts index 76878b83..3f7e4915 100644 --- a/e2e/modal-driver.ts +++ b/e2e/modal-driver.ts @@ -1,40 +1,38 @@ import { v1 as uuid } from "uuid"; -import MaputnikCypressHelper from "./maputnik-cypress-helper"; +import { PlaywrightHelper } from "./playwright-helper"; -export default class ModalDriver { - private helper = new MaputnikCypressHelper(); +export class ModalDriver { + private readonly helper = new PlaywrightHelper(); public when = { - fillLayers: (opts: { type: string; layer?: string; id?: string }) => { - // Having logic in test code is an anti pattern. - // This should be split to multiple single responsibility functions - const type = opts.type; - const layer = opts.layer; - let id; - if (opts.id) { - id = opts.id; - } else { - id = `${type}:${uuid()}`; - } - this.helper.when.selectOption("add-layer.layer-type.select", type); - this.helper.when.type("add-layer.layer-id.input", id); + fillLayers: async (opts: { type: string; layer?: string; id?: string }) => { + const { when, get, then } = this.helper; + const id = opts.id ?? `${opts.type}:${uuid()}`; - if (layer) { - this.helper.when.doWithin(() => { - this.helper.get.element("input").clear().type(layer!); - }, "add-layer.layer-source-block"); + await when.select("add-layer.layer-type.select", opts.type); + await when.type("add-layer.layer-id.input", id); + + if (opts.layer) { + const input = get.elementByTestId("add-layer.layer-source-block").locator("input"); + await input.click(); + await input.fill(opts.layer); + // The source input is a controlled downshift combobox; wait for React to + // settle on the typed value before submitting. + await 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(); } - this.helper.when.click("add-layer"); + await when.click("add-layer"); return id; }, - open: () => { - this.helper.when.click("layer-list:add-layer"); + open: async () => { + await this.helper.when.click("layer-list:add-layer"); }, - close: (key: string) => { - this.helper.when.click(key + ".close-modal"); + close: async (key: string) => { + await this.helper.when.click(key + ".close-modal"); }, }; } diff --git a/e2e/modals.spec.ts b/e2e/modals.spec.ts index f2c13fe5..370fb556 100644 --- a/e2e/modals.spec.ts +++ b/e2e/modals.spec.ts @@ -1,103 +1,106 @@ +import { test, expect, describe, beforeEach } from "./utils/fixtures"; import { MaputnikDriver } from "./maputnik-driver"; -import tokens from "../src/config/tokens.json" with {type: "json"}; -const test = it; +import tokens from "../src/config/tokens.json" with { type: "json" }; describe("modals", () => { - const { beforeAndAfter, when, get, given, then } = new MaputnikDriver(); - beforeAndAfter(); + const { given, get, when, then } = new MaputnikDriver(); - beforeEach(() => { - when.setStyle(""); + beforeEach(async () => { + await given.setupMockBackedResponses(); + await when.setStyle("both"); + await when.setStyle(""); }); + describe("open", () => { - beforeEach(() => { - when.click("nav:open"); + beforeEach(async () => { + await when.click("nav:open"); }); - test("close", () => { - when.modal.close("modal:open"); - then(get.elementByTestId("modal:open")).shouldNotExist(); + test("close", async () => { + await when.modal.close("modal:open"); + await then(get.elementByTestId("modal:open")).shouldNotExist(); }); - test("upload", () => { - when.chooseExampleFile(); - then(get.fixture("example-style.json")).shouldEqualToStoredStyle(); + test("upload", async () => { + await when.chooseExampleFile(); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.fixture("example-style.json")); }); - test("upload via drag and drop", () => { - when.dropExampleFile(); - then(get.fixture("example-style.json")).shouldEqualToStoredStyle(); + test("upload via drag and drop", async () => { + await when.dropExampleFile(); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.fixture("example-style.json")); }); describe("when click open url", () => { - beforeEach(() => { + beforeEach(async () => { const styleFileUrl = get.exampleFileUrl(); - when.setValue("modal:open.url.input", styleFileUrl); - when.click("modal:open.url.button"); - when.wait(200); + await when.setValue("modal:open.url.input", styleFileUrl); + await when.click("modal:open.url.button"); + await when.wait(200); }); - test("load from url", () => { - then(get.responseBody("example-style.json")).shouldEqualToStoredStyle(); + test("load from url", async () => { + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.responseBody("example-style.json")); }); }); }); describe("shortcuts", () => { - test("open/close", () => { - when.setStyle(""); - when.typeKeys("?"); - when.modal.close("modal:shortcuts"); - then(get.elementByTestId("modal:shortcuts")).shouldNotExist(); + test("open/close", async () => { + await when.setStyle(""); + await when.typeKeys("?"); + await when.modal.close("modal:shortcuts"); + await then(get.elementByTestId("modal:shortcuts")).shouldNotExist(); }); }); describe("export", () => { - beforeEach(() => { - when.click("nav:export"); + beforeEach(async () => { + await when.click("nav:export"); }); - test("close", () => { - when.modal.close("modal:export"); - then(get.elementByTestId("modal:export")).shouldNotExist(); + test("close", async () => { + await when.modal.close("modal:export"); + await then(get.elementByTestId("modal:export")).shouldNotExist(); }); // TODO: Work out how to download a file and check the contents - test("download"); + test.skip("download", () => {}); }); describe("sources", () => { - beforeEach(() => { - when.setStyle("layer"); - when.click("nav:sources"); + beforeEach(async () => { + await when.setStyle("layer"); + await when.click("nav:sources"); }); - test("active sources"); - test("public source"); + test.skip("active sources", () => {}); + test.skip("public source", () => {}); - test("add new source", () => { + test("add new source", async () => { const sourceId = "n1z2v3r"; - when.setValue("modal:sources.add.source_id", sourceId); - when.select("modal:sources.add.source_type", "tile_vector"); - when.select("modal:sources.add.scheme_type", "tms"); - when.click("modal:sources.add.add_source"); - when.wait(200); - then( - get.styleFromLocalStorage().then((style) => style.sources[sourceId]) - ).shouldInclude({ + await when.setValue("modal:sources.add.source_id", sourceId); + await when.select("modal:sources.add.source_type", "tile_vector"); + await when.select("modal:sources.add.scheme_type", "tms"); + await when.click("modal:sources.add.add_source"); + await when.wait(200); + await then(get.styleFromLocalStorage().then((style) => style.sources[sourceId])).shouldInclude({ scheme: "tms", }); }); - test("add new pmtiles source", () => { + test("add new pmtiles source", async () => { const sourceId = "pmtilestest"; - when.setValue("modal:sources.add.source_id", sourceId); - when.select("modal:sources.add.source_type", "pmtiles_vector"); - when.setValue("modal:sources.add.source_url", "https://data.source.coop/protomaps/openstreetmap/v4.pmtiles"); - when.click("modal:sources.add.add_source"); - when.click("modal:sources.add.add_source"); - when.wait(200); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + await when.setValue("modal:sources.add.source_id", sourceId); + await when.select("modal:sources.add.source_type", "pmtiles_vector"); + await when.setValue( + "modal:sources.add.source_url", + "https://data.source.coop/protomaps/openstreetmap/v4.pmtiles" + ); + await when.click("modal:sources.add.add_source"); + await when.click("modal:sources.add.add_source"); + await when.wait(200); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ sources: { pmtilestest: { type: "vector", @@ -107,351 +110,296 @@ describe("modals", () => { }); }); - test("add new raster source", () => { + test("add new raster source", async () => { const sourceId = "rastertest"; - when.setValue("modal:sources.add.source_id", sourceId); - when.select("modal:sources.add.source_type", "tile_raster"); - when.select("modal:sources.add.scheme_type", "xyz"); - when.setValue("modal:sources.add.tile_size", "128"); - when.click("modal:sources.add.add_source"); - when.wait(200); - then( - get.styleFromLocalStorage().then((style) => style.sources[sourceId]) - ).shouldInclude({ + await when.setValue("modal:sources.add.source_id", sourceId); + await when.select("modal:sources.add.source_type", "tile_raster"); + await when.select("modal:sources.add.scheme_type", "xyz"); + await when.setValue("modal:sources.add.tile_size", "128"); + await when.click("modal:sources.add.add_source"); + await when.wait(200); + await then(get.styleFromLocalStorage().then((style) => style.sources[sourceId])).shouldInclude({ tileSize: 128, }); }); }); describe("inspect", () => { - test("toggle", () => { + test("toggle", async () => { // There is no assertion in this test - when.setStyle("geojson"); - when.select("maputnik-select", "inspect"); + await when.setStyle("geojson"); + await when.select("maputnik-select", "inspect"); }); }); describe("style settings", () => { - beforeEach(() => { - when.click("nav:settings"); + beforeEach(async () => { + await when.click("nav:settings"); }); describe("when click name filed spec information", () => { - beforeEach(() => { - when.click("field-doc-button-Name"); + beforeEach(async () => { + await when.click("field-doc-button-Name"); }); - test("should show the spec information", () => { - then(get.elementsText("spec-field-doc")).shouldInclude( - "name for the style" - ); + test("should show the spec information", async () => { + await then(get.elementsText("spec-field-doc")).shouldInclude("name for the style"); }); }); describe("when set name and click owner", () => { - beforeEach(() => { - when.setValue("modal:settings.name", "foobar"); - when.click("modal:settings.owner"); - when.wait(200); + beforeEach(async () => { + await when.setValue("modal:settings.name", "foobar"); + await when.click("modal:settings.owner"); + await when.wait(200); }); - test("show name specifications", () => { - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + test("show name specifications", async () => { + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ name: "foobar", }); }); }); describe("when set owner and click name", () => { - beforeEach(() => { - when.setValue("modal:settings.owner", "foobar"); - when.click("modal:settings.name"); - when.wait(200); + beforeEach(async () => { + await when.setValue("modal:settings.owner", "foobar"); + await when.click("modal:settings.name"); + await when.wait(200); }); - test("should update owner in local storage", () => { - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + test("should update owner in local storage", async () => { + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ owner: "foobar", }); }); }); - test("sprite url", () => { - when.setTextInJsonEditor("\"http://example.com\""); - when.click("modal:settings.name"); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + test("sprite url", async () => { + await when.setTextInJsonEditor('"http://example.com"'); + await when.click("modal:settings.name"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ sprite: "http://example.com", }); }); - test("sprite object", () => { - when.setTextInJsonEditor(JSON.stringify([{ id: "1", url: "2" }])); + test("sprite object", async () => { + await when.setTextInJsonEditor(JSON.stringify([{ id: "1", url: "2" }])); - when.click("modal:settings.name"); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + await when.click("modal:settings.name"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ sprite: [{ id: "1", url: "2" }], }); }); - test("glyphs url", () => { + test("glyphs url", async () => { const glyphsUrl = "http://example.com/{fontstack}/{range}.pbf"; - when.setValue("modal:settings.glyphs", glyphsUrl); - when.click("modal:settings.name"); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + await when.setValue("modal:settings.glyphs", glyphsUrl); + await when.click("modal:settings.name"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ glyphs: glyphsUrl, }); }); - test("maptiler access token", () => { + test("maptiler access token", async () => { const apiKey = "testing123"; - when.setValue( - "modal:settings.maputnik:openmaptiles_access_token", - apiKey - ); - when.click("modal:settings.name"); - then( - get.styleFromLocalStorage().then((style) => style.metadata) - ).shouldInclude({ + await when.setValue("modal:settings.maputnik:openmaptiles_access_token", apiKey); + await when.click("modal:settings.name"); + await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({ "maputnik:openmaptiles_access_token": apiKey, }); }); - test("thunderforest access token", () => { + test("thunderforest access token", async () => { const apiKey = "testing123"; - when.setValue( - "modal:settings.maputnik:thunderforest_access_token", - apiKey - ); - when.click("modal:settings.name"); - then( - get.styleFromLocalStorage().then((style) => style.metadata) - ).shouldInclude({ "maputnik:thunderforest_access_token": apiKey }); + await when.setValue("modal:settings.maputnik:thunderforest_access_token", apiKey); + await when.click("modal:settings.name"); + await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({ + "maputnik:thunderforest_access_token": apiKey, + }); }); - test("stadia access token", () => { + test("stadia access token", async () => { const apiKey = "testing123"; - when.setValue( - "modal:settings.maputnik:stadia_access_token", - apiKey - ); - when.click("modal:settings.name"); - then( - get.styleFromLocalStorage().then((style) => style.metadata) - ).shouldInclude({ "maputnik:stadia_access_token": apiKey }); + await when.setValue("modal:settings.maputnik:stadia_access_token", apiKey); + await when.click("modal:settings.name"); + await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({ + "maputnik:stadia_access_token": apiKey, + }); }); - test("locationiq access token", () => { + test("locationiq access token", async () => { const apiKey = "testing123"; - when.setValue( - "modal:settings.maputnik:locationiq_access_token", - apiKey - ); - when.click("modal:settings.name"); - then( - get.styleFromLocalStorage().then((style) => style.metadata) - ).shouldInclude({ "maputnik:locationiq_access_token": apiKey }); + await when.setValue("modal:settings.maputnik:locationiq_access_token", apiKey); + await when.click("modal:settings.name"); + await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({ + "maputnik:locationiq_access_token": apiKey, + }); }); - test("style projection mercator", () => { - when.select("modal:settings.projection", "mercator"); - then( - get.styleFromLocalStorage().then((style) => style.projection) - ).shouldInclude({ type: "mercator" }); + test("style projection mercator", async () => { + await when.select("modal:settings.projection", "mercator"); + await then(get.styleFromLocalStorage().then((style) => style.projection)).shouldInclude({ + type: "mercator", + }); }); - test("style projection globe", () => { - when.select("modal:settings.projection", "globe"); - then( - get.styleFromLocalStorage().then((style) => style.projection) - ).shouldInclude({ type: "globe" }); + test("style projection globe", async () => { + await when.select("modal:settings.projection", "globe"); + await then(get.styleFromLocalStorage().then((style) => style.projection)).shouldInclude({ + type: "globe", + }); }); - - test("style projection vertical-perspective", () => { - when.select("modal:settings.projection", "vertical-perspective"); - then( - get.styleFromLocalStorage().then((style) => style.projection) - ).shouldInclude({ type: "vertical-perspective" }); - + test("style projection vertical-perspective", async () => { + await when.select("modal:settings.projection", "vertical-perspective"); + await then(get.styleFromLocalStorage().then((style) => style.projection)).shouldInclude({ + type: "vertical-perspective", + }); }); - test("style renderer", () => { - cy.on("uncaught:exception", () => false); // this is due to the fact that this is an invalid style for openlayers - when.select("modal:settings.maputnik:renderer", "ol"); - then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual( - "ol" - ); + test("style renderer", async () => { + await when.select("modal:settings.maputnik:renderer", "ol"); + await then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual("ol"); - when.click("modal:settings.name"); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + await when.click("modal:settings.name"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ 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"); - test("include API key when change renderer", () => { + await when.select("modal:settings.maputnik:renderer", "mlgljs"); + await then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual("mlgljs"); - when.click("modal:settings.close-modal"); - when.click("nav:open"); + await when.select("modal:settings.maputnik:renderer", "ol"); + await then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual("ol"); - get.elementByAttribute("aria-label", "MapTiler Basic").should("exist").click(); - when.wait(1000); - when.click("nav:settings"); - - when.select("modal:settings.maputnik:renderer", "mlgljs"); - then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual( - "mlgljs" + await given.intercept( + /https:\/\/api\.maptiler\.com\/tiles\/v3-openmaptiles\/tiles\.json\?key=.*/, + "tileRequest", + "GET" ); - when.select("modal:settings.maputnik:renderer", "ol"); - then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual( - "ol" + await when.select("modal:settings.maputnik:renderer", "mlgljs"); + await then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual("mlgljs"); + + 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", () => { - beforeEach(() => { - when.setStyle("layer"); - when.modal.open(); + beforeEach(async () => { + await when.setStyle("layer"); + await when.modal.open(); }); - test("shows duplicate id error", () => { - when.setValue("add-layer.layer-id.input", "background"); - when.click("add-layer"); - then(get.elementByTestId("modal:add-layer")).shouldExist(); - then(get.element(".maputnik-modal-error")).shouldContainText( - "Layer ID already exists" - ); + test("shows duplicate id error", async () => { + await when.setValue("add-layer.layer-id.input", "background"); + await when.click("add-layer"); + await then(get.elementByTestId("modal:add-layer")).shouldExist(); + await then(get.element(".maputnik-modal-error")).shouldContainText("Layer ID already exists"); }); }); - describe("sources", () => { - test("toggle"); + describe("sources placeholder", () => { + test.skip("toggle", () => {}); }); describe("global state", () => { - beforeEach(() => { - when.click("nav:global-state"); + beforeEach(async () => { + await when.click("nav:global-state"); }); - test("add variable", () => { - when.wait(100); - when.click("global-state-add-variable"); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + test("add variable", async () => { + await when.wait(100); + await when.click("global-state-add-variable"); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ state: { key1: { default: "value" } }, }); }); - - test("add multiple variables", () => { - when.click("global-state-add-variable"); - when.click("global-state-add-variable"); - when.click("global-state-add-variable"); - when.wait(100); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + test("add multiple variables", async () => { + await when.click("global-state-add-variable"); + await when.click("global-state-add-variable"); + await when.click("global-state-add-variable"); + await when.wait(100); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ state: { key1: { default: "value" }, key2: { default: "value" }, key3: { default: "value" } }, }); }); - test("remove variable", () => { - when.click("global-state-add-variable"); - when.click("global-state-add-variable"); - when.click("global-state-add-variable"); - when.click("global-state-remove-variable", 0); - when.wait(100); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + test("remove variable", async () => { + await when.click("global-state-add-variable"); + await when.click("global-state-add-variable"); + await when.click("global-state-add-variable"); + await when.click("global-state-remove-variable", 0); + await when.wait(100); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ state: { key2: { default: "value" }, key3: { default: "value" } }, }); }); - test("edit variable key", () => { - when.click("global-state-add-variable"); - when.wait(100); - when.setValue("global-state-variable-key:0", "mykey"); - when.typeKeys("{enter}"); - when.wait(100); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + test("edit variable key", async () => { + await when.click("global-state-add-variable"); + await when.wait(100); + await when.setValue("global-state-variable-key:0", "mykey"); + await when.typeKeys("{enter}"); + await when.wait(100); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ state: { mykey: { default: "value" } }, }); }); - test("edit variable value", () => { - when.click("global-state-add-variable"); - when.wait(100); - when.setValue("global-state-variable-value:0", "myvalue"); - when.typeKeys("{enter}"); - when.wait(100); - then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ + test("edit variable value", async () => { + await when.click("global-state-add-variable"); + await when.wait(100); + await when.setValue("global-state-variable-value:0", "myvalue"); + await when.typeKeys("{enter}"); + await when.wait(100); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ state: { key1: { default: "myvalue" } }, }); }); }); describe("error panel", () => { - test("not visible when no errors", () => { - then(get.element("maputnik-message-panel-error")).shouldNotExist(); + test("not visible when no errors", async () => { + await then(get.element("maputnik-message-panel-error")).shouldNotExist(); }); - test("visible on style error", () => { - when.modal.open(); - when.modal.fillLayers({ + test("visible on style error", async () => { + await when.modal.open(); + await when.modal.fillLayers({ type: "circle", layer: "invalid", }); - then(get.element(".maputnik-message-panel-error")).shouldBeVisible(); + await then(get.element(".maputnik-message-panel-error")).shouldBeVisible(); }); }); describe("Handle localStorage QuotaExceededError", () => { - test("handles quota exceeded error when opening style from URL", () => { + test("handles quota exceeded error when opening style from URL", async () => { // Clear localStorage to start fresh - cy.clearLocalStorage(); - - // 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 - } - } - }); + await when.clearLocalStorage(); + await when.fillLocalStorage(); // Open the style via URL input - when.click("nav:open"); - when.setValue("modal:open.url.input", get.exampleFileUrl()); - when.click("modal:open.url.button"); + await when.click("nav:open"); + await when.setValue("modal:open.url.input", get.exampleFileUrl()); + await when.click("modal:open.url.button"); - then(get.responseBody("example-style.json")).shouldEqualToStoredStyle(); - then(get.styleFromLocalStorage()).shouldExist(); + await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.responseBody("example-style.json")); + await then(get.styleFromLocalStorage()).shouldExist(); }); }); }); diff --git a/e2e/playwright-helper.ts b/e2e/playwright-helper.ts new file mode 100644 index 00000000..b99a2f02 --- /dev/null +++ b/e2e/playwright-helper.ts @@ -0,0 +1,414 @@ +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, + timeout = 10000, + interval = 100 +): Promise { + 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 { + readonly __query = true as const; + constructor(private readonly getter: () => Promise) {} + + get(): Promise { + return this.getter(); + } + + then(mapper: (value: T) => U | Promise): Query { + return new Query(async () => mapper(await this.getter())); + } +} + +function isQuery(target: unknown): target is Query { + return typeof target === "object" && target !== null && (target as Query).__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): 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 { + 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 { + const target = this.target; + if (isQuery(target)) { + await retry(async () => assertion(await target.get())); + } else { + assertion(await (target as any)); + } + } + + // Element assertions (auto-retrying via Playwright web-first assertions). + shouldBeVisible = () => expect(this.locator().first()).toBeVisible(); + // Some testids resolve to many elements that are always rendered but hidden + // (e.g. per-field documentation panels); "not visible" means none is visible. + shouldNotBeVisible = () => expect(this.locator().filter({ visible: true })).toHaveCount(0); + shouldExist = async () => { + if (isLocator(this.target)) { + await expect(this.locator().first()).toBeAttached(); + } else { + await this.assertValue((value) => expect(value).toBeTruthy()); + } + }; + shouldNotExist = () => expect(this.locator()).toHaveCount(0); + shouldBeFocused = () => expect(this.locator().first()).toBeFocused(); + shouldNotBeFocused = () => expect(this.locator().first()).not.toBeFocused(); + shouldHaveValue = (value: string) => expect(this.locator().first()).toHaveValue(value); + shouldContainText = async (text: string) => { + const locator = this.locator(); + // Prefer the visible element when a testid resolves to several (only the + // open documentation panel is visible; the rest are hidden in the DOM). + const target = (await locator.count()) > 1 ? locator.filter({ visible: true }).first() : locator.first(); + await expect(target).toContainText(text); + }; + shouldHaveText = (text: string) => expect(this.locator().first()).toHaveText(text); + shouldHaveLength = (length: number) => expect(this.locator()).toHaveCount(length); + shouldHaveCss = (property: string, value: string) => expect(this.locator().first()).toHaveCSS(property, value); + + // Value assertions (auto-retrying for Query targets). + shouldEqual = (value: any) => this.assertValue((actual) => expect(actual).toBe(value)); + + shouldInclude = (value: any) => + this.assertValue((actual) => { + if (typeof value === "object" && value !== null) { + expect(actual).toMatchObject(value); + } else { + expect(String(actual)).toContain(String(value)); + } + }); + + shouldDeepNestedInclude = (value: Record) => + this.assertValue((actual) => assertDeepNestedInclude(actual, value)); +} + +async function typeSequence(page: Page, text: string): Promise { + const tokens = text.match(/\{[^}]+\}|[^{]+/g) ?? []; + const modifierMap: Record = { meta: "Meta", ctrl: "Control", shift: "Shift", alt: "Alt" }; + const namedKeys: Record = { + esc: "Escape", + enter: "Enter", + backspace: "Backspace", + del: "Delete", + tab: "Tab", + 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(); + + 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(getter: () => Promise): Query { + return new Query(getter); + } + + /** Entry point for fluent assertions over a Locator or a value/Query. */ + public then = (target: T): Assertable => 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