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 }} diff --git a/e2e/app.test.ts b/e2e/app.test.ts index 8833e95..7fc2ab9 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,55 @@ 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("#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([ + 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..1411960 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,10 +289,13 @@ 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 () => { + testFailedDownload( + "a style that 404s fails the download rather than truncating it", + async () => { + 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", @@ -315,10 +309,16 @@ describe("chromium", () => { } catch (err) { return err instanceof Error ? err.message : String(err); } - }), - ]); + }); + expect(message).not.toBe("resolved"); - 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(); + }, + ); }); 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 0000000..519d62e Binary files /dev/null and b/e2e/fixtures/tiles/0/0/0.png differ diff --git a/e2e/fixtures/tiles/1/0/0.png b/e2e/fixtures/tiles/1/0/0.png new file mode 100644 index 0000000..1d81d65 Binary files /dev/null and b/e2e/fixtures/tiles/1/0/0.png differ diff --git a/e2e/fixtures/tiles/1/0/1.png b/e2e/fixtures/tiles/1/0/1.png new file mode 100644 index 0000000..651df25 Binary files /dev/null and b/e2e/fixtures/tiles/1/0/1.png differ diff --git a/e2e/fixtures/tiles/1/1/0.png b/e2e/fixtures/tiles/1/1/0.png new file mode 100644 index 0000000..6f95e56 Binary files /dev/null and b/e2e/fixtures/tiles/1/1/0.png differ diff --git a/e2e/fixtures/tiles/1/1/1.png b/e2e/fixtures/tiles/1/1/1.png new file mode 100644 index 0000000..8aee747 Binary files /dev/null and b/e2e/fixtures/tiles/1/1/1.png differ diff --git a/e2e/fixtures/tiles/2/0/0.png b/e2e/fixtures/tiles/2/0/0.png new file mode 100644 index 0000000..6f591bd Binary files /dev/null and b/e2e/fixtures/tiles/2/0/0.png differ diff --git a/e2e/fixtures/tiles/2/0/1.png b/e2e/fixtures/tiles/2/0/1.png new file mode 100644 index 0000000..2a24529 Binary files /dev/null and b/e2e/fixtures/tiles/2/0/1.png differ diff --git a/e2e/fixtures/tiles/2/0/2.png b/e2e/fixtures/tiles/2/0/2.png new file mode 100644 index 0000000..6002ff0 Binary files /dev/null and b/e2e/fixtures/tiles/2/0/2.png differ diff --git a/e2e/fixtures/tiles/2/0/3.png b/e2e/fixtures/tiles/2/0/3.png new file mode 100644 index 0000000..63b8ac0 Binary files /dev/null and b/e2e/fixtures/tiles/2/0/3.png differ diff --git a/e2e/fixtures/tiles/2/1/0.png b/e2e/fixtures/tiles/2/1/0.png new file mode 100644 index 0000000..b24b096 Binary files /dev/null and b/e2e/fixtures/tiles/2/1/0.png differ diff --git a/e2e/fixtures/tiles/2/1/1.png b/e2e/fixtures/tiles/2/1/1.png new file mode 100644 index 0000000..6f60c58 Binary files /dev/null and b/e2e/fixtures/tiles/2/1/1.png differ diff --git a/e2e/fixtures/tiles/2/1/2.png b/e2e/fixtures/tiles/2/1/2.png new file mode 100644 index 0000000..63947da Binary files /dev/null and b/e2e/fixtures/tiles/2/1/2.png differ diff --git a/e2e/fixtures/tiles/2/1/3.png b/e2e/fixtures/tiles/2/1/3.png new file mode 100644 index 0000000..f848c23 Binary files /dev/null and b/e2e/fixtures/tiles/2/1/3.png differ diff --git a/e2e/fixtures/tiles/2/2/0.png b/e2e/fixtures/tiles/2/2/0.png new file mode 100644 index 0000000..a8c7627 Binary files /dev/null and b/e2e/fixtures/tiles/2/2/0.png differ diff --git a/e2e/fixtures/tiles/2/2/1.png b/e2e/fixtures/tiles/2/2/1.png new file mode 100644 index 0000000..f9531df Binary files /dev/null and b/e2e/fixtures/tiles/2/2/1.png differ diff --git a/e2e/fixtures/tiles/2/2/2.png b/e2e/fixtures/tiles/2/2/2.png new file mode 100644 index 0000000..0730003 Binary files /dev/null and b/e2e/fixtures/tiles/2/2/2.png differ diff --git a/e2e/fixtures/tiles/2/2/3.png b/e2e/fixtures/tiles/2/2/3.png new file mode 100644 index 0000000..034d61f Binary files /dev/null and b/e2e/fixtures/tiles/2/2/3.png differ diff --git a/e2e/fixtures/tiles/2/3/0.png b/e2e/fixtures/tiles/2/3/0.png new file mode 100644 index 0000000..60b20ea Binary files /dev/null and b/e2e/fixtures/tiles/2/3/0.png differ diff --git a/e2e/fixtures/tiles/2/3/1.png b/e2e/fixtures/tiles/2/3/1.png new file mode 100644 index 0000000..a87354e Binary files /dev/null and b/e2e/fixtures/tiles/2/3/1.png differ diff --git a/e2e/fixtures/tiles/2/3/2.png b/e2e/fixtures/tiles/2/3/2.png new file mode 100644 index 0000000..3f61b92 Binary files /dev/null and b/e2e/fixtures/tiles/2/3/2.png differ diff --git a/e2e/fixtures/tiles/2/3/3.png b/e2e/fixtures/tiles/2/3/3.png new file mode 100644 index 0000000..c1eb4c4 Binary files /dev/null and b/e2e/fixtures/tiles/2/3/3.png differ 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);