Test MapLibre maps
with Playwright.

Find a feature. Click it. Assert what changed.

geowright reads the map through its public API and acts with real mouse, touch and keyboard input, never map.fire(). Assert what was added, changed or removed on a WebGL map that a DOM-only suite can't see.

geowright on npm

Explore map interactions

A real MapLibre map with OpenStreetMap streets and fictional deliveries, beside the test you would write for each case.

What the test does

© OpenStreetMapProtomaps

Delivery featureLayer: deliveries

Preview. Start the live demo to load the local map.

map.spec.ts
const map = geowright(page);await map.feature({ layer: 'deliveries', id: 7 }).click();await expect(map).toHavePopup({ text: 'Delivery 07' });

Choose a condition

Start the demo, then select a delivery, hide it, or cover it. The code shows what a test would do in each case.

See the complete test

What a DOM-only suite misses on a map

A MapLibre map renders to a single WebGL canvas. Finding an element, reading its text, clicking it, waiting for it to appear: all of it stops working once the thing under test is a pixel inside that canvas. geowright closes the two gaps that leaves.

Assert

After an action in the normal UI, verify that something was added to, changed on, or removed from the map.

assert
await page.getByTestId('alert-remove-2').click();await expect(map).not.toHaveFeature({ layer: 'alerts', id: 2 });

Act

Perform actions that start on the map: click a feature, right-click a location, draw, drag a vertex.

act
await map.feature({ layer: 'alerts', id: 4 }).click();await map.clickAt({ lng: 34.78, lat: 32.08 });

A tour of the API

Assertions retry like web-first assertions; actions auto-wait, are strict, and are verified.

API reference
  1. Queries that match what a user can see

    map.feature(q) is strict, like a Locator; map.features(q) is a set. MapLibre also returns features painted at opacity 0, so queries evaluate the style for each feature and match only the visible ones. A failure names the property that hid it.

    Visible by default
    queries.spec.ts
    await expect(map).toHaveFeature({ layer: 'alerts', where: { severity: 'high' } });await expect(map.features({ layer: 'alerts' })).toHaveFeatureCount(3);await expect(map.feature({ layer: 'sensors', id: 501 })).toBeVisibleOnMap();
  2. Failures say what was on the map

    A failed assertion lists what was rendered and why nothing matched. It also attaches the map with every candidate pixel drawn on it, and tells apart a missing layer, a hidden layer, and an off-screen, covered or ambiguous feature.

    Failure messages
    Output
    expect(map).toHaveFeature({"layer":"alerts","where":{"severity":"critical"}})Expected: at least one visible feature matching, within 7000msReceived: 0 match(es)What is on the map:  view: {"center":{"lng":34.78,"lat":32.08},"zoom":12, ...}  layer "alerts": 3 feature(s) rendered in viewport    - alerts#3 {"id":3,"name":"Noise complaint","severity":"low"} @(363.5,428.7)    - alerts#2 {"id":2,"name":"Road closure","severity":"medium"} @(538.3,256.9)    - alerts#1 {"id":1,"name":"Gas leak","severity":"high"} @(421.8,325.6)    "severity" wanted "critical"; rendered values: "low", "medium", "high"
  3. Touch and keyboard

    tap() is verified like click(). Pinch, two-finger rotate, one-finger pan and double tap go through the Chrome DevTools Protocol, so they are Chromium only. Keys go to the focused map.

    Touch and keyboard
    touch.spec.ts
    test.use({ hasTouch: true });const here = { lng: 34.78, lat: 32.08 };await map.feature({ layer: 'alerts', id: 2 }).tap();await map.pinchAt(here, { scale: 2 });await map.twoFingerRotateAt(here, { degrees: 40 });await map.keyboard.press('Shift+ArrowRight');
  4. Markers and popups

    DOM markers and popups come back as normal Playwright Locators. toHavePopup checks that a popup is open and anchored on the feature. Feature actions never click through a marker.

    Markers and popups
    popups.spec.ts
    const hq = await map.marker({ at: { lng: 34.84, lat: 32.12 } });await hq.click();await map.feature({ layer: 'alerts', id: 1 }).click();await expect(map).toHavePopup({ feature: { layer: 'alerts', id: 1 }, text: 'Gas leak' });
  5. Drawing and geometry

    Draw helpers are input choreography, not a binding to one tool, and are tested against terra-draw and mapbox-gl-draw. toHaveGeometry compares the full geometry the map's source holds, in pixels.

    Geometry and drawing
    drawing.spec.ts
    await page.getByTestId('draw-polygon').click();await map.draw.polygon([a, b, c, d], { finish: 'click-first' });const drawn = map.feature({ layer: 'td-polygon' });await expect(drawn).toHaveGeometry([a, b, c, d], { tolerancePx: 2 });
  6. Vector tiles without a tile server

    Real MVT tiles are cut from a GeoJSON fixture inside page.route. The app keeps its production {z}/{x}/{y}.pbf URL and code path.

    Vector tiles without a server
    tiles.spec.ts
    import { routeVectorTiles } from 'geowright/tiles';await routeVectorTiles(page, '**/vt/buildings/**', { buildings: buildingsGeoJSON });await page.goto('/');await expect(map).toHaveFeature({ layer: 'buildings' });

