The grey-box model
Why geowright reads the map's public state but acts only through real input, and why the app registers its map with one line.
A MapLibre map renders to a single WebGL canvas. Finding an element, reading its text, clicking it, waiting for it to appear: everything a Playwright suite is good at stops working once the thing under test is a pixel inside that canvas.
Two gaps
That leaves two gaps in an otherwise ordinary end-to-end suite:
- Assert: after driving the app's normal UI, verify that something was added to, changed on or removed from the map. A marker appeared, a layer's data changed, the camera moved to the right place.
- Act: perform actions that start on the map itself. Click a feature, right-click a location, drag a vertex, draw a shape.
Read the state, act like a person
geowright closes both gaps with a small engine injected into the page and a Playwright binding, and takes a grey-box stance to do it. It reads the map's public state (rendered features, paint properties, camera, layer list) to decide what to press and what to expect. Every action it performs is real input: page.mouse and CDP touch and keyboard events at real screen pixels, dispatched the way a person's input reaches the browser.
It never calls map.fire() or synthesizes a DOM or map event. CDP's own Input domain, which Playwright's mouse and keyboard already use, is fine; anything that skips the browser's input pipeline is not. A synthetic event would skip the app's own event handling, and that is part of what a test has to exercise.
Public API only
The adapter uses MapLibre's public API only: no private fields, no internal render flags, nothing that could change without notice in a minor release. Even deciding whether the map is still is built from public surface: isMoving() and a pixel sampled on two frames.
All of it sits behind one seam. The engine that matches queries, chooses where to press and writes the failure messages imports nothing from any map library. Everything library-specific is a method on the adapter, most of them optional, so an adapter can leave out what its library cannot do. Newer methods are detected when they are there, never assumed from a version number; that is how one engine runs on MapLibre GL JS 2–6 and Mapbox GL JS 1.13.
One line in the app
Getting hold of the map instance comes first, and for a bundled app it cannot be found from outside. MapLibre GL JS 6 ships as ES modules only: there is no global to intercept, the Map class is a module-private binding, and the map's container element holds no reference back to the map. A test in geowright's own suite checks this against a real bundled app. It looks for a library global, walks every property of window and the container's DOM for a way back to the map, and finds only the explicit hook.
So the app registers its map with one line:
const map = new maplibregl.Map({ … });if (import.meta.env.MODE !== 'production') (window.__geowright_maps ??= {}).main = map;Nothing is imported, so the app ships no test code and never depends on geowright. The adapter and engine are injected by the test runner, again after every navigation. With a wrapper library the line moves to wherever the wrapper hands back the map, usually a load callback: the one line, per wrapper.
A wrapper's load callback fires on MapLibre's load event, which needs every declared source. If one data request fails, the map is never registered: “no map registered” says where to look.
Test and development builds only. The line makes the live map instance reachable by any script on the page, so it never ships to production.
Read more
- The app hookThe line, its TypeScript declaration, and keeping it out of production builds.
- How an action is verifiedWhat is checked before real input is sent, and when it arrives.
- Supported versionsWhat runs on MapLibre GL JS 2–6 and Mapbox GL JS 1.13.