mirror of
https://github.com/maputnik/editor.git
synced 2026-08-02 19:27:25 +00:00
Compare commits
28 Commits
3896c4c187
...
70a2db580f
| Author | SHA1 | Date | |
|---|---|---|---|
| 70a2db580f | |||
| b76d9cbe29 | |||
| dd04c14d96 | |||
| 6c31add041 | |||
| 60ed80651c | |||
| c642ec5325 | |||
| 0c98378243 | |||
| 95ba3c4428 | |||
| b112c6a2bf | |||
| a251faf8f7 | |||
| 8580499ec3 | |||
| 56a9840def | |||
| 3d1919738b | |||
| cc317c20a1 | |||
| a9c3da0f40 | |||
| dc5d6dd77d | |||
| 95d87ad7ef | |||
| 09efbb5cd6 | |||
| 4f046cb83e | |||
| fb00dea285 | |||
| c00395ee11 | |||
| 5092a924aa | |||
| 7d80dccb81 | |||
| a93fe4d78b | |||
| 603a30cba8 | |||
| 6f55fda7d4 | |||
| 8f0ee898ff | |||
| be9456d11b |
@@ -164,8 +164,3 @@ jobs:
|
||||
run: npm run test-e2e
|
||||
env:
|
||||
E2E_NO_WEBSERVER: "1"
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
files: ${{ github.workspace }}/coverage/coverage-final.json
|
||||
verbose: true
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { test } from "./fixtures";
|
||||
import { test, describe, beforeEach } from "./utils/fixtures";
|
||||
import { MaputnikDriver } from "./maputnik-driver";
|
||||
|
||||
test.describe("accessibility", () => {
|
||||
describe("accessibility", () => {
|
||||
const { given, get, when, then } = new MaputnikDriver();
|
||||
|
||||
test.beforeEach(async () => {
|
||||
beforeEach(async () => {
|
||||
await given.setupMockBackedResponses();
|
||||
await when.setStyle("both");
|
||||
});
|
||||
|
||||
test.describe("skip links", () => {
|
||||
test.beforeEach(async () => {
|
||||
describe("skip links", () => {
|
||||
beforeEach(async () => {
|
||||
await when.setStyle("layer");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { test } from "./fixtures";
|
||||
import { beforeEach, describe, test } from "./utils/fixtures";
|
||||
import { MaputnikDriver } from "./maputnik-driver";
|
||||
|
||||
test.describe("code editor", () => {
|
||||
describe("code editor", () => {
|
||||
const { given, get, when, then } = new MaputnikDriver();
|
||||
|
||||
test.beforeEach(async () => {
|
||||
beforeEach(async () => {
|
||||
await given.setupMockBackedResponses();
|
||||
await when.setStyle("both");
|
||||
});
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
const OUTPUT_DIR = path.resolve(process.cwd(), ".nyc_output");
|
||||
|
||||
/**
|
||||
* Reads the istanbul coverage object (injected by vite-plugin-istanbul) from the
|
||||
* given page. Returns `null` when the page has not been instrumented.
|
||||
*/
|
||||
export async function readCoverage(page: Page): Promise<unknown | null> {
|
||||
try {
|
||||
return await page.evaluate(() => (window as unknown as { __coverage__?: unknown }).__coverage__ ?? null);
|
||||
} catch {
|
||||
// Page might be navigating/closed.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a coverage chunk to `.nyc_output` so that `nyc report` can merge it.
|
||||
* istanbul-lib-coverage (used by nyc) sums the hit counts across every file it
|
||||
* finds, so writing one file per chunk is enough to accumulate coverage across
|
||||
* navigations and tests.
|
||||
*/
|
||||
export function writeCoverage(coverage: unknown, id: string): void {
|
||||
if (!coverage) return;
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
fs.writeFileSync(path.join(OUTPUT_DIR, `playwright-${id}.json`), JSON.stringify(coverage));
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
import { readCoverage, writeCoverage } from "./coverage";
|
||||
|
||||
let activePage: Page | undefined;
|
||||
const coverageChunks: unknown[] = [];
|
||||
|
||||
/** The page for the currently running test. Throws if used outside a test. */
|
||||
export function currentPage(): Page {
|
||||
if (!activePage) {
|
||||
throw new Error("No active page: a MaputnikDriver method was called outside of a running test.");
|
||||
}
|
||||
return activePage;
|
||||
}
|
||||
|
||||
/** Records a coverage snapshot (called before navigations, which reset __coverage__). */
|
||||
export function recordCoverageChunk(chunk: unknown): void {
|
||||
if (chunk) coverageChunks.push(chunk);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto fixture that binds the current test's page for the (page-lazy)
|
||||
* MaputnikDriver, auto-accepts confirm dialogs, and writes the istanbul
|
||||
* coverage collected during the test to `.nyc_output`.
|
||||
*/
|
||||
export const test = base.extend<{ maputnikPage: void }>({
|
||||
maputnikPage: [
|
||||
async ({ page }, use, testInfo) => {
|
||||
activePage = page;
|
||||
coverageChunks.length = 0;
|
||||
// Accept confirm dialogs (e.g. the "replace current style" prompt). These
|
||||
// are dismissed by default, which would cancel loading a style via URL.
|
||||
page.on("dialog", (dialog) => dialog.accept().catch(() => undefined));
|
||||
|
||||
await use();
|
||||
|
||||
const finalCoverage = await readCoverage(page);
|
||||
if (finalCoverage) coverageChunks.push(finalCoverage);
|
||||
coverageChunks.forEach((chunk, index) => writeCoverage(chunk, `${testInfo.testId}-${index}`));
|
||||
coverageChunks.length = 0;
|
||||
activePage = undefined;
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
});
|
||||
|
||||
export { expect };
|
||||
+3
-3
@@ -1,13 +1,13 @@
|
||||
import { test } from "./fixtures";
|
||||
import { beforeEach, describe, test } from "./utils/fixtures";
|
||||
import { MaputnikDriver } from "./maputnik-driver";
|
||||
|
||||
test.describe("history", () => {
|
||||
describe("history", () => {
|
||||
const { given, get, when, then } = new MaputnikDriver();
|
||||
|
||||
const undoKeyCombo = process.platform === "darwin" ? "{meta}z" : "{ctrl}z";
|
||||
const redoKeyCombo = process.platform === "darwin" ? "{meta}{shift}z" : "{ctrl}y";
|
||||
|
||||
test.beforeEach(async () => {
|
||||
beforeEach(async () => {
|
||||
await given.setupMockBackedResponses();
|
||||
await when.setStyle("both");
|
||||
});
|
||||
|
||||
+6
-6
@@ -1,15 +1,15 @@
|
||||
import { test } from "./fixtures";
|
||||
import { beforeEach, describe, test } from "./utils/fixtures";
|
||||
import { MaputnikDriver } from "./maputnik-driver";
|
||||
|
||||
test.describe("i18n", () => {
|
||||
describe("i18n", () => {
|
||||
const { given, get, when, then } = new MaputnikDriver();
|
||||
|
||||
test.beforeEach(async () => {
|
||||
beforeEach(async () => {
|
||||
await given.setupMockBackedResponses();
|
||||
await when.setStyle("both");
|
||||
});
|
||||
|
||||
test.describe("language detector", () => {
|
||||
describe("language detector", () => {
|
||||
test("English", async () => {
|
||||
await when.visit("?lng=en");
|
||||
await then(get.elementByTestId("maputnik-lang-select")).shouldHaveValue("en");
|
||||
@@ -21,8 +21,8 @@ test.describe("i18n", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("language switcher", () => {
|
||||
test.beforeEach(async () => {
|
||||
describe("language switcher", () => {
|
||||
beforeEach(async () => {
|
||||
await when.setStyle("layer");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { test } from "./fixtures";
|
||||
import { beforeEach, describe, test } from "./utils/fixtures";
|
||||
import { MaputnikDriver } from "./maputnik-driver";
|
||||
|
||||
test.describe("keyboard", () => {
|
||||
describe("keyboard", () => {
|
||||
const { given, get, when, then } = new MaputnikDriver();
|
||||
|
||||
test.beforeEach(async () => {
|
||||
beforeEach(async () => {
|
||||
await given.setupMockBackedResponses();
|
||||
await when.setStyle("both");
|
||||
});
|
||||
|
||||
test.describe("shortcuts", () => {
|
||||
test.beforeEach(async () => {
|
||||
describe("shortcuts", () => {
|
||||
beforeEach(async () => {
|
||||
await given.setupMockBackedResponses();
|
||||
await when.setStyle("");
|
||||
});
|
||||
|
||||
+24
-29
@@ -1,11 +1,11 @@
|
||||
import { v1 as uuid } from "uuid";
|
||||
import { test } from "./fixtures";
|
||||
import { beforeEach, describe, test } from "./utils/fixtures";
|
||||
import { MaputnikDriver } from "./maputnik-driver";
|
||||
|
||||
test.describe("layer editor", () => {
|
||||
describe("layer editor", () => {
|
||||
const { given, get, when, then } = new MaputnikDriver();
|
||||
|
||||
test.beforeEach(async () => {
|
||||
beforeEach(async () => {
|
||||
await given.setupMockBackedResponses();
|
||||
await when.setStyle("both");
|
||||
await when.modal.open();
|
||||
@@ -41,7 +41,7 @@ test.describe("layer editor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("source", () => {
|
||||
describe("source", () => {
|
||||
test("should show error when the source is invalid", async () => {
|
||||
await when.modal.fillLayers({
|
||||
type: "circle",
|
||||
@@ -53,10 +53,10 @@ test.describe("layer editor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("min-zoom", () => {
|
||||
describe("min-zoom", () => {
|
||||
let bgId: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
beforeEach(async () => {
|
||||
bgId = await createBackground();
|
||||
await when.click("layer-list-item:background:" + bgId);
|
||||
await when.setValue("min-zoom.input-text", "1");
|
||||
@@ -78,10 +78,10 @@ test.describe("layer editor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("max-zoom", () => {
|
||||
describe("max-zoom", () => {
|
||||
let bgId: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
beforeEach(async () => {
|
||||
bgId = await createBackground();
|
||||
await when.click("layer-list-item:background:" + bgId);
|
||||
await when.setValue("max-zoom.input-text", "1");
|
||||
@@ -95,11 +95,11 @@ test.describe("layer editor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("comments", () => {
|
||||
describe("comments", () => {
|
||||
let bgId: string;
|
||||
const comment = "42";
|
||||
|
||||
test.beforeEach(async () => {
|
||||
beforeEach(async () => {
|
||||
bgId = await createBackground();
|
||||
await when.click("layer-list-item:background:" + bgId);
|
||||
await when.setValue("layer-comment.input", comment);
|
||||
@@ -118,8 +118,8 @@ test.describe("layer editor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("when unsetting", () => {
|
||||
test.beforeEach(async () => {
|
||||
describe("when unsetting", () => {
|
||||
beforeEach(async () => {
|
||||
await when.clear("layer-comment.input");
|
||||
await when.click("min-zoom.input-text");
|
||||
});
|
||||
@@ -132,9 +132,9 @@ test.describe("layer editor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("color", () => {
|
||||
describe("color", () => {
|
||||
let bgId: string;
|
||||
test.beforeEach(async () => {
|
||||
beforeEach(async () => {
|
||||
bgId = await createBackground();
|
||||
await when.click("layer-list-item:background:" + bgId);
|
||||
await when.click("spec-field:background-color");
|
||||
@@ -147,9 +147,9 @@ test.describe("layer editor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("opacity", () => {
|
||||
describe("opacity", () => {
|
||||
let bgId: string;
|
||||
test.beforeEach(async () => {
|
||||
beforeEach(async () => {
|
||||
bgId = await createBackground();
|
||||
await when.click("layer-list-item:background:" + bgId);
|
||||
await when.type("spec-field-input:background-opacity", "0.");
|
||||
@@ -165,33 +165,31 @@ test.describe("layer editor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("filter", () => {
|
||||
describe("filter", () => {
|
||||
test.skip("expand/collapse", () => {});
|
||||
test.skip("compound filter", () => {});
|
||||
});
|
||||
|
||||
test.describe("layout", () => {
|
||||
describe("layout", () => {
|
||||
test("text-font", async () => {
|
||||
await when.setStyle("font");
|
||||
await when.collapseGroupInLayerEditor();
|
||||
await when.collapseGroupInLayerEditor(1);
|
||||
await when.collapseGroupInLayerEditor(2);
|
||||
await when.doWithin("spec-field:text-font", async () => {
|
||||
await get.element(".maputnik-autocomplete input").first().click();
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("paint", () => {
|
||||
describe("paint", () => {
|
||||
test.skip("expand/collapse", () => {});
|
||||
test.skip("color", () => {});
|
||||
test.skip("pattern", () => {});
|
||||
test.skip("opacity", () => {});
|
||||
});
|
||||
|
||||
test.describe("json-editor", () => {
|
||||
describe("json-editor", () => {
|
||||
test("add", async () => {
|
||||
const id = await when.modal.fillLayers({
|
||||
type: "circle",
|
||||
@@ -202,8 +200,7 @@ test.describe("layer editor", () => {
|
||||
layers: [{ id, type: "circle", source: "example" }],
|
||||
});
|
||||
|
||||
const sourceText = get.elementByText('"source"');
|
||||
await sourceText.click();
|
||||
await when.clickByText('"source"');
|
||||
await when.typeKeys('"');
|
||||
|
||||
await then(get.element(".cm-lint-marker-error")).shouldExist();
|
||||
@@ -227,7 +224,7 @@ test.describe("layer editor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("sticky header", () => {
|
||||
describe("sticky header", () => {
|
||||
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({
|
||||
@@ -239,9 +236,7 @@ test.describe("layer editor", () => {
|
||||
const header = get.elementByTestId("layer-editor.header");
|
||||
await then(header).shouldBeVisible();
|
||||
|
||||
await get
|
||||
.element(".maputnik-scroll-container")
|
||||
.evaluate((el) => el.scrollTo(0, el.scrollHeight));
|
||||
await when.scrollToBottom(get.element(".maputnik-scroll-container"));
|
||||
await when.wait(200);
|
||||
|
||||
await then(header).shouldBeVisible();
|
||||
|
||||
+31
-31
@@ -1,18 +1,18 @@
|
||||
import { test } from "./fixtures";
|
||||
import { beforeEach, describe, test } from "./utils/fixtures";
|
||||
import { MaputnikDriver } from "./maputnik-driver";
|
||||
|
||||
test.describe("layers list", () => {
|
||||
describe("layers list", () => {
|
||||
const { given, get, when, then } = new MaputnikDriver();
|
||||
|
||||
test.beforeEach(async () => {
|
||||
beforeEach(async () => {
|
||||
await given.setupMockBackedResponses();
|
||||
await when.setStyle("both");
|
||||
await when.modal.open();
|
||||
});
|
||||
|
||||
test.describe("ops", () => {
|
||||
describe("ops", () => {
|
||||
let id: string;
|
||||
test.beforeEach(async () => {
|
||||
beforeEach(async () => {
|
||||
id = await when.modal.fillLayers({ type: "background" });
|
||||
});
|
||||
|
||||
@@ -22,8 +22,8 @@ test.describe("layers list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("when clicking delete", () => {
|
||||
test.beforeEach(async () => {
|
||||
describe("when clicking delete", () => {
|
||||
beforeEach(async () => {
|
||||
await when.click("layer-list-item:" + id + ":delete");
|
||||
});
|
||||
test("should empty layers in local storage", async () => {
|
||||
@@ -33,8 +33,8 @@ test.describe("layers list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("when clicking duplicate", () => {
|
||||
test.beforeEach(async () => {
|
||||
describe("when clicking duplicate", () => {
|
||||
beforeEach(async () => {
|
||||
await when.click("layer-list-item:" + id + ":copy");
|
||||
});
|
||||
test("should add copy layer in local storage", async () => {
|
||||
@@ -47,8 +47,8 @@ test.describe("layers list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("when clicking hide", () => {
|
||||
test.beforeEach(async () => {
|
||||
describe("when clicking hide", () => {
|
||||
beforeEach(async () => {
|
||||
await when.click("layer-list-item:" + id + ":toggle-visibility");
|
||||
});
|
||||
|
||||
@@ -58,8 +58,8 @@ test.describe("layers list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("when clicking show", () => {
|
||||
test.beforeEach(async () => {
|
||||
describe("when clicking show", () => {
|
||||
beforeEach(async () => {
|
||||
await when.click("layer-list-item:" + id + ":toggle-visibility");
|
||||
});
|
||||
|
||||
@@ -70,9 +70,9 @@ test.describe("layers list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("when selecting a layer", () => {
|
||||
describe("when selecting a layer", () => {
|
||||
let secondId: string;
|
||||
test.beforeEach(async () => {
|
||||
beforeEach(async () => {
|
||||
await when.modal.open();
|
||||
secondId = await when.modal.fillLayers({
|
||||
id: "second-layer",
|
||||
@@ -89,7 +89,7 @@ test.describe("layers list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("background", () => {
|
||||
describe("background", () => {
|
||||
test("add", async () => {
|
||||
const id = await when.modal.fillLayers({ type: "background" });
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
@@ -100,7 +100,7 @@ test.describe("layers list", () => {
|
||||
test.skip("modify", () => {});
|
||||
});
|
||||
|
||||
test.describe("fill", () => {
|
||||
describe("fill", () => {
|
||||
test("add", async () => {
|
||||
const id = await when.modal.fillLayers({ type: "fill", layer: "example" });
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
@@ -112,7 +112,7 @@ test.describe("layers list", () => {
|
||||
test.skip("change source", () => {});
|
||||
});
|
||||
|
||||
test.describe("line", () => {
|
||||
describe("line", () => {
|
||||
test("add", async () => {
|
||||
const id = await when.modal.fillLayers({ type: "line", layer: "example" });
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
@@ -121,7 +121,6 @@ test.describe("layers list", () => {
|
||||
});
|
||||
|
||||
test("groups", async () => {
|
||||
await when.modal.open();
|
||||
const id1 = await when.modal.fillLayers({ id: "aa", type: "line", layer: "example" });
|
||||
|
||||
await when.modal.open();
|
||||
@@ -151,7 +150,7 @@ test.describe("layers list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("symbol", () => {
|
||||
describe("symbol", () => {
|
||||
test("add", async () => {
|
||||
const id = await when.modal.fillLayers({ type: "symbol", layer: "example" });
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
@@ -189,7 +188,7 @@ test.describe("layers list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("raster", () => {
|
||||
describe("raster", () => {
|
||||
test("add", async () => {
|
||||
const id = await when.modal.fillLayers({ type: "raster", layer: "raster" });
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
@@ -198,7 +197,7 @@ test.describe("layers list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("circle", () => {
|
||||
describe("circle", () => {
|
||||
test("add", async () => {
|
||||
const id = await when.modal.fillLayers({ type: "circle", layer: "example" });
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
@@ -207,7 +206,7 @@ test.describe("layers list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("fill extrusion", () => {
|
||||
describe("fill extrusion", () => {
|
||||
test("add", async () => {
|
||||
const id = await when.modal.fillLayers({ type: "fill-extrusion", layer: "example" });
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
@@ -216,7 +215,7 @@ test.describe("layers list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("hillshade", () => {
|
||||
describe("hillshade", () => {
|
||||
test("add", async () => {
|
||||
const id = await when.modal.fillLayers({ type: "hillshade", layer: "example" });
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
@@ -267,7 +266,7 @@ test.describe("layers list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("color-relief", () => {
|
||||
describe("color-relief", () => {
|
||||
test("add", async () => {
|
||||
const id = await when.modal.fillLayers({ type: "color-relief", layer: "example" });
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
@@ -285,7 +284,7 @@ test.describe("layers list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("groups", () => {
|
||||
describe("groups", () => {
|
||||
test("simple", async () => {
|
||||
await when.setStyle("geojson");
|
||||
|
||||
@@ -308,9 +307,8 @@ test.describe("layers list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("drag and drop", () => {
|
||||
describe("drag and drop", () => {
|
||||
test("move layer should update local storage", async () => {
|
||||
await when.modal.open();
|
||||
const firstId = await when.modal.fillLayers({ id: "a", type: "background" });
|
||||
await when.modal.open();
|
||||
const secondId = await when.modal.fillLayers({ id: "b", type: "background" });
|
||||
@@ -329,10 +327,12 @@ test.describe("layers list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("sticky header", () => {
|
||||
describe("sticky header", () => {
|
||||
test("should keep header visible when scrolling layer list", async () => {
|
||||
// Setup: Create multiple layers to enable scrolling
|
||||
for (let i = 0; i < 20; i++) {
|
||||
// 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" });
|
||||
}
|
||||
@@ -342,7 +342,7 @@ test.describe("layers list", () => {
|
||||
await then(header).shouldBeVisible();
|
||||
|
||||
// Scroll the layer list container
|
||||
await get.elementByTestId("layer-list").evaluate((el) => el.scrollTo(0, el.scrollHeight));
|
||||
await when.scrollToBottom(get.elementByTestId("layer-list"));
|
||||
await when.wait(200);
|
||||
await then(header).shouldBeVisible();
|
||||
await then(get.elementByTestId("layer-list:add-layer")).shouldBeVisible();
|
||||
|
||||
+7
-7
@@ -1,15 +1,15 @@
|
||||
import { test } from "./fixtures";
|
||||
import { beforeEach, describe, test } from "./utils/fixtures";
|
||||
import { MaputnikDriver } from "./maputnik-driver";
|
||||
|
||||
test.describe("map", () => {
|
||||
describe("map", () => {
|
||||
const { given, get, when, then } = new MaputnikDriver();
|
||||
|
||||
test.beforeEach(async () => {
|
||||
beforeEach(async () => {
|
||||
await given.setupMockBackedResponses();
|
||||
await when.setStyle("both");
|
||||
});
|
||||
|
||||
test.describe("zoom level", () => {
|
||||
describe("zoom level", () => {
|
||||
test("via url", async () => {
|
||||
const zoomLevel = 12.37;
|
||||
await when.setStyle("geojson", zoomLevel);
|
||||
@@ -38,14 +38,14 @@ test.describe("map", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("search", () => {
|
||||
describe("search", () => {
|
||||
test("should exist", async () => {
|
||||
await then(get.searchControl()).shouldBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("popup", () => {
|
||||
test.beforeEach(async () => {
|
||||
describe("popup", () => {
|
||||
beforeEach(async () => {
|
||||
await when.setStyle("rectangles");
|
||||
await then(get.locationHash()).shouldExist();
|
||||
});
|
||||
|
||||
+64
-451
@@ -1,270 +1,31 @@
|
||||
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 { readCoverage } from "./coverage";
|
||||
import { currentPage, recordCoverageChunk } from "./fixtures";
|
||||
import { PlaywrightHelper } from "./playwright-helper";
|
||||
import { ModalDriver } from "./modal-driver";
|
||||
|
||||
const baseUrl = "http://localhost:8888/";
|
||||
const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
|
||||
const DATA_ATTRIBUTE = "data-wd-key";
|
||||
|
||||
const isMac = process.platform === "darwin";
|
||||
|
||||
function testIdSelector(testId: string): string {
|
||||
return `[${DATA_ATTRIBUTE}="${testId}"]`;
|
||||
}
|
||||
/**
|
||||
* 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 readonly helper = new PlaywrightHelper();
|
||||
private readonly modalDriver = new ModalDriver();
|
||||
|
||||
export function readFixture(name: string): any {
|
||||
const contents = fs.readFileSync(path.join(FIXTURES_DIR, name), "utf-8");
|
||||
return JSON.parse(contents);
|
||||
}
|
||||
then = this.helper.then;
|
||||
|
||||
/** Reads the maputnik style currently persisted in localStorage. */
|
||||
function styleFromLocalStorage(page: Page): Promise<any> {
|
||||
return page.evaluate(() => {
|
||||
const styleId = window.localStorage.getItem("maputnik:latest_style");
|
||||
const styleItemKey = `maputnik:style:${styleId}`;
|
||||
const styleItem = window.localStorage.getItem(styleItemKey);
|
||||
/** Reads the maputnik style currently persisted in localStorage. */
|
||||
private async readStoredStyle(): Promise<any> {
|
||||
const styleId = await this.helper.get.localStorageItem("maputnik:latest_style");
|
||||
const styleItem = await this.helper.get.localStorageItem(`maputnik:style:${styleId}`);
|
||||
if (!styleItem) throw new Error("Could not get styleItem from localStorage");
|
||||
return JSON.parse(styleItem);
|
||||
});
|
||||
}
|
||||
|
||||
async function retry(assertion: () => Promise<void> | void, timeout = 10000, interval = 100): Promise<void> {
|
||||
const start = Date.now();
|
||||
let lastError: unknown;
|
||||
for (;;) {
|
||||
try {
|
||||
await assertion();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (Date.now() - start > timeout) throw lastError;
|
||||
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A lazily-evaluated value (e.g. the style in localStorage). Assertions on a
|
||||
* Query re-read the value until they pass, mirroring Cypress' retry-ability.
|
||||
*/
|
||||
export class Query<T> {
|
||||
readonly __maputnikQuery = true as const;
|
||||
constructor(private readonly getter: () => Promise<T>) {}
|
||||
|
||||
get(): Promise<T> {
|
||||
return this.getter();
|
||||
}
|
||||
|
||||
then<U>(mapper: (value: T) => U | Promise<U>): Query<U> {
|
||||
return new Query<U>(async () => mapper(await this.getter()));
|
||||
}
|
||||
}
|
||||
|
||||
function isQuery(target: unknown): target is Query<unknown> {
|
||||
return typeof target === "object" && target !== null && (target as Query<unknown>).__maputnikQuery === true;
|
||||
}
|
||||
|
||||
function isLocator(target: unknown): target is Locator {
|
||||
return (
|
||||
typeof target === "object" &&
|
||||
target !== null &&
|
||||
typeof (target as Locator).count === "function" &&
|
||||
typeof (target as Locator).boundingBox === "function"
|
||||
);
|
||||
}
|
||||
|
||||
/** Asserts that every top-level key in `expected` deep-equals its counterpart in `actual`. */
|
||||
function assertDeepNestedInclude(actual: any, expected: Record<string, unknown>): void {
|
||||
for (const key of Object.keys(expected)) {
|
||||
expect(actual?.[key], `property "${key}"`).toEqual(expected[key]);
|
||||
}
|
||||
}
|
||||
|
||||
export class MaputnikAssertable<T> {
|
||||
constructor(private readonly target: T, private readonly page?: Page) {}
|
||||
|
||||
private locator(): Locator {
|
||||
if (!isLocator(this.target)) throw new Error("Expected a Locator target for this assertion");
|
||||
return this.target;
|
||||
}
|
||||
|
||||
private async assertValue(assertion: (value: any) => void): Promise<void> {
|
||||
const target = this.target;
|
||||
if (isQuery(target)) {
|
||||
await retry(async () => assertion(await target.get()));
|
||||
} else {
|
||||
assertion(await (target as any));
|
||||
}
|
||||
}
|
||||
|
||||
// Element assertions (auto-retrying via Playwright web-first assertions).
|
||||
shouldBeVisible = () => expect(this.locator().first()).toBeVisible();
|
||||
// Some testids resolve to many elements that are always rendered but hidden
|
||||
// (e.g. per-field documentation panels); "not visible" means none is visible.
|
||||
shouldNotBeVisible = () => expect(this.locator().filter({ visible: true })).toHaveCount(0);
|
||||
shouldExist = async () => {
|
||||
if (isLocator(this.target)) {
|
||||
await expect(this.locator().first()).toBeAttached();
|
||||
} else {
|
||||
await this.assertValue((value) => expect(value).toBeTruthy());
|
||||
}
|
||||
};
|
||||
shouldNotExist = () => expect(this.locator()).toHaveCount(0);
|
||||
shouldBeFocused = () => expect(this.locator().first()).toBeFocused();
|
||||
shouldNotBeFocused = () => expect(this.locator().first()).not.toBeFocused();
|
||||
shouldHaveValue = (value: string) => expect(this.locator().first()).toHaveValue(value);
|
||||
shouldContainText = async (text: string) => {
|
||||
const locator = this.locator();
|
||||
// Prefer the visible element when a testid resolves to several (only the
|
||||
// open documentation panel is visible; the rest are hidden in the DOM).
|
||||
const target = (await locator.count()) > 1 ? locator.filter({ visible: true }).first() : locator.first();
|
||||
await expect(target).toContainText(text);
|
||||
};
|
||||
shouldHaveText = (text: string) => expect(this.locator().first()).toHaveText(text);
|
||||
shouldHaveLength = (length: number) => expect(this.locator()).toHaveCount(length);
|
||||
shouldHaveCss = (property: string, value: string) => expect(this.locator().first()).toHaveCSS(property, value);
|
||||
|
||||
// Value assertions (auto-retrying for Query targets).
|
||||
shouldEqual = (value: any) => this.assertValue((actual) => expect(actual).toBe(value));
|
||||
|
||||
shouldInclude = (value: any) =>
|
||||
this.assertValue((actual) => {
|
||||
if (typeof value === "object" && value !== null) {
|
||||
expect(actual).toMatchObject(value);
|
||||
} else {
|
||||
expect(String(actual)).toContain(String(value));
|
||||
}
|
||||
});
|
||||
|
||||
shouldDeepNestedInclude = (value: Record<string, unknown>) =>
|
||||
this.assertValue((actual) => assertDeepNestedInclude(actual, value));
|
||||
|
||||
/**
|
||||
* Asserts that the object under test (a fixture / response body) contains every
|
||||
* top-level property of the style currently stored in localStorage.
|
||||
*/
|
||||
shouldEqualToStoredStyle = async () => {
|
||||
if (!this.page) throw new Error("shouldEqualToStoredStyle requires a page-bound assertable");
|
||||
const expected = await (this.target as any);
|
||||
await retry(async () => {
|
||||
const stored = await styleFromLocalStorage(this.page!);
|
||||
assertDeepNestedInclude(expected, stored);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a Cypress-style key sequence (e.g. "{meta}z", "{esc}", "0.") into
|
||||
* Playwright keyboard actions on the currently focused element.
|
||||
*/
|
||||
async function typeSequence(page: Page, text: string): Promise<void> {
|
||||
const tokens = text.match(/\{[^}]+\}|[^{]+/g) ?? [];
|
||||
const modifierMap: Record<string, string> = { meta: "Meta", ctrl: "Control", shift: "Shift", alt: "Alt" };
|
||||
const namedKeys: Record<string, string> = {
|
||||
esc: "Escape",
|
||||
enter: "Enter",
|
||||
backspace: "Backspace",
|
||||
del: "Delete",
|
||||
tab: "Tab",
|
||||
};
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
if (!token.startsWith("{") || !token.endsWith("}")) {
|
||||
await page.keyboard.type(token);
|
||||
continue;
|
||||
}
|
||||
const name = token.slice(1, -1).toLowerCase();
|
||||
if (name === "selectall") {
|
||||
await page.keyboard.press(isMac ? "Meta+a" : "Control+a");
|
||||
} else if (namedKeys[name]) {
|
||||
await page.keyboard.press(namedKeys[name]);
|
||||
} else if (modifierMap[name]) {
|
||||
const modifiers = [modifierMap[name]];
|
||||
let j = i + 1;
|
||||
while (j < tokens.length && /^\{(meta|ctrl|shift|alt)\}$/i.test(tokens[j])) {
|
||||
modifiers.push(modifierMap[tokens[j].slice(1, -1).toLowerCase()]);
|
||||
j++;
|
||||
}
|
||||
const key = tokens[j] ?? "";
|
||||
await page.keyboard.press([...modifiers, key].join("+"));
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function centerOf(locator: Locator): Promise<{ x: number; y: number }> {
|
||||
const box = await locator.boundingBox();
|
||||
if (!box) throw new Error("Element has no bounding box");
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||
}
|
||||
|
||||
export class MaputnikDriver {
|
||||
private scope: Locator | null = null;
|
||||
private readonly recordedRequests = new Map<string, Request[]>();
|
||||
private readonly modalDriver = new ModalDriver(this);
|
||||
|
||||
/**
|
||||
* The page for the currently running test. Resolved lazily so a single driver
|
||||
* instance can be created once per `describe` and reused across its tests.
|
||||
*/
|
||||
private get page(): Page {
|
||||
return currentPage();
|
||||
}
|
||||
|
||||
// ---- Element access ------------------------------------------------------
|
||||
|
||||
private root(): Page | Locator {
|
||||
return this.scope ?? this.page;
|
||||
}
|
||||
|
||||
private testId(testId: string): Locator {
|
||||
return this.root().locator(testIdSelector(testId));
|
||||
}
|
||||
|
||||
then = <T>(target: T) => new MaputnikAssertable(target, this.page);
|
||||
|
||||
// ---- given ---------------------------------------------------------------
|
||||
|
||||
public given = {
|
||||
fixture: (_name: string, _alias?: string) => {
|
||||
// Fixtures are read directly from disk in Playwright, no registration needed.
|
||||
},
|
||||
|
||||
intercept: async (url: string, alias: string, _method = "GET") => {
|
||||
this.recordedRequests.set(alias, []);
|
||||
// Convert the Cypress-style glob (which may contain "?" in a query string)
|
||||
// into a regex so query parameters match reliably.
|
||||
const pattern = new RegExp(
|
||||
"^" + url.replace(/[.+^${}()|[\]\\?]/g, "\\$&").replace(/\*/g, ".*") + "$"
|
||||
);
|
||||
await this.page.route(pattern, (route) => {
|
||||
this.recordedRequests.get(alias)!.push(route.request());
|
||||
route.continue();
|
||||
});
|
||||
},
|
||||
|
||||
interceptAndMockResponse: async (options: {
|
||||
method?: string;
|
||||
url: string | RegExp;
|
||||
response: unknown | { fixture: string };
|
||||
alias?: string;
|
||||
}) => {
|
||||
const { url, response, alias } = options;
|
||||
if (alias) this.recordedRequests.set(alias, []);
|
||||
await this.page.route(url, (route) => {
|
||||
if (alias) this.recordedRequests.get(alias)!.push(route.request());
|
||||
const body =
|
||||
response && typeof response === "object" && "fixture" in (response as any)
|
||||
? readFixture((response as { fixture: string }).fixture)
|
||||
: response;
|
||||
route.fulfill({ json: body });
|
||||
});
|
||||
},
|
||||
...this.helper.given,
|
||||
|
||||
setupMockBackedResponses: async () => {
|
||||
const styleFixtures = [
|
||||
@@ -279,16 +40,16 @@ export class MaputnikDriver {
|
||||
"example-style-with-zoom-5-and-center-50-50.json",
|
||||
];
|
||||
for (const fixture of styleFixtures) {
|
||||
await this.given.interceptAndMockResponse({
|
||||
await this.helper.given.interceptAndMockResponse({
|
||||
method: "GET",
|
||||
url: baseUrl + fixture,
|
||||
response: { fixture },
|
||||
alias: fixture === "example-style.json" ? "example-style.json" : undefined,
|
||||
});
|
||||
}
|
||||
await this.given.interceptAndMockResponse({ method: "GET", url: /example\.local\//, response: [] });
|
||||
await this.given.interceptAndMockResponse({ method: "GET", url: /example\.com\//, response: [] });
|
||||
await this.given.interceptAndMockResponse({
|
||||
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"],
|
||||
@@ -296,97 +57,11 @@ export class MaputnikDriver {
|
||||
},
|
||||
};
|
||||
|
||||
// ---- when ----------------------------------------------------------------
|
||||
|
||||
public when = {
|
||||
...this.helper.when,
|
||||
|
||||
modal: this.modalDriver.when,
|
||||
|
||||
visit: async (url: string) => {
|
||||
// Snapshot coverage before navigating, since a full page load resets it.
|
||||
recordCoverageChunk(await readCoverage(this.page));
|
||||
const target = url.startsWith("http") ? url : new URL(url, baseUrl).toString();
|
||||
await this.page.goto(target);
|
||||
},
|
||||
|
||||
wait: (ms: number) => this.page.waitForTimeout(ms),
|
||||
|
||||
tab: () => this.page.keyboard.press("Tab"),
|
||||
|
||||
typeKeys: (keys: string) => typeSequence(this.page, keys),
|
||||
|
||||
doWithin: async (selector: string, fn: () => Promise<void> | void) => {
|
||||
const previous = this.scope;
|
||||
this.scope = (previous ?? this.page).locator(testIdSelector(selector));
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
this.scope = previous;
|
||||
}
|
||||
},
|
||||
|
||||
click: async (testId: string, index = 0) => {
|
||||
// Documentation buttons are wrapped in a <label>/.maputnik-doc-target that
|
||||
// Playwright treats as intercepting the click; bypass the check for them.
|
||||
const force = testId.startsWith("field-doc-button-");
|
||||
await this.testId(testId).nth(index).click({ force });
|
||||
},
|
||||
|
||||
realClick: async (testId: string) => {
|
||||
await this.testId(testId).click();
|
||||
},
|
||||
|
||||
hover: async (testId: string) => {
|
||||
await this.testId(testId).hover();
|
||||
},
|
||||
|
||||
focus: async (testId: string) => {
|
||||
await this.testId(testId).focus();
|
||||
},
|
||||
|
||||
clear: async (testId: string) => {
|
||||
await this.testId(testId).clear();
|
||||
},
|
||||
|
||||
select: async (testId: string, value: string) => {
|
||||
await this.testId(testId).selectOption(value);
|
||||
},
|
||||
|
||||
selectWithin: async (selector: string, value: string) => {
|
||||
await this.root().locator(testIdSelector(selector)).locator("select").selectOption(value);
|
||||
},
|
||||
|
||||
setValue: async (testId: string, text: string) => {
|
||||
const input = this.testId(testId);
|
||||
await input.fill("");
|
||||
await input.fill(text);
|
||||
},
|
||||
|
||||
type: async (testId: string, text: string) => {
|
||||
await this.testId(testId).focus();
|
||||
// Place the caret at the start of the field (matching how the original
|
||||
// Cypress suite typed), so a leading "{backspace}" is a no-op rather than
|
||||
// clearing an already-committed value.
|
||||
await this.page.keyboard.press("Home");
|
||||
await typeSequence(this.page, text);
|
||||
},
|
||||
|
||||
setValueToPropertyArray: async (selector: string, value: string) => {
|
||||
await this.when.doWithin(selector, async () => {
|
||||
const input = this.root().locator(".maputnik-array-block-content input").last();
|
||||
await input.focus();
|
||||
await typeSequence(this.page, "{selectall}" + value);
|
||||
});
|
||||
},
|
||||
|
||||
addValueToPropertyArray: async (selector: string, value: string) => {
|
||||
await this.when.doWithin(selector, async () => {
|
||||
await this.root().locator(".maputnik-array-add-value").click();
|
||||
const input = this.root().locator(".maputnik-array-block-content input").last();
|
||||
await input.focus();
|
||||
await typeSequence(this.page, "{selectall}" + value);
|
||||
});
|
||||
},
|
||||
|
||||
setStyle: async (
|
||||
styleProperties:
|
||||
| "geojson"
|
||||
@@ -417,163 +92,101 @@ export class MaputnikDriver {
|
||||
url.hash = `${zoom}/41.3805/2.1635`;
|
||||
}
|
||||
|
||||
await this.when.visit(url.toString());
|
||||
await this.helper.when.visit(url.toString());
|
||||
|
||||
const toolbarLink = this.testId("toolbar:link");
|
||||
const toolbarLink = this.helper.get.elementByTestId("toolbar:link");
|
||||
await toolbarLink.scrollIntoViewIfNeeded();
|
||||
await expect(toolbarLink).toBeVisible();
|
||||
await this.then(toolbarLink).shouldBeVisible();
|
||||
},
|
||||
|
||||
openASecondStyleWithDifferentZoomAndCenter: async () => {
|
||||
await this.page.getByRole("button", { name: "Open" }).click();
|
||||
const input = this.testId("modal:open.url.input");
|
||||
await expect(input).toBeEnabled();
|
||||
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");
|
||||
},
|
||||
|
||||
chooseExampleFile: async () => {
|
||||
await this.openFileByFixture("example-style.json", "modal:open.dropzone", "modal:open.file.input");
|
||||
await this.when.wait(200);
|
||||
await this.helper.when.openFileByFixture("example-style.json", "modal:open.dropzone", "modal:open.file.input");
|
||||
await this.helper.when.wait(200);
|
||||
},
|
||||
|
||||
dropExampleFile: async () => {
|
||||
await this.dropFileByFixture("example-style.json", "modal:open.dropzone");
|
||||
await this.when.wait(200);
|
||||
await this.helper.when.dropFileByFixture("example-style.json", "modal:open.dropzone");
|
||||
await this.helper.when.wait(200);
|
||||
},
|
||||
|
||||
clickZoomIn: async () => {
|
||||
await this.page.locator(".maplibregl-ctrl-zoom-in").click();
|
||||
await this.helper.get.element(".maplibregl-ctrl-zoom-in").click();
|
||||
},
|
||||
|
||||
closePopup: async () => {
|
||||
await this.page.locator(".maplibregl-popup-close-button").click();
|
||||
},
|
||||
|
||||
clickCenter: async (testId: string) => {
|
||||
const { x, y } = await centerOf(this.testId(testId));
|
||||
await this.page.mouse.move(x, y);
|
||||
await this.page.mouse.down();
|
||||
await this.when.wait(200);
|
||||
await this.page.mouse.up();
|
||||
await this.helper.get.element(".maplibregl-popup-close-button").click();
|
||||
},
|
||||
|
||||
collapseGroupInLayerEditor: async (index = 0) => {
|
||||
await this.page.locator(".maputnik-layer-editor-group__button").nth(index).click();
|
||||
await this.helper.get.element(".maputnik-layer-editor-group__button").nth(index).click();
|
||||
},
|
||||
|
||||
appendTextInJsonEditor: async (text: string) => {
|
||||
await this.page.locator(".cm-line").first().click();
|
||||
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.page.keyboard.press("Home");
|
||||
await typeSequence(this.page, text);
|
||||
await this.helper.when.typeKeys("{home}");
|
||||
await this.helper.when.typeText(text);
|
||||
},
|
||||
|
||||
setTextInJsonEditor: async (text: string) => {
|
||||
const firstLine = this.page.locator(".cm-line").first();
|
||||
await firstLine.click();
|
||||
await this.page.keyboard.press(isMac ? "Meta+a" : "Control+a");
|
||||
await this.page.keyboard.type(text);
|
||||
await this.helper.get.element(".cm-line").first().click();
|
||||
await this.helper.when.typeKeys("{selectall}");
|
||||
await this.helper.when.typeText(text);
|
||||
},
|
||||
|
||||
dragAndDropWithWait: async (source: string, target: string) => {
|
||||
const from = await centerOf(this.testId(source));
|
||||
const to = await centerOf(this.testId(target));
|
||||
await this.page.mouse.move(from.x, from.y);
|
||||
await this.page.mouse.down();
|
||||
await this.page.mouse.move(from.x, from.y + 10);
|
||||
await this.page.mouse.move(to.x, to.y, { steps: 10 });
|
||||
await this.when.wait(100);
|
||||
await this.page.mouse.up();
|
||||
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);
|
||||
},
|
||||
|
||||
waitForResponse: async (alias: string) => {
|
||||
const requests = this.recordedRequests.get(alias);
|
||||
if (!requests) throw new Error(`No intercept registered for alias "${alias}"`);
|
||||
await retry(async () => {
|
||||
if (requests.length === 0) throw new Error(`No request recorded for alias "${alias}"`);
|
||||
});
|
||||
return requests[requests.length - 1];
|
||||
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);
|
||||
},
|
||||
|
||||
waitForExampleFileResponse: () => this.when.waitForResponse("example-style.json"),
|
||||
waitForExampleFileResponse: () => this.helper.when.waitForResponse("example-style.json"),
|
||||
|
||||
clearLocalStorage: () => this.page.evaluate(() => window.localStorage.clear()),
|
||||
/** Fill localStorage until we get a QuotaExceededError. */
|
||||
fillLocalStorage: () => this.helper.when.fillLocalStorageUntilQuota("maputnik:fill-"),
|
||||
};
|
||||
|
||||
// ---- get -----------------------------------------------------------------
|
||||
|
||||
public get = {
|
||||
...this.helper.get,
|
||||
|
||||
isMac: () => isMac,
|
||||
|
||||
element: (selector: string) => this.root().locator(selector),
|
||||
canvas: () => this.helper.get.element("canvas"),
|
||||
|
||||
elementByTestId: (testId: string) => this.testId(testId),
|
||||
searchControl: () => this.helper.get.element(".maplibregl-ctrl-geocoder"),
|
||||
|
||||
elementByText: (text: string) => this.root().getByText(text),
|
||||
skipTargetLayerList: () => this.helper.get.elementByTestId("skip-target-layer-list"),
|
||||
|
||||
elementByAttribute: (attribute: string, value: string) =>
|
||||
this.root().locator(`[${attribute}="${value}"]`),
|
||||
skipTargetLayerEditor: () => this.helper.get.elementByTestId("skip-target-layer-editor"),
|
||||
|
||||
canvas: () => this.page.locator("canvas"),
|
||||
styleFromLocalStorage: () => this.helper.query(() => this.readStoredStyle()),
|
||||
|
||||
searchControl: () => this.page.locator(".maplibregl-ctrl-geocoder"),
|
||||
|
||||
skipTargetLayerList: () => this.testId("skip-target-layer-list"),
|
||||
|
||||
skipTargetLayerEditor: () => this.testId("skip-target-layer-editor"),
|
||||
|
||||
inputValue: (testId: string) => new Query<string>(() => this.testId(testId).first().inputValue()),
|
||||
|
||||
elementsText: (testId: string) => new Query<string>(() => this.testId(testId).first().innerText()),
|
||||
|
||||
locationHash: () => new Query<string>(async () => new URL(this.page.url()).hash),
|
||||
|
||||
styleFromLocalStorage: () => new Query<any>(() => styleFromLocalStorage(this.page)),
|
||||
|
||||
fixture: (name: string) => Promise.resolve(readFixture(name)),
|
||||
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 Promise.resolve(readFixture(name));
|
||||
return this.helper.readFixture(name);
|
||||
},
|
||||
|
||||
exampleFileUrl: () => baseUrl + "example-style.json",
|
||||
};
|
||||
|
||||
// ---- file open helpers ---------------------------------------------------
|
||||
|
||||
private async openFileByFixture(fixture: string, buttonTestId: string, inputTestId: string): Promise<void> {
|
||||
const content = JSON.stringify(readFixture(fixture));
|
||||
const hasPicker = await this.page.evaluate(() => "showOpenFilePicker" in window);
|
||||
if (hasPicker) {
|
||||
await this.page.evaluate((fileContent) => {
|
||||
(window as any).showOpenFilePicker = async () => [
|
||||
{ getFile: async () => ({ text: async () => fileContent }) },
|
||||
];
|
||||
}, content);
|
||||
await this.testId(buttonTestId).click();
|
||||
} else {
|
||||
await this.testId(inputTestId).setInputFiles({
|
||||
name: fixture,
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(content),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async dropFileByFixture(fixture: string, dropzoneTestId: string): Promise<void> {
|
||||
const content = JSON.stringify(readFixture(fixture));
|
||||
const dataTransfer = await this.page.evaluateHandle((fileContent) => {
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(new File([fileContent], "example-style.json", { type: "application/json" }));
|
||||
return dt;
|
||||
}, content);
|
||||
const dropzone = this.testId(dropzoneTestId);
|
||||
await dropzone.dispatchEvent("dragenter", { dataTransfer });
|
||||
await dropzone.dispatchEvent("dragover", { dataTransfer });
|
||||
await dropzone.dispatchEvent("drop", { dataTransfer });
|
||||
}
|
||||
}
|
||||
|
||||
+11
-18
@@ -1,27 +1,24 @@
|
||||
import { v1 as uuid } from "uuid";
|
||||
import { expect } from "@playwright/test";
|
||||
import type { MaputnikDriver } from "./maputnik-driver";
|
||||
import { PlaywrightHelper } from "./playwright-helper";
|
||||
|
||||
export class ModalDriver {
|
||||
constructor(private readonly driver: MaputnikDriver) {}
|
||||
private readonly helper = new PlaywrightHelper();
|
||||
|
||||
public when = {
|
||||
fillLayers: async (opts: { type: string; layer?: string; id?: string }) => {
|
||||
const { when, get } = this.driver;
|
||||
const { when, get, then } = this.helper;
|
||||
const id = opts.id ?? `${opts.type}:${uuid()}`;
|
||||
|
||||
await when.select("add-layer.layer-type.select", opts.type);
|
||||
await when.type("add-layer.layer-id.input", id);
|
||||
|
||||
if (opts.layer) {
|
||||
await when.doWithin("add-layer.layer-source-block", async () => {
|
||||
const input = get.element("input");
|
||||
await input.click();
|
||||
await input.fill(opts.layer!);
|
||||
// The source input is a controlled downshift combobox; wait for React
|
||||
// to settle on the typed value before submitting.
|
||||
await expect(input).toHaveValue(opts.layer!);
|
||||
});
|
||||
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();
|
||||
}
|
||||
@@ -31,15 +28,11 @@ export class ModalDriver {
|
||||
},
|
||||
|
||||
open: async () => {
|
||||
// No-op when the add-layer modal is already open (some specs call open()
|
||||
// both in a beforeEach and at the start of the test body).
|
||||
const modal = this.driver.get.elementByTestId("modal:add-layer").first();
|
||||
if (await modal.isVisible()) return;
|
||||
await this.driver.when.click("layer-list:add-layer");
|
||||
await this.helper.when.click("layer-list:add-layer");
|
||||
},
|
||||
|
||||
close: async (key: string) => {
|
||||
await this.driver.when.click(key + ".close-modal");
|
||||
await this.helper.when.click(key + ".close-modal");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+36
-60
@@ -1,20 +1,18 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { test, expect, describe, beforeEach } from "./utils/fixtures";
|
||||
import { MaputnikDriver } from "./maputnik-driver";
|
||||
import tokens from "../src/config/tokens.json" with { type: "json" };
|
||||
|
||||
test.describe("modals", () => {
|
||||
describe("modals", () => {
|
||||
const { given, get, when, then } = new MaputnikDriver();
|
||||
|
||||
test.beforeEach(async () => {
|
||||
beforeEach(async () => {
|
||||
await given.setupMockBackedResponses();
|
||||
// Load a style first so it is persisted to localStorage, then reset the URL
|
||||
// to the root (no style param) — several tests read the stored style.
|
||||
await when.setStyle("both");
|
||||
await when.setStyle("");
|
||||
});
|
||||
|
||||
test.describe("open", () => {
|
||||
test.beforeEach(async () => {
|
||||
describe("open", () => {
|
||||
beforeEach(async () => {
|
||||
await when.click("nav:open");
|
||||
});
|
||||
|
||||
@@ -25,16 +23,16 @@ test.describe("modals", () => {
|
||||
|
||||
test("upload", async () => {
|
||||
await when.chooseExampleFile();
|
||||
await then(get.fixture("example-style.json")).shouldEqualToStoredStyle();
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.fixture("example-style.json"));
|
||||
});
|
||||
|
||||
test("upload via drag and drop", async () => {
|
||||
await when.dropExampleFile();
|
||||
await then(get.fixture("example-style.json")).shouldEqualToStoredStyle();
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.fixture("example-style.json"));
|
||||
});
|
||||
|
||||
test.describe("when click open url", () => {
|
||||
test.beforeEach(async () => {
|
||||
describe("when click open url", () => {
|
||||
beforeEach(async () => {
|
||||
const styleFileUrl = get.exampleFileUrl();
|
||||
|
||||
await when.setValue("modal:open.url.input", styleFileUrl);
|
||||
@@ -42,12 +40,12 @@ test.describe("modals", () => {
|
||||
await when.wait(200);
|
||||
});
|
||||
test("load from url", async () => {
|
||||
await then(get.responseBody("example-style.json")).shouldEqualToStoredStyle();
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.responseBody("example-style.json"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("shortcuts", () => {
|
||||
describe("shortcuts", () => {
|
||||
test("open/close", async () => {
|
||||
await when.setStyle("");
|
||||
await when.typeKeys("?");
|
||||
@@ -56,8 +54,8 @@ test.describe("modals", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("export", () => {
|
||||
test.beforeEach(async () => {
|
||||
describe("export", () => {
|
||||
beforeEach(async () => {
|
||||
await when.click("nav:export");
|
||||
});
|
||||
|
||||
@@ -70,8 +68,8 @@ test.describe("modals", () => {
|
||||
test.skip("download", () => {});
|
||||
});
|
||||
|
||||
test.describe("sources", () => {
|
||||
test.beforeEach(async () => {
|
||||
describe("sources", () => {
|
||||
beforeEach(async () => {
|
||||
await when.setStyle("layer");
|
||||
await when.click("nav:sources");
|
||||
});
|
||||
@@ -126,7 +124,7 @@ test.describe("modals", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("inspect", () => {
|
||||
describe("inspect", () => {
|
||||
test("toggle", async () => {
|
||||
// There is no assertion in this test
|
||||
await when.setStyle("geojson");
|
||||
@@ -134,13 +132,13 @@ test.describe("modals", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("style settings", () => {
|
||||
test.beforeEach(async () => {
|
||||
describe("style settings", () => {
|
||||
beforeEach(async () => {
|
||||
await when.click("nav:settings");
|
||||
});
|
||||
|
||||
test.describe("when click name filed spec information", () => {
|
||||
test.beforeEach(async () => {
|
||||
describe("when click name filed spec information", () => {
|
||||
beforeEach(async () => {
|
||||
await when.click("field-doc-button-Name");
|
||||
});
|
||||
|
||||
@@ -149,8 +147,8 @@ test.describe("modals", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("when set name and click owner", () => {
|
||||
test.beforeEach(async () => {
|
||||
describe("when set name and click owner", () => {
|
||||
beforeEach(async () => {
|
||||
await when.setValue("modal:settings.name", "foobar");
|
||||
await when.click("modal:settings.owner");
|
||||
await when.wait(200);
|
||||
@@ -163,8 +161,8 @@ test.describe("modals", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("when set owner and click name", () => {
|
||||
test.beforeEach(async () => {
|
||||
describe("when set owner and click name", () => {
|
||||
beforeEach(async () => {
|
||||
await when.setValue("modal:settings.owner", "foobar");
|
||||
await when.click("modal:settings.name");
|
||||
await when.wait(200);
|
||||
@@ -273,7 +271,7 @@ test.describe("modals", () => {
|
||||
await when.click("modal:settings.close-modal");
|
||||
await when.click("nav:open");
|
||||
|
||||
await get.elementByAttribute("aria-label", "MapTiler Basic").click();
|
||||
await when.clickByAttribute("aria-label", "MapTiler Basic");
|
||||
await when.wait(1000);
|
||||
await when.click("nav:settings");
|
||||
|
||||
@@ -284,7 +282,7 @@ test.describe("modals", () => {
|
||||
await then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual("ol");
|
||||
|
||||
await given.intercept(
|
||||
"https://api.maptiler.com/tiles/v3-openmaptiles/tiles.json?key=*",
|
||||
/https:\/\/api\.maptiler\.com\/tiles\/v3-openmaptiles\/tiles\.json\?key=.*/,
|
||||
"tileRequest",
|
||||
"GET"
|
||||
);
|
||||
@@ -299,8 +297,8 @@ test.describe("modals", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("add layer", () => {
|
||||
test.beforeEach(async () => {
|
||||
describe("add layer", () => {
|
||||
beforeEach(async () => {
|
||||
await when.setStyle("layer");
|
||||
await when.modal.open();
|
||||
});
|
||||
@@ -313,12 +311,12 @@ test.describe("modals", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("sources placeholder", () => {
|
||||
describe("sources placeholder", () => {
|
||||
test.skip("toggle", () => {});
|
||||
});
|
||||
|
||||
test.describe("global state", () => {
|
||||
test.beforeEach(async () => {
|
||||
describe("global state", () => {
|
||||
beforeEach(async () => {
|
||||
await when.click("nav:global-state");
|
||||
});
|
||||
|
||||
@@ -374,7 +372,7 @@ test.describe("modals", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("error panel", () => {
|
||||
describe("error panel", () => {
|
||||
test("not visible when no errors", async () => {
|
||||
await then(get.element("maputnik-message-panel-error")).shouldNotExist();
|
||||
});
|
||||
@@ -389,40 +387,18 @@ test.describe("modals", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Handle localStorage QuotaExceededError", () => {
|
||||
test("handles quota exceeded error when opening style from URL", async ({ page }) => {
|
||||
describe("Handle localStorage QuotaExceededError", () => {
|
||||
test("handles quota exceeded error when opening style from URL", async () => {
|
||||
// Clear localStorage to start fresh
|
||||
await when.clearLocalStorage();
|
||||
|
||||
// fill localStorage until we get a QuotaExceededError
|
||||
await page.evaluate(() => {
|
||||
let chunkSize = 1000;
|
||||
const chunk = new Array(chunkSize).join("x");
|
||||
let index = 0;
|
||||
|
||||
// Keep adding until we hit the quota
|
||||
for (;;) {
|
||||
try {
|
||||
const key = `maputnik:fill-${index++}`;
|
||||
window.localStorage.setItem(key, chunk);
|
||||
} catch (e: any) {
|
||||
// Verify it's a quota error
|
||||
if (e.name === "QuotaExceededError") {
|
||||
if (chunkSize <= 1) return;
|
||||
chunkSize /= 2;
|
||||
continue;
|
||||
}
|
||||
throw e; // Unexpected error
|
||||
}
|
||||
}
|
||||
});
|
||||
await when.fillLocalStorage();
|
||||
|
||||
// Open the style via URL input
|
||||
await when.click("nav:open");
|
||||
await when.setValue("modal:open.url.input", get.exampleFileUrl());
|
||||
await when.click("modal:open.url.button");
|
||||
|
||||
await then(get.responseBody("example-style.json")).shouldEqualToStoredStyle();
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.responseBody("example-style.json"));
|
||||
await then(get.styleFromLocalStorage()).shouldExist();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> | void,
|
||||
timeout = 10000,
|
||||
interval = 100
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
let lastError: unknown;
|
||||
while (true) {
|
||||
try {
|
||||
await assertion();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (Date.now() - start > timeout) throw lastError;
|
||||
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A lazily-evaluated value (e.g. the style in localStorage). Assertions on a
|
||||
* Query re-read the value until they pass.
|
||||
*/
|
||||
class Query<T> {
|
||||
readonly __query = true as const;
|
||||
constructor(private readonly getter: () => Promise<T>) {}
|
||||
|
||||
get(): Promise<T> {
|
||||
return this.getter();
|
||||
}
|
||||
|
||||
then<U>(mapper: (value: T) => U | Promise<U>): Query<U> {
|
||||
return new Query<U>(async () => mapper(await this.getter()));
|
||||
}
|
||||
}
|
||||
|
||||
function isQuery(target: unknown): target is Query<unknown> {
|
||||
return typeof target === "object" && target !== null && (target as Query<unknown>).__query === true;
|
||||
}
|
||||
|
||||
function isLocator(target: unknown): target is Locator {
|
||||
return (
|
||||
typeof target === "object" &&
|
||||
target !== null &&
|
||||
typeof (target as Locator).count === "function" &&
|
||||
typeof (target as Locator).boundingBox === "function"
|
||||
);
|
||||
}
|
||||
|
||||
/** Asserts that every top-level key in `expected` deep-equals its counterpart in `actual`. */
|
||||
function assertDeepNestedInclude(actual: any, expected: Record<string, unknown>): void {
|
||||
for (const key of Object.keys(expected)) {
|
||||
expect(actual?.[key], `property "${key}"`).toEqual(expected[key]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fluent, auto-retrying assertions over a Playwright Locator or a lazily
|
||||
* evaluated value/Query. This is the generic base that the maputnik-specific
|
||||
* assertable extends.
|
||||
*/
|
||||
export class Assertable<T> {
|
||||
constructor(private readonly target: T) {}
|
||||
|
||||
private locator(): Locator {
|
||||
if (!isLocator(this.target)) throw new Error("Expected a Locator target for this assertion");
|
||||
return this.target;
|
||||
}
|
||||
|
||||
protected async assertValue(assertion: (value: any) => void): Promise<void> {
|
||||
const target = this.target;
|
||||
if (isQuery(target)) {
|
||||
await retry(async () => assertion(await target.get()));
|
||||
} else {
|
||||
assertion(await (target as any));
|
||||
}
|
||||
}
|
||||
|
||||
// Element assertions (auto-retrying via Playwright web-first assertions).
|
||||
shouldBeVisible = () => expect(this.locator().first()).toBeVisible();
|
||||
// Some testids resolve to many elements that are always rendered but hidden
|
||||
// (e.g. per-field documentation panels); "not visible" means none is visible.
|
||||
shouldNotBeVisible = () => expect(this.locator().filter({ visible: true })).toHaveCount(0);
|
||||
shouldExist = async () => {
|
||||
if (isLocator(this.target)) {
|
||||
await expect(this.locator().first()).toBeAttached();
|
||||
} else {
|
||||
await this.assertValue((value) => expect(value).toBeTruthy());
|
||||
}
|
||||
};
|
||||
shouldNotExist = () => expect(this.locator()).toHaveCount(0);
|
||||
shouldBeFocused = () => expect(this.locator().first()).toBeFocused();
|
||||
shouldNotBeFocused = () => expect(this.locator().first()).not.toBeFocused();
|
||||
shouldHaveValue = (value: string) => expect(this.locator().first()).toHaveValue(value);
|
||||
shouldContainText = async (text: string) => {
|
||||
const locator = this.locator();
|
||||
// Prefer the visible element when a testid resolves to several (only the
|
||||
// open documentation panel is visible; the rest are hidden in the DOM).
|
||||
const target = (await locator.count()) > 1 ? locator.filter({ visible: true }).first() : locator.first();
|
||||
await expect(target).toContainText(text);
|
||||
};
|
||||
shouldHaveText = (text: string) => expect(this.locator().first()).toHaveText(text);
|
||||
shouldHaveLength = (length: number) => expect(this.locator()).toHaveCount(length);
|
||||
shouldHaveCss = (property: string, value: string) => expect(this.locator().first()).toHaveCSS(property, value);
|
||||
|
||||
// Value assertions (auto-retrying for Query targets).
|
||||
shouldEqual = (value: any) => this.assertValue((actual) => expect(actual).toBe(value));
|
||||
|
||||
shouldInclude = (value: any) =>
|
||||
this.assertValue((actual) => {
|
||||
if (typeof value === "object" && value !== null) {
|
||||
expect(actual).toMatchObject(value);
|
||||
} else {
|
||||
expect(String(actual)).toContain(String(value));
|
||||
}
|
||||
});
|
||||
|
||||
shouldDeepNestedInclude = (value: Record<string, unknown>) =>
|
||||
this.assertValue((actual) => assertDeepNestedInclude(actual, value));
|
||||
}
|
||||
|
||||
async function typeSequence(page: Page, text: string): Promise<void> {
|
||||
const tokens = text.match(/\{[^}]+\}|[^{]+/g) ?? [];
|
||||
const modifierMap: Record<string, string> = { meta: "Meta", ctrl: "Control", shift: "Shift", alt: "Alt" };
|
||||
const namedKeys: Record<string, string> = {
|
||||
esc: "Escape",
|
||||
enter: "Enter",
|
||||
backspace: "Backspace",
|
||||
del: "Delete",
|
||||
tab: "Tab",
|
||||
home: "Home",
|
||||
};
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
if (!token.startsWith("{") || !token.endsWith("}")) {
|
||||
await page.keyboard.type(token);
|
||||
continue;
|
||||
}
|
||||
const name = token.slice(1, -1).toLowerCase();
|
||||
if (name === "selectall") {
|
||||
await page.keyboard.press(isMac ? "Meta+a" : "Control+a");
|
||||
} else if (namedKeys[name]) {
|
||||
await page.keyboard.press(namedKeys[name]);
|
||||
} else if (modifierMap[name]) {
|
||||
const modifiers = [modifierMap[name]];
|
||||
let j = i + 1;
|
||||
while (j < tokens.length && /^\{(meta|ctrl|shift|alt)\}$/i.test(tokens[j])) {
|
||||
modifiers.push(modifierMap[tokens[j].slice(1, -1).toLowerCase()]);
|
||||
j++;
|
||||
}
|
||||
const key = tokens[j] ?? "";
|
||||
await page.keyboard.press([...modifiers, key].join("+"));
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function centerOf(locator: Locator): Promise<{ x: number; y: number }> {
|
||||
const box = await locator.boundingBox();
|
||||
if (!box) throw new Error("Element has no bounding box");
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* This is where all plywright-specific test helpers live.
|
||||
* It is used by the MaputnikDriver to implement the Maputnik-specific test helpers.
|
||||
*/
|
||||
export class PlaywrightHelper {
|
||||
private readonly recordedRequests = new Map<string, Request[]>();
|
||||
|
||||
private get page(): Page {
|
||||
return currentPage();
|
||||
}
|
||||
|
||||
private testId(testId: string): Locator {
|
||||
return this.page.locator(testIdSelector(testId));
|
||||
}
|
||||
|
||||
/** Reads and parses a JSON fixture from the fixtures directory. */
|
||||
public readFixture(name: string): any {
|
||||
return JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, name), "utf-8"));
|
||||
}
|
||||
|
||||
/** Wraps a lazily-evaluated value so assertions on it auto-retry. */
|
||||
public query<T>(getter: () => Promise<T>): Query<T> {
|
||||
return new Query<T>(getter);
|
||||
}
|
||||
|
||||
/** Entry point for fluent assertions over a Locator or a value/Query. */
|
||||
public then = <T>(target: T): Assertable<T> => new Assertable(target);
|
||||
|
||||
public given = {
|
||||
intercept: async (pattern: RegExp, alias: string, _method = "GET") => {
|
||||
this.recordedRequests.set(alias, []);
|
||||
await this.page.route(pattern, (route) => {
|
||||
this.recordedRequests.get(alias)!.push(route.request());
|
||||
route.continue();
|
||||
});
|
||||
},
|
||||
|
||||
interceptAndMockResponse: async (options: {
|
||||
method?: string;
|
||||
url: string | RegExp;
|
||||
response: unknown | { fixture: string };
|
||||
alias?: string;
|
||||
}) => {
|
||||
const { url, response, alias } = options;
|
||||
if (alias) this.recordedRequests.set(alias, []);
|
||||
await this.page.route(url, (route) => {
|
||||
if (alias) this.recordedRequests.get(alias)!.push(route.request());
|
||||
const body =
|
||||
response && typeof response === "object" && "fixture" in (response as any)
|
||||
? this.readFixture((response as { fixture: string }).fixture)
|
||||
: response;
|
||||
route.fulfill({ json: body });
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
public when = {
|
||||
visit: async (url: string) => {
|
||||
// Snapshot coverage before navigating, since a full page load resets it.
|
||||
await recordCoverageChunk(this.page);
|
||||
await this.page.goto(url);
|
||||
},
|
||||
|
||||
wait: (ms: number) => this.page.waitForTimeout(ms),
|
||||
|
||||
tab: () => this.page.keyboard.press("Tab"),
|
||||
|
||||
typeKeys: (keys: string) => typeSequence(this.page, keys),
|
||||
|
||||
/** Types raw text into the focused element (no "{key}" sequence parsing). */
|
||||
typeText: (text: string) => this.page.keyboard.type(text),
|
||||
|
||||
clickButtonByName: async (name: string) => {
|
||||
await this.page.getByRole("button", { name }).click();
|
||||
},
|
||||
|
||||
click: async (testId: string, index = 0) => {
|
||||
// Documentation buttons are wrapped in a <label>/.maputnik-doc-target that
|
||||
// Playwright treats as intercepting the click; bypass the check for them.
|
||||
const force = testId.startsWith("field-doc-button-");
|
||||
await this.testId(testId).nth(index).click({ force });
|
||||
},
|
||||
|
||||
realClick: async (testId: string) => {
|
||||
await this.testId(testId).click();
|
||||
},
|
||||
|
||||
hover: async (testId: string) => {
|
||||
await this.testId(testId).hover();
|
||||
},
|
||||
|
||||
focus: async (testId: string) => {
|
||||
await this.testId(testId).focus();
|
||||
},
|
||||
|
||||
clear: async (testId: string) => {
|
||||
await this.testId(testId).clear();
|
||||
},
|
||||
|
||||
select: async (testId: string, value: string) => {
|
||||
await this.testId(testId).selectOption(value);
|
||||
},
|
||||
|
||||
selectWithin: async (parentTestId: string, value: string) => {
|
||||
await this.testId(parentTestId).locator("select").selectOption(value);
|
||||
},
|
||||
|
||||
clickWithin: async (parentTestId: string, selector: string) => {
|
||||
await this.testId(parentTestId).locator(selector).first().click();
|
||||
},
|
||||
|
||||
clickByText: async (text: string) => {
|
||||
await this.page.getByText(text).click();
|
||||
},
|
||||
|
||||
clickByAttribute: async (attribute: string, value: string) => {
|
||||
await this.page.locator(`[${attribute}="${value}"]`).click();
|
||||
},
|
||||
|
||||
scrollToBottom: async (element: Locator) => {
|
||||
await element.evaluate((el) => el.scrollTo(0, el.scrollHeight));
|
||||
},
|
||||
|
||||
setValue: async (testId: string, text: string) => {
|
||||
const input = this.testId(testId);
|
||||
await input.fill("");
|
||||
await input.fill(text);
|
||||
},
|
||||
|
||||
type: async (testId: string, text: string) => {
|
||||
await this.testId(testId).focus();
|
||||
// Place the caret at the start of the field, so a leading "{backspace}"
|
||||
// is a no-op rather than clearing an already-committed value.
|
||||
await this.page.keyboard.press("Home");
|
||||
await typeSequence(this.page, text);
|
||||
},
|
||||
|
||||
dragAndDropWithWait: async (source: string, target: string) => {
|
||||
const from = await centerOf(this.testId(source));
|
||||
const to = await centerOf(this.testId(target));
|
||||
await this.page.mouse.move(from.x, from.y);
|
||||
await this.page.mouse.down();
|
||||
await this.page.mouse.move(from.x, from.y + 10);
|
||||
await this.page.mouse.move(to.x, to.y, { steps: 10 });
|
||||
await this.page.waitForTimeout(100);
|
||||
await this.page.mouse.up();
|
||||
},
|
||||
|
||||
clickCenter: async (testId: string) => {
|
||||
const { x, y } = await centerOf(this.testId(testId));
|
||||
await this.page.mouse.move(x, y);
|
||||
await this.page.mouse.down();
|
||||
await this.page.waitForTimeout(200);
|
||||
await this.page.mouse.up();
|
||||
},
|
||||
|
||||
openFileByFixture: async (fixture: string, buttonTestId: string, inputTestId: string) => {
|
||||
const content = JSON.stringify(this.readFixture(fixture));
|
||||
const hasPicker = await this.page.evaluate(() => "showOpenFilePicker" in window);
|
||||
if (hasPicker) {
|
||||
await this.page.evaluate((fileContent) => {
|
||||
(window as any).showOpenFilePicker = async () => [
|
||||
{ getFile: async () => ({ text: async () => fileContent }) },
|
||||
];
|
||||
}, content);
|
||||
await this.testId(buttonTestId).click();
|
||||
} else {
|
||||
await this.testId(inputTestId).setInputFiles({
|
||||
name: fixture,
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(content),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
dropFileByFixture: async (fixture: string, dropzoneTestId: string) => {
|
||||
const content = JSON.stringify(this.readFixture(fixture));
|
||||
const dataTransfer = await this.page.evaluateHandle((fileContent) => {
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(new File([fileContent], "example-style.json", { type: "application/json" }));
|
||||
return dt;
|
||||
}, content);
|
||||
const dropzone = this.testId(dropzoneTestId);
|
||||
await dropzone.dispatchEvent("dragenter", { dataTransfer });
|
||||
await dropzone.dispatchEvent("dragover", { dataTransfer });
|
||||
await dropzone.dispatchEvent("drop", { dataTransfer });
|
||||
},
|
||||
|
||||
waitForResponse: async (alias: string) => {
|
||||
const requests = this.recordedRequests.get(alias);
|
||||
if (!requests) throw new Error(`No intercept registered for alias "${alias}"`);
|
||||
await retry(async () => {
|
||||
if (requests.length === 0) throw new Error(`No request recorded for alias "${alias}"`);
|
||||
});
|
||||
return requests[requests.length - 1];
|
||||
},
|
||||
|
||||
clearLocalStorage: () => this.page.evaluate(() => window.localStorage.clear()),
|
||||
|
||||
/** Writes to localStorage under `keyPrefix` until a QuotaExceededError is hit. */
|
||||
fillLocalStorageUntilQuota: (keyPrefix: string) =>
|
||||
this.page.evaluate((prefix) => {
|
||||
let chunkSize = 1000;
|
||||
const chunk = new Array(chunkSize).join("x");
|
||||
let index = 0;
|
||||
while (true) {
|
||||
try {
|
||||
window.localStorage.setItem(`${prefix}${index++}`, chunk);
|
||||
} catch (e: any) {
|
||||
if (e.name === "QuotaExceededError") {
|
||||
if (chunkSize <= 1) return;
|
||||
chunkSize /= 2;
|
||||
continue;
|
||||
}
|
||||
throw e; // Unexpected error
|
||||
}
|
||||
}
|
||||
}, keyPrefix),
|
||||
};
|
||||
|
||||
public get = {
|
||||
element: (selector: string) => this.page.locator(selector),
|
||||
|
||||
localStorageItem: (key: string) =>
|
||||
this.page.evaluate((k) => window.localStorage.getItem(k), key),
|
||||
|
||||
elementByTestId: (testId: string) => this.testId(testId),
|
||||
|
||||
inputValue: (testId: string) => new Query<string>(() => this.testId(testId).first().inputValue()),
|
||||
|
||||
elementsText: (testId: string) => new Query<string>(() => this.testId(testId).first().innerText()),
|
||||
|
||||
locationHash: () => new Query<string>(async () => new URL(this.page.url()).hash),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
let activePage: Page | undefined;
|
||||
const coverageChunks: unknown[] = [];
|
||||
|
||||
/** The page for the currently running test. Throws if used outside a test. */
|
||||
export function currentPage(): Page {
|
||||
if (!activePage) {
|
||||
throw new Error("No active page: a MaputnikDriver method was called outside of a running test.");
|
||||
}
|
||||
return activePage;
|
||||
}
|
||||
|
||||
const OUTPUT_DIR = path.resolve(process.cwd(), ".nyc_output");
|
||||
|
||||
/**
|
||||
* Reads the istanbul coverage object (injected by vite-plugin-istanbul) from the
|
||||
* given page. Returns `null` when the page has not been instrumented.
|
||||
*/
|
||||
async function readCoverage(page: Page): Promise<unknown | null> {
|
||||
try {
|
||||
return await page.evaluate(() => (window as unknown as { __coverage__?: unknown }).__coverage__ ?? null);
|
||||
} catch {
|
||||
// Page might be navigating/closed.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a coverage chunk to `.nyc_output` so that `nyc report` can merge it.
|
||||
* istanbul-lib-coverage (used by nyc) sums the hit counts across every file it
|
||||
* finds, so writing one file per chunk is enough to accumulate coverage across
|
||||
* navigations and tests.
|
||||
*/
|
||||
export function writeCoverage(coverage: unknown, id: string): void {
|
||||
if (!coverage) return;
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
fs.writeFileSync(path.join(OUTPUT_DIR, `playwright-${id}.json`), JSON.stringify(coverage));
|
||||
}
|
||||
|
||||
/** Records a coverage snapshot (called before navigations, which reset __coverage__). */
|
||||
export async function recordCoverageChunk(page: Page): Promise<void> {
|
||||
const chunk = await readCoverage(page);
|
||||
if (chunk) coverageChunks.push(chunk);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto fixture that binds the current test's page for the (page-lazy)
|
||||
* MaputnikDriver, auto-accepts confirm dialogs, and writes the istanbul
|
||||
* coverage collected during the test to `.nyc_output`.
|
||||
*/
|
||||
const extendedTest = test.extend<{ maputnikPage: void }>({
|
||||
maputnikPage: [
|
||||
async ({ page }, use, testInfo) => {
|
||||
activePage = page;
|
||||
coverageChunks.length = 0;
|
||||
// Accept confirm dialogs (e.g. the "replace current style" prompt). These
|
||||
// are dismissed by default, which would cancel loading a style via URL.
|
||||
page.on("dialog", (dialog) => dialog.accept().catch(() => undefined));
|
||||
|
||||
await use();
|
||||
|
||||
const finalCoverage = await readCoverage(page);
|
||||
if (finalCoverage) coverageChunks.push(finalCoverage);
|
||||
coverageChunks.forEach((chunk, index) => writeCoverage(chunk, `${testInfo.testId}-${index}`));
|
||||
coverageChunks.length = 0;
|
||||
activePage = undefined;
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
});
|
||||
|
||||
const describe = extendedTest.describe;
|
||||
const beforeEach = extendedTest.beforeEach;
|
||||
export {
|
||||
expect,
|
||||
describe,
|
||||
extendedTest as test,
|
||||
beforeEach,
|
||||
};
|
||||
@@ -9,8 +9,8 @@ const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:8888/";
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
testMatch: "**/*.spec.ts",
|
||||
globalSetup: "./e2e/global-setup.ts",
|
||||
globalTeardown: "./e2e/global-teardown.ts",
|
||||
globalSetup: "./e2e/utils/e2e-setup.ts",
|
||||
globalTeardown: "./e2e/utils/e2e-teardown.ts",
|
||||
fullyParallel: true,
|
||||
forbidOnly: isCI,
|
||||
retries: isCI ? 2 : 0,
|
||||
|
||||
Reference in New Issue
Block a user