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