How feature interactions work

A map can draw a feature without making it usable. geowright checks the conditions that matter before sending input.

What an action waits for
One intended feature
A strict query resolves exactly one match by layer, ID, properties, or location. Ambiguity produces a diagnostic, not a guess.
A usable action point
The target must be visible, stable, and reachable. An overlapping feature or page element can block the click.
Real browser input
Playwright sends the pointer input. The target is checked again when the press arrives; geowright never calls map.fire().
An observable result
Retry assertions against features, popups, geometry, or the camera until the expected map state appears.

Add geowright to your test suite

Register your map in a test build. Then use the same feature queries and assertions inside Playwright.

map.ts · Vite
if (import.meta.env.MODE !== 'production') {
  (window.__geowright_maps ??= {}).main = map;
}
Open the setup guide
  1. 1

    Expose the map in your test build

    Add the hook beside map creation, guarded for test or development builds. Nothing is imported into your app.

  2. 2

    Prepare Playwright

    The initializer supplies a fixture and the software-WebGL setup, plus a first spec.

  3. 3

    Make one useful assertion

    Query a feature, send real input, and check what changed. Replace the example data with your own.

Compatibility and testing evidence

Recorded coverage, with the limits alongside it.

Figures from the project's design notes.
ScopeRecorded evidenceWhat that means
Other applications33 apps · 135 specsMapLibre examples and React, Vue, and Svelte wrapper apps. Test subjects, not customer endorsements.
Version compatibilityMapLibre 2–6
Mapbox 1.13
The core matrix covers actions and assertions. Globe, terrain, touch, and drawing have narrower coverage.
Repeated execution6,980 executions · 0 retries
349 tests × 20 repeats
0 flakes, 0 retries across 20 repeats of the complete suite; 13 opt-in tests skipped. The suite has passed this bar repeatedly as it grew.

The design notes include the failures that led to fixes, along with what remains unresolved.

Read the evidence in context

Limits, stated plainly

What geowright cannot do, or does only partly, from its README.

All limits

Questions

Does anything ship in my app?

No. The app registers its map with one line and imports nothing; the adapter is injected by Playwright at test time. Keep that line out of production builds: it makes the map instance reachable by every script on the page.

The app hook
Does it call map.fire()?

No. Actions are real input, page.mouse at projected pixels, and the adapter uses the map library's public API only.

What an action waits for
Which map libraries and versions?

MapLibre GL JS 2–6 and Mapbox GL JS 1.13. Globe, terrain, touch, drawing tools and the third-party apps are checked on MapLibre 6 only. Mapbox GL JS v2 and later are not tested.

Supported versions
Which browsers and Playwright versions?

Tested on Chromium with software WebGL. @playwright/test 1.49 or later: CI runs the full suite on 1.49.0 and on the current version.

Limits
Does it work with React, Svelte or Vue wrappers?

Yes: one line in the wrapper's load callback, for react-map-gl, @vis.gl/react-maplibre, svelte-maplibre-gl and vue-maplibre-gl. npx geowright init prints the form your wrapper needs.

The one line, per wrapper
Can a browser agent use it?

Through Playwright MCP's --init-script: a text snapshot lists each feature with a verified clickable pixel, and an accessibility mirror makes features clickable buttons.

Agents (Playwright MCP)

Write your first map test

Expose the map in a test build, prepare Playwright, then assert on one feature. The guide walks through each step.