From 72e55eea805397edbe209256479504b27cdcd23d Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Thu, 17 Sep 2026 01:37:56 +0100 Subject: [PATCH 1/5] test(e2e): add a raster tile fixture set for zoom 0-2 21 solid-colour tiles, one distinct colour each, so an export through a {z}/{x}/{y} template can be checked against a known palette without a network. Generated once by make-tiles.ts and committed. --- e2e/fixtures/make-tiles.ts | 63 +++++++++++++++++++++++++++++++++++ e2e/fixtures/tiles/0/0/0.png | Bin 0 -> 723 bytes e2e/fixtures/tiles/1/0/0.png | Bin 0 -> 725 bytes e2e/fixtures/tiles/1/0/1.png | Bin 0 -> 725 bytes e2e/fixtures/tiles/1/1/0.png | Bin 0 -> 726 bytes e2e/fixtures/tiles/1/1/1.png | Bin 0 -> 726 bytes e2e/fixtures/tiles/2/0/0.png | Bin 0 -> 725 bytes e2e/fixtures/tiles/2/0/1.png | Bin 0 -> 725 bytes e2e/fixtures/tiles/2/0/2.png | Bin 0 -> 726 bytes e2e/fixtures/tiles/2/0/3.png | Bin 0 -> 725 bytes e2e/fixtures/tiles/2/1/0.png | Bin 0 -> 725 bytes e2e/fixtures/tiles/2/1/1.png | Bin 0 -> 726 bytes e2e/fixtures/tiles/2/1/2.png | Bin 0 -> 725 bytes e2e/fixtures/tiles/2/1/3.png | Bin 0 -> 725 bytes e2e/fixtures/tiles/2/2/0.png | Bin 0 -> 725 bytes e2e/fixtures/tiles/2/2/1.png | Bin 0 -> 726 bytes e2e/fixtures/tiles/2/2/2.png | Bin 0 -> 725 bytes e2e/fixtures/tiles/2/2/3.png | Bin 0 -> 725 bytes e2e/fixtures/tiles/2/3/0.png | Bin 0 -> 726 bytes e2e/fixtures/tiles/2/3/1.png | Bin 0 -> 725 bytes e2e/fixtures/tiles/2/3/2.png | Bin 0 -> 725 bytes e2e/fixtures/tiles/2/3/3.png | Bin 0 -> 726 bytes 22 files changed, 63 insertions(+) create mode 100644 e2e/fixtures/make-tiles.ts create mode 100644 e2e/fixtures/tiles/0/0/0.png create mode 100644 e2e/fixtures/tiles/1/0/0.png create mode 100644 e2e/fixtures/tiles/1/0/1.png create mode 100644 e2e/fixtures/tiles/1/1/0.png create mode 100644 e2e/fixtures/tiles/1/1/1.png create mode 100644 e2e/fixtures/tiles/2/0/0.png create mode 100644 e2e/fixtures/tiles/2/0/1.png create mode 100644 e2e/fixtures/tiles/2/0/2.png create mode 100644 e2e/fixtures/tiles/2/0/3.png create mode 100644 e2e/fixtures/tiles/2/1/0.png create mode 100644 e2e/fixtures/tiles/2/1/1.png create mode 100644 e2e/fixtures/tiles/2/1/2.png create mode 100644 e2e/fixtures/tiles/2/1/3.png create mode 100644 e2e/fixtures/tiles/2/2/0.png create mode 100644 e2e/fixtures/tiles/2/2/1.png create mode 100644 e2e/fixtures/tiles/2/2/2.png create mode 100644 e2e/fixtures/tiles/2/2/3.png create mode 100644 e2e/fixtures/tiles/2/3/0.png create mode 100644 e2e/fixtures/tiles/2/3/1.png create mode 100644 e2e/fixtures/tiles/2/3/2.png create mode 100644 e2e/fixtures/tiles/2/3/3.png diff --git a/e2e/fixtures/make-tiles.ts b/e2e/fixtures/make-tiles.ts new file mode 100644 index 0000000..c6da130 --- /dev/null +++ b/e2e/fixtures/make-tiles.ts @@ -0,0 +1,63 @@ +// Regenerates e2e/fixtures/tiles/{z}/{x}/{y}.png, a solid distinct colour per +// tile, for zoom 0-2. The result is committed; run this only to change it: +// node --experimental-strip-types e2e/fixtures/make-tiles.ts +import fs from "node:fs"; +import path from "node:path"; +import { encode } from "fast-png"; + +const OUT_DIR = path.join(import.meta.dirname, "tiles"); +const TILE_SIZE = 256; +const MAX_ZOOM = 2; +/** Golden-angle hue steps keep neighbouring tiles far apart in colour. */ +const HUE_STEP = 137.508; + +function hslToRgb(h: number, s: number, l: number): [number, number, number] { + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - c / 2; + const [r, g, b] = ( + [ + [c, x, 0], + [x, c, 0], + [0, c, x], + [0, x, c], + [x, 0, c], + [c, 0, x], + ] as const + )[Math.floor(h / 60) % 6]; + return [ + Math.round((r + m) * 255), + Math.round((g + m) * 255), + Math.round((b + m) * 255), + ]; +} + +function solidTile(rgb: [number, number, number]): Uint8Array { + const data = new Uint8Array(TILE_SIZE * TILE_SIZE * 3); + for (let i = 0; i < data.length; i += 3) data.set(rgb, i); + return data; +} + +fs.rmSync(OUT_DIR, { recursive: true, force: true }); +let index = 0; +for (let z = 0; z <= MAX_ZOOM; z++) { + const side = 2 ** z; + for (let x = 0; x < side; x++) { + for (let y = 0; y < side; y++) { + const rgb = hslToRgb((index++ * HUE_STEP) % 360, 0.75, 0.45); + const dir = path.join(OUT_DIR, String(z), String(x)); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, `${y}.png`), + encode({ + width: TILE_SIZE, + height: TILE_SIZE, + data: solidTile(rgb), + channels: 3, + depth: 8, + }), + ); + } + } +} +console.log(`wrote ${index} tiles to ${OUT_DIR}`); diff --git a/e2e/fixtures/tiles/0/0/0.png b/e2e/fixtures/tiles/0/0/0.png new file mode 100644 index 0000000000000000000000000000000000000000..519d62efea6bbaa2819afc78112d5ae9896c57e6 GIT binary patch literal 723 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K58911L)MWvCLk0$>S)MMAAr*0NFEjEoFmN1D z=)Pg!y_ToQOv_+9cO=sUHHOSlVl)f}A{~4y-^#C-zr5cHm{=J+UHx3vIVCg!03o2I As{jB1 literal 0 HcmV?d00001 diff --git a/e2e/fixtures/tiles/1/0/0.png b/e2e/fixtures/tiles/1/0/0.png new file mode 100644 index 0000000000000000000000000000000000000000..1d81d65fe9e4739a628cb6e6cae3d26ed253d717 GIT binary patch literal 725 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K58911L)MWvCLk0$>Ii4<#Ar*0NFEjEo7;qfe zAiYzqhRNM?Jo?lE9;W$<+Mb6Mw<&;$U9 C^0u4+ literal 0 HcmV?d00001 diff --git a/e2e/fixtures/tiles/1/0/1.png b/e2e/fixtures/tiles/1/0/1.png new file mode 100644 index 0000000000000000000000000000000000000000..651df25bbd31edfa74dfa1676b92f468372b9c06 GIT binary patch literal 725 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K58911L)MWvCLk0$>Ii4<#Ar*0NFEjEo7;qfe zAiXo<$wMy1>>%#i&*Ga{6nq&jjS{0_Fc9hBEyL{`QLg^N8G*p$%HZkh=d#Wzp$Pzp C3b*wD literal 0 HcmV?d00001 diff --git a/e2e/fixtures/tiles/1/1/0.png b/e2e/fixtures/tiles/1/1/0.png new file mode 100644 index 0000000000000000000000000000000000000000..6f95e56376a59ff0a69b533330bbcc775791f690 GIT binary patch literal 726 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K58911L)MWvCLk0$>xt=bLAr*0NFE81OJ` z>^vjCKxC`diRXG;mDci`SQLC2E{zhSVK5Ns;4Qxt=bLAr*0NFFW!w7;rFb zl$`mW->sl^LUs^$ZN1ng76o61OQXbS7z{)@c*}6%x_G~ZJ=1kydS&o*^>bP0l+XkK DL1(wW literal 0 HcmV?d00001 diff --git a/e2e/fixtures/tiles/2/0/0.png b/e2e/fixtures/tiles/2/0/0.png new file mode 100644 index 0000000000000000000000000000000000000000..6f591bd8846066e28b56fd1824028ede71eaa722 GIT binary patch literal 725 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K58911L)MWvCLk0$>Ii4<#Ar*0NFEjEo7;qfe zAiXnC<`I`-b`W>%Z}v?r3cd`NMv2id7>IQ6mO*Wj$igH$w|ro7W$<+Mb6Mw<&;$TZ C0Jf3< literal 0 HcmV?d00001 diff --git a/e2e/fixtures/tiles/2/0/1.png b/e2e/fixtures/tiles/2/0/1.png new file mode 100644 index 0000000000000000000000000000000000000000..2a24529eac29fc2cbb1d8e71e02fdca84bf84fa0 GIT binary patch literal 725 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K58911L)MWvCLk0$>Ii4<#Ar*0NFEjEo7;qfe zAiYz5&0{Xb>>%#i&GMUA6nq&jjS{0_Fc9hBEkky$;O0`zsiMH-%HZkh=d#Wzp$P!Z Cjkext=bLAr*0NFFW!w7;rFb zl)TCR->#r_LUs^$?Pu{#EDF91mqv-vFc^q*@RmVw2mkbR=09J6>6O9L)z4*}Q$iB} D0Rp(F literal 0 HcmV?d00001 diff --git a/e2e/fixtures/tiles/2/0/3.png b/e2e/fixtures/tiles/2/0/3.png new file mode 100644 index 0000000000000000000000000000000000000000..63b8ac04778aeee81597e3fc9f25213d4b097f19 GIT binary patch literal 725 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K58911L)MWvCLk0$>Ii4<#Ar*0NFEjEo7;qfe zAiY!9<}sIIb`W>1wfrU)1z(0sqr_+!3`9D3%kZvHsC6^z(L=!G%HZkh=d#Wzp$P!1 CYPVVd literal 0 HcmV?d00001 diff --git a/e2e/fixtures/tiles/2/1/0.png b/e2e/fixtures/tiles/2/1/0.png new file mode 100644 index 0000000000000000000000000000000000000000..b24b09617530766447530b996d764b0037ab5be2 GIT binary patch literal 725 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K58911L)MWvCLk0$>Ii4<#Ar*0NFEjEo7;qfe zAiXnw%|kB5>>%#idiG5$3cd`NMv2id7>IQ6mSMW0XfcPC({f;PW$<+Mb6Mw<&;$UY ChPGS) literal 0 HcmV?d00001 diff --git a/e2e/fixtures/tiles/2/1/1.png b/e2e/fixtures/tiles/2/1/1.png new file mode 100644 index 0000000000000000000000000000000000000000..6f60c5851f302903c2b5352345a61c9f2fcf69bf GIT binary patch literal 726 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K58911L)MWvCLk0$>xt=bLAr*0NFE81OJ` z>^vjCh-ItRiRXG;m7m2ou_*X5TpA@t!(brN!CQuV=@JEpm-hoxE(3$7tDnm{r-UW| Dnclg7 literal 0 HcmV?d00001 diff --git a/e2e/fixtures/tiles/2/1/2.png b/e2e/fixtures/tiles/2/1/2.png new file mode 100644 index 0000000000000000000000000000000000000000..63947da483d62fd6981c37205ecb48ca01356217 GIT binary patch literal 725 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K58911L)MWvCLk0$>Ii4<#Ar*0NFEjEo7;qfe zAicBZ$OA6L>>%#i&GMUA6nq&jjS{0_Fc9hBErY6oxLQrMbto{oGI+ZBxvXIi4<#Ar*0NFEjEo7;qfe zAidKw<`I`-b`W=Mz1Sue1z(0sqr_+!3`9D3%W%<2MDz4+yIi4<#Ar*0NFEjEo7;qfe zAiY!k&0{Xb>>%#i-|U-M6nq&jjS{0_Fc9hBErV9FptWK#dnGWrGI+ZBxvXxt=bLAr*0NFE81OJ` z?Ce?aPhhLoiRXG;mG$hKSQLC2E{zhSVK5Ns;4Q-o7Xb!cf$*Ea^vdAr>gTe~DWM4f D=R>$P literal 0 HcmV?d00001 diff --git a/e2e/fixtures/tiles/2/2/2.png b/e2e/fixtures/tiles/2/2/2.png new file mode 100644 index 0000000000000000000000000000000000000000..07300035f8f53ba93b174d8ff1e83869c263abe9 GIT binary patch literal 725 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K58911L)MWvCLk0$>Ii4<#Ar*0NFEjEo7;qfe zAidM{&m%6y>>%#i&*Ga{6nq&jjS{0_Fc9hBErZHtp}&Ij*-e1SmBG{1&t;ucLK6UH C3%6|m literal 0 HcmV?d00001 diff --git a/e2e/fixtures/tiles/2/2/3.png b/e2e/fixtures/tiles/2/2/3.png new file mode 100644 index 0000000000000000000000000000000000000000..034d61fc805f8b9f29a1f4ea6a36839f7e117544 GIT binary patch literal 725 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K58911L)MWvCLk0$>Ii4<#Ar*0NFEjEo7;qfe zAiXm^=OLG3b`W>1wfrU)1z(0sqr_+!3`9D3%kYj(^!0(gp$)*~%HZkh=d#Wzp$P!J C4!6?) literal 0 HcmV?d00001 diff --git a/e2e/fixtures/tiles/2/3/0.png b/e2e/fixtures/tiles/2/3/0.png new file mode 100644 index 0000000000000000000000000000000000000000..60b20eaa35cea4dc52e6fa9f91dc76e0970c4a2a GIT binary patch literal 726 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K58911L)MWvCLk0$>xt=bLAr*0NFFW!w7;rFb zl$=>F|EHjJLUs^$Z9V%Y76o61OQXbS7z{)@c*`(3K;q`=4|6Sm>6O9L)z4*}Q$iB} D*%`Q< literal 0 HcmV?d00001 diff --git a/e2e/fixtures/tiles/2/3/1.png b/e2e/fixtures/tiles/2/3/1.png new file mode 100644 index 0000000000000000000000000000000000000000..a87354eb9aa683d402c6141260156878e857711b GIT binary patch literal 725 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K58911L)MWvCLk0$>Ii4<#Ar*0NFEjEo7;qfe zAicAM%Z}v?r3cd`NMv2id7>IQ6mf@zM*t+9hA3p(;D}$%2pUXO@geCxo CAGn+V literal 0 HcmV?d00001 diff --git a/e2e/fixtures/tiles/2/3/2.png b/e2e/fixtures/tiles/2/3/2.png new file mode 100644 index 0000000000000000000000000000000000000000..3f61b9214c57229dd7e7f419ad00b9e5dae1573e GIT binary patch literal 725 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K58911L)MWvCLk0$>Ii4<#Ar*0NFEjEo7;qfe zAidLR$|Ekt>>%#i&GMUA6nq&jjS{0_Fc9hBErXJ&@PWjGsqcZwmBG{1&t;ucLK6V* C9Ji?e literal 0 HcmV?d00001 diff --git a/e2e/fixtures/tiles/2/3/3.png b/e2e/fixtures/tiles/2/3/3.png new file mode 100644 index 0000000000000000000000000000000000000000..c1eb4c450254dbdcdfe4ce2d90086d4c7bc9b1f0 GIT binary patch literal 726 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K58911L)MWvCLk0$>xt=bLAr*0NFFW!w7;rFb zl$XMaKKgzO;h+Iq1~EDF91mqv-vFc^q*@RlK}P2du1(2C8#^vdAr>gTe~DWM4f Dao)HM literal 0 HcmV?d00001 From e470abbe6f787946c5d6469845b5980d22c860fe Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Thu, 17 Sep 2026 01:38:06 +0100 Subject: [PATCH 2/5] test(e2e): run the e2e suite under chromium, firefox and webkit Each test file's body now runs once per engine through describeEngines in e2e/browsers.ts, which owns the launch options, the shared page and the download directory. Firefox is skipped when CI is set, as CI installs only chromium and webkit. Three things had to change for the other two engines: Playwright's WebKit does not route requests that pass through a controlling service worker, so the cancel test's page.route delay never applied and the export finished before Cancel was clicked. The fixture server now honours ?delay= instead, which every engine sees. Playwright's Firefox never raises a download event for a response whose body errors, and stops reporting downloads at all once one has. The two tests that assert a failed download skip Firefox, and the cancel test runs last. Filling the style field clears the resolved style and re-resolves it after a debounce, so waiting only for Export to become enabled could match the render before the change landed and click through to a no-op. Tests that change the style now wait for Export to drop out first. Also adds an app test that exports through the raster tile fixtures and asserts every flat patch of the output is one of the tile colours. --- e2e/app.test.ts | 161 +++++++++++++++++++++++++++------------ e2e/browsers.ts | 90 ++++++++++++++++++++++ e2e/export.test.ts | 96 +++++++++++------------ e2e/global-setup.ts | 7 +- e2e/map-renderer.test.ts | 27 ++----- 5 files changed, 261 insertions(+), 120 deletions(-) create mode 100644 e2e/browsers.ts diff --git a/e2e/app.test.ts b/e2e/app.test.ts index 8833e95..a4ce353 100644 --- a/e2e/app.test.ts +++ b/e2e/app.test.ts @@ -1,26 +1,27 @@ import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { decode, type DecodedPng } from "fast-png"; -import { chromium, type Browser, type Page } from "playwright"; -import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import type { Page } from "playwright"; +import { beforeAll, expect, test } from "vitest"; +import { baseUrl, describeEngines, usePage } from "./browsers.ts"; import { mmToPx } from "../src/lib/viewport/index.ts"; -const baseUrl = "http://localhost:4174"; const fixtureStyle = `${baseUrl}/fixtures/style-geojson.json`; /** Seeded so the app never resolves the default style over the network. */ const seededStyle = `${baseUrl}/fixtures/style-geojson-nobg.json`; - -const chromiumArgs = - process.platform === "darwin" - ? ["--use-gl=angle", "--use-angle=metal"] - : ["--use-gl=angle", "--use-angle=swiftshader"]; +const rasterTemplate = `${baseUrl}/fixtures/tiles/{z}/{x}/{y}.png`; const BBOX = "-4, -3, 4, 3"; const WIDTH_MM = 80; const HEIGHT_MM = 53; const DPI = 96; +/** Big enough an area, small enough a page, that the export needs the zoom-2 + * tiles — the deepest the fixture set goes. */ +const RASTER_BBOX = "-90, -45, 90, 45"; +const RASTER_WIDTH_MM = 150; +const RASTER_HEIGHT_MM = 84; + function pixelAt(png: DecodedPng, x: number, y: number): number[] { const i = (y * png.width + x) * png.channels; return [png.data[i], png.data[i + 1], png.data[i + 2]]; @@ -30,22 +31,65 @@ function isRed([r, g, b]: number[]): boolean { return r > 200 && g < 60 && b < 60; } -describe("chromium app", () => { - let browser: Browser; +/** Waits out a change to the style field: the form drops the resolved style on + * the first keystroke and re-resolves it after a debounce, and Export is only + * meaningful once it has come back. */ +async function waitForStyle(page: Page): Promise { + await page.waitForSelector("#export[disabled]"); + await page.waitForSelector("#export:not([disabled])"); +} + +function near(a: number[], b: number[], tolerance = 4): boolean { + return a.every((value, i) => Math.abs(value - b[i]) <= tolerance); +} + +/** The colour of every fixture tile, read back from the generated PNGs. */ +function tilePalette(): number[][] { + const dir = path.resolve("e2e/fixtures/tiles"); + return fs + .readdirSync(dir, { recursive: true, encoding: "utf8" }) + .filter((file) => file.endsWith(".png")) + .map((file) => + pixelAt(decode(fs.readFileSync(path.join(dir, file))), 0, 0), + ); +} + +/** Positions whose whole neighbourhood is one flat colour, i.e. tile interiors + * rather than the seams where MapLibre blends adjacent tiles. */ +function* flatPatches( + png: DecodedPng, + step = 16, + radius = 2, +): Generator<[number, number, number[]]> { + for (let y = radius; y < png.height - radius; y += step) { + for (let x = radius; x < png.width - radius; x += step) { + const centre = pixelAt(png, x, y); + let flat = true; + for (let dy = -radius; flat && dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + if (!near(pixelAt(png, x + dx, y + dy), centre, 1)) { + flat = false; + break; + } + } + } + if (flat) yield [x, y, centre]; + } + } +} + +describeEngines((engine) => { + const browser = usePage(engine); let page: Page; - let downloadDir: string; beforeAll(async () => { - downloadDir = fs.mkdtempSync(path.join(os.tmpdir(), "map-printer-app-")); - browser = await chromium.launch({ - headless: true, - args: ["--ignore-gpu-blocklist", "--enable-webgl", ...chromiumArgs], - }); - page = await browser.newPage({ acceptDownloads: true }); - page.on("pageerror", (error) => console.error("[page]", error)); - await page.addInitScript((json: string) => { - localStorage.setItem("map-printer-settings", json); - }, JSON.stringify({ style: seededStyle, dpi: DPI })); + page = browser.page; + await page.addInitScript( + (json: string) => { + localStorage.setItem("map-printer-settings", json); + }, + JSON.stringify({ style: seededStyle, dpi: DPI }), + ); await page.goto(baseUrl); await page.waitForSelector("#export"); @@ -56,11 +100,6 @@ describe("chromium app", () => { await page.selectOption("#dpi", String(DPI)); }); - afterAll(async () => { - await browser?.close(); - if (downloadDir) fs.rmSync(downloadDir, { recursive: true, force: true }); - }); - test("Export stays disabled until the attribution box is ticked", async () => { await page.check("#attribution-ok"); await page.waitForSelector("#export:not([disabled])"); @@ -91,7 +130,7 @@ describe("chromium app", () => { expect(download.suggestedFilename()).toBe( `map-${WIDTH_MM}x${HEIGHT_MM}mm-${DPI}dpi.png`, ); - const file = path.join(downloadDir, download.suggestedFilename()); + const file = path.join(browser.downloadDir, download.suggestedFilename()); await download.saveAs(file); const png = decode(fs.readFileSync(file)); @@ -104,25 +143,53 @@ describe("chromium app", () => { await page.waitForSelector('[data-message="success"]'); }); - test("cancelling an export restores the form", async () => { - // Held up so the export is certain to still be running when Cancel is hit. - await page.route(fixtureStyle, async (route) => { - await new Promise((resolve) => setTimeout(resolve, 1_000)); - await route.continue(); - }); - try { - await page.click("#export"); - await page.waitForSelector('[role="progressbar"]'); - await page.click("#cancel"); - - await page.waitForSelector('[data-message="error"]'); - expect(await page.textContent('[data-message="error"]')).toContain( - "cancelled", - ); - await page.waitForSelector('[role="progressbar"]', { state: "detached" }); - await page.waitForSelector("#export:not([disabled])"); - } finally { - await page.unroute(fixtureStyle); + test("exports a raster tile template in the fixture palette", async () => { + await page.fill("#style", rasterTemplate); + await page.fill("#bbox", RASTER_BBOX); + await page.fill("#width", String(RASTER_WIDTH_MM)); + await page.fill("#height", String(RASTER_HEIGHT_MM)); + await waitForStyle(page); + + const [download] = await Promise.all([ + page.waitForEvent("download", { timeout: 60_000 }), + page.click("#export"), + ]); + const file = path.join(browser.downloadDir, download.suggestedFilename()); + await download.saveAs(file); + const png = decode(fs.readFileSync(file)); + + const palette = tilePalette(); + const seen = new Set(); + let sampled = 0; + for (const [x, y, rgb] of flatPatches(png)) { + const match = palette.find((color) => near(rgb, color)); + expect(match, `${rgb} at ${x},${y} is not a tile colour`).toBeDefined(); + seen.add(String(match)); + sampled++; } + expect(sampled).toBeGreaterThan(100); + expect(seen.size).toBeGreaterThan(1); + }); + + // Last: Playwright's Firefox stops reporting downloads once one has been + // errored, which is what cancelling does. + test("cancelling an export restores the form", async () => { + // The fixture server holds the style back so the export is certain to still + // be running when Cancel is hit. Playwright's WebKit does not route + // requests that pass through a controlling service worker, so the delay + // cannot come from `page.route`. + await page.fill("#style", `${fixtureStyle}?delay=2000`); + await waitForStyle(page); + + await page.click("#export"); + await page.waitForSelector('[role="progressbar"]'); + await page.click("#cancel"); + + await page.waitForSelector('[data-message="error"]'); + expect(await page.textContent('[data-message="error"]')).toContain( + "cancelled", + ); + await page.waitForSelector('[role="progressbar"]', { state: "detached" }); + await page.waitForSelector("#export:not([disabled])"); }); }); diff --git a/e2e/browsers.ts b/e2e/browsers.ts new file mode 100644 index 0000000..cecd280 --- /dev/null +++ b/e2e/browsers.ts @@ -0,0 +1,90 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + chromium, + firefox, + webkit, + type Browser, + type BrowserType, + type LaunchOptions, + type Page, +} from "playwright"; +import { afterAll, beforeAll, describe } from "vitest"; + +export const baseUrl = "http://localhost:4174"; +export const harnessUrl = `${baseUrl}/e2e/harness/index.html`; + +const chromiumArgs = [ + "--ignore-gpu-blocklist", + "--enable-webgl", + "--use-gl=angle", + process.platform === "darwin" + ? "--use-angle=metal" + : "--use-angle=swiftshader", +]; + +export interface Engine { + name: string; + type: BrowserType; + launchOptions?: LaunchOptions; + /** Playwright's Firefox never raises a `download` event for a response whose + * body errors, so the tests that assert a failed download run without it. */ + reportsFailedDownloads: boolean; +} + +export const engines: Engine[] = [ + { + name: "chromium", + type: chromium, + launchOptions: { args: chromiumArgs }, + reportsFailedDownloads: true, + }, + { name: "firefox", type: firefox, reportsFailedDownloads: false }, + { name: "webkit", type: webkit, reportsFailedDownloads: true }, +]; + +/** Runs `body` once per engine. Firefox is left out of CI, which installs only + * chromium and webkit. */ +export function describeEngines(body: (engine: Engine) => void): void { + for (const engine of engines) { + const suite = + engine.name === "firefox" && process.env.CI ? describe.skip : describe; + suite(engine.name, () => body(engine)); + } +} + +export interface EnginePage { + page: Page; + /** Temp directory downloads are saved into, removed after the suite. */ + downloadDir: string; +} + +/** One page per suite, opened in `beforeAll` and closed after. */ +export function usePage(engine: Engine): EnginePage { + const holder = {} as EnginePage; + let browser: Browser | undefined; + + beforeAll(async () => { + holder.downloadDir = fs.mkdtempSync( + path.join(os.tmpdir(), `map-printer-${engine.name}-`), + ); + browser = await engine.type.launch({ + headless: true, + ...engine.launchOptions, + }); + holder.page = await browser.newPage({ acceptDownloads: true }); + holder.page.on("pageerror", (error) => + console.error(`[${engine.name}]`, error), + ); + }); + + afterAll(async () => { + await browser?.close(); + if (holder.downloadDir) { + fs.rmSync(holder.downloadDir, { recursive: true, force: true }); + } + }); + + return holder; +} diff --git a/e2e/export.test.ts b/e2e/export.test.ts index ac965e7..14528c5 100644 --- a/e2e/export.test.ts +++ b/e2e/export.test.ts @@ -1,9 +1,9 @@ import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { decode, type DecodedPng } from "fast-png"; -import { chromium, type Browser, type Page } from "playwright"; -import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import type { Page } from "playwright"; +import { beforeAll, expect, test } from "vitest"; +import { describeEngines, harnessUrl, usePage } from "./browsers.ts"; import { fitBounds, project, @@ -11,15 +11,8 @@ import { type Viewport, } from "../src/lib/viewport/index.ts"; -const baseUrl = "http://localhost:4174"; -const harnessUrl = `${baseUrl}/e2e/harness/index.html`; const fixtureStyle = "/fixtures/style-geojson.json"; -const chromiumArgs = - process.platform === "darwin" - ? ["--use-gl=angle", "--use-angle=metal"] - : ["--use-gl=angle", "--use-angle=swiftshader"]; - /** The red square in the fixture style. */ const SQUARE: Bbox = [-1, -1, 1, 1]; /** Room around the square, in the 4:3 aspect of the small export. */ @@ -79,29 +72,17 @@ interface ExportRequest { tileSize?: { width: number; height: number }; } -describe("chromium", () => { - let browser: Browser; +describeEngines((engine) => { + const browser = usePage(engine); let page: Page; - let downloadDir: string; beforeAll(async () => { - downloadDir = fs.mkdtempSync(path.join(os.tmpdir(), "map-printer-e2e-")); - browser = await chromium.launch({ - headless: true, - args: ["--ignore-gpu-blocklist", "--enable-webgl", ...chromiumArgs], - }); - page = await browser.newPage({ acceptDownloads: true }); - page.on("pageerror", (error) => console.error("[page]", error)); + page = browser.page; await page.goto(harnessUrl); await page.waitForFunction(() => Boolean(window.mapPrinter)); await page.evaluate(() => window.mapPrinter.downloadReady()); }); - afterAll(async () => { - await browser?.close(); - if (downloadDir) fs.rmSync(downloadDir, { recursive: true, force: true }); - }); - /** Exports through the service worker and decodes what reached disk. */ async function exportPng(request: ExportRequest) { const [download, result] = await Promise.all([ @@ -125,7 +106,7 @@ describe("chromium", () => { }, JSON.stringify(request)), ]); expect(download.suggestedFilename()).toBe(request.filename); - const file = path.join(downloadDir, download.suggestedFilename()); + const file = path.join(browser.downloadDir, download.suggestedFilename()); await download.saveAs(file); return { result, png: decode(fs.readFileSync(file)) }; } @@ -192,7 +173,11 @@ describe("chromium", () => { tileSize, }); - const v = fitBounds(TIGHT_BBOX, widthPx / pixelRatio, heightPx / pixelRatio); + const v = fitBounds( + TIGHT_BBOX, + widthPx / pixelRatio, + heightPx / pixelRatio, + ); expect(png.width).toBe(widthPx); expect(png.height).toBe(heightPx); expect(result.progress).toHaveLength(3); @@ -228,7 +213,11 @@ describe("chromium", () => { for (const y of sampleAlong(square.top, square.bottom, margin)) { expectColor(pixelAt(png, square.left + margin, y), RED, `left in ${y}`); - expectColor(pixelAt(png, square.left - margin, y), WHITE, `left out ${y}`); + expectColor( + pixelAt(png, square.left - margin, y), + WHITE, + `left out ${y}`, + ); expectColor(pixelAt(png, square.right - margin, y), RED, `right in ${y}`); expectColor( pixelAt(png, square.right + margin, y), @@ -252,7 +241,9 @@ describe("chromium", () => { } }); - test("aborting mid-export fails the download", async () => { + const testFailedDownload = engine.reportsFailedDownloads ? test : test.skip; + + testFailedDownload("aborting mid-export fails the download", async () => { const request: ExportRequest = { style: fixtureStyle, bbox: TIGHT_BBOX, @@ -298,27 +289,30 @@ describe("chromium", () => { expect(png.width).toBe(320); }); - test("a style that 404s fails the download rather than truncating it", async () => { - const [download, message] = await Promise.all([ - page.waitForEvent("download", { timeout: 60_000 }), - page.evaluate(async () => { - try { - await window.mapPrinter.exportMap({ - style: "/fixtures/no-such-style.json", - bbox: [-4, -3, 4, 3], - widthPx: 320, - heightPx: 240, - pixelRatio: 1, - filename: "missing-style.png", - }); - return "resolved"; - } catch (err) { - return err instanceof Error ? err.message : String(err); - } - }), - ]); + testFailedDownload( + "a style that 404s fails the download rather than truncating it", + async () => { + const [download, message] = await Promise.all([ + page.waitForEvent("download", { timeout: 60_000 }), + page.evaluate(async () => { + try { + await window.mapPrinter.exportMap({ + style: "/fixtures/no-such-style.json", + bbox: [-4, -3, 4, 3], + widthPx: 320, + heightPx: 240, + pixelRatio: 1, + filename: "missing-style.png", + }); + return "resolved"; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + }), + ]); - expect(message).not.toBe("resolved"); - expect(await download.failure()).not.toBeNull(); - }); + expect(message).not.toBe("resolved"); + expect(await download.failure()).not.toBeNull(); + }, + ); }); diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index 4bca5c8..2b097b5 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -35,7 +35,12 @@ export async function setup() { } const type = CONTENT_TYPES[path.extname(file)]; if (type) res.setHeader("Content-Type", type); - fs.createReadStream(file).pipe(res); + const delay = Number(url.searchParams.get("delay")); + if (!delay) return fs.createReadStream(file).pipe(res); + // A held-up response has to be fetched afresh every time for the + // delay to be worth anything. + res.setHeader("Cache-Control", "no-store"); + setTimeout(() => fs.createReadStream(file).pipe(res), delay); }); }, }, diff --git a/e2e/map-renderer.test.ts b/e2e/map-renderer.test.ts index 300d746..fd44482 100644 --- a/e2e/map-renderer.test.ts +++ b/e2e/map-renderer.test.ts @@ -1,6 +1,7 @@ -import { chromium, type Browser, type Page } from "playwright"; -import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import type { Page } from "playwright"; +import { beforeAll, expect, test } from "vitest"; import type { StyleSpecification } from "maplibre-gl"; +import { baseUrl, describeEngines, harnessUrl, usePage } from "./browsers.ts"; import { fitBounds, project, @@ -10,15 +11,8 @@ import { type Viewport, } from "../src/lib/viewport/index.ts"; -const baseUrl = "http://localhost:4174"; -const harnessUrl = `${baseUrl}/e2e/harness/index.html`; const fixtureStyle = "/fixtures/style-geojson.json"; -const chromiumArgs = - process.platform === "darwin" - ? ["--use-gl=angle", "--use-angle=metal"] - : ["--use-gl=angle", "--use-angle=swiftshader"]; - /** The red square in the fixture style. */ const SQUARE: Bbox = [-1, -1, 1, 1]; /** A view that places the square in the upper half of the tile. */ @@ -158,25 +152,16 @@ async function createRenderer( }, JSON.stringify(opts)); } -describe("chromium", () => { - let browser: Browser; +describeEngines((engine) => { + const browser = usePage(engine); let page: Page; beforeAll(async () => { - browser = await chromium.launch({ - headless: true, - args: ["--ignore-gpu-blocklist", "--enable-webgl", ...chromiumArgs], - }); - page = await browser.newPage(); - page.on("pageerror", (error) => console.error("[page]", error)); + page = browser.page; await page.goto(harnessUrl); await page.waitForFunction(() => Boolean(window.mapPrinter)); }); - afterAll(async () => { - await browser?.close(); - }); - test("maxTileSize reports a usable size", async () => { const size = await page.evaluate(() => window.mapPrinter.maxTileSize(2)); expect(size.width).toBeGreaterThanOrEqual(1024); From 24f06d2d442b91359c4f3ccc8e8c4ded351fbc6e Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Thu, 17 Sep 2026 01:38:10 +0100 Subject: [PATCH 3/5] ci: run typecheck, unit tests, build and e2e on push and PR Mirrors map-downloader's workflow: Node 22, a Playwright browser cache keyed on the Playwright version, chromium and webkit only, e2e under xvfb-run. --- .github/workflows/test.yml | 61 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..5cabf23 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,61 @@ +name: Test + +on: + push: + branches: [master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Get Playwright version + id: playwright-version + run: echo "PLAYWRIGHT_VERSION=$(npx playwright --version)" >> $GITHUB_OUTPUT + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache/restore@v6 + with: + path: ~/.cache/ms-playwright + key: playwright-chromium-webkit-${{ steps.playwright-version.outputs.PLAYWRIGHT_VERSION }} + + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: npx playwright install --with-deps chromium webkit + + - name: Install Playwright dependencies + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps chromium webkit + + - name: Typecheck + run: npm run typecheck + + - name: Unit tests + run: npm test + + - name: Build + run: npm run build + + # Firefox is not installed here; e2e/browsers.ts skips it when CI is set. + - name: e2e tests + run: xvfb-run npm run test:e2e + + - name: Save Playwright cache + if: always() && steps.playwright-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v6 + with: + path: ~/.cache/ms-playwright + key: ${{ steps.playwright-cache.outputs.cache-primary-key }} From a50433a9400254758fbc09a650e590d72c10d524 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Thu, 17 Sep 2026 01:47:50 +0100 Subject: [PATCH 4/5] test(e2e): fill the style last in the raster export test The debounced style resolve only restarts on a style or token change, so the three fills between the style keystroke and waitForStyle could outlast it and the wait would miss Export going disabled. --- e2e/app.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/e2e/app.test.ts b/e2e/app.test.ts index a4ce353..7fc2ab9 100644 --- a/e2e/app.test.ts +++ b/e2e/app.test.ts @@ -144,10 +144,12 @@ describeEngines((engine) => { }); test("exports a raster tile template in the fixture palette", async () => { - await page.fill("#style", rasterTemplate); await page.fill("#bbox", RASTER_BBOX); await page.fill("#width", String(RASTER_WIDTH_MM)); await page.fill("#height", String(RASTER_HEIGHT_MM)); + // Last: the debounced re-resolve would otherwise re-enable Export before + // waitForStyle sees it go disabled. + await page.fill("#style", rasterTemplate); await waitForStyle(page); const [download] = await Promise.all([ From 5b73ada21a58381a44c5901c2cc39e054f7dae02 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Thu, 17 Sep 2026 03:18:47 +0100 Subject: [PATCH 5/5] test(e2e): accept no download when the response errors before its first byte Linux WebKit reports no download at all when the service-worker response is errored before any body byte arrives, so the 404-style test timed out in CI. --- e2e/export.test.ts | 46 ++++++++++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/e2e/export.test.ts b/e2e/export.test.ts index 14528c5..1411960 100644 --- a/e2e/export.test.ts +++ b/e2e/export.test.ts @@ -292,27 +292,33 @@ describeEngines((engine) => { testFailedDownload( "a style that 404s fails the download rather than truncating it", async () => { - const [download, message] = await Promise.all([ - page.waitForEvent("download", { timeout: 60_000 }), - page.evaluate(async () => { - try { - await window.mapPrinter.exportMap({ - style: "/fixtures/no-such-style.json", - bbox: [-4, -3, 4, 3], - widthPx: 320, - heightPx: 240, - pixelRatio: 1, - filename: "missing-style.png", - }); - return "resolved"; - } catch (err) { - return err instanceof Error ? err.message : String(err); - } - }), - ]); - + const downloaded = page + .waitForEvent("download", { timeout: 60_000 }) + .catch(() => null); + const message = await page.evaluate(async () => { + try { + await window.mapPrinter.exportMap({ + style: "/fixtures/no-such-style.json", + bbox: [-4, -3, 4, 3], + widthPx: 320, + heightPx: 240, + pixelRatio: 1, + filename: "missing-style.png", + }); + return "resolved"; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + }); expect(message).not.toBe("resolved"); - expect(await download.failure()).not.toBeNull(); + + // The response errors before any body byte reaches the browser; Linux + // WebKit then reports no download at all, other engines a failed one. + const download = await Promise.race([ + downloaded, + page.waitForTimeout(3000).then(() => null), + ]); + if (download) expect(await download.failure()).not.toBeNull(); }, ); });