DocsChangelog

Changelog

Notable changes, newest first.

Notable changes to geowright, newest first. While the version is 0.x a minor version may change the API; such changes are marked BREAKING. The reasoning behind each behaviour is in docs/DESIGN.md.

0.1.1 — 2026-10-25

  • Refuse globe locations near the limb when projection round-tripping shows the click would land more than about one degree away.
  • geowright init --help and -h show init usage; geowright --version prints the package version.
  • Wrapper app hook validation matches recorded files by relative path, so an unrecorded file with the same basename is rejected.
  • Add npm keywords and clarify the package description for discoverability.

0.1.0 — 2026-10-24

The first published version.

Assert and act on a WebGL map from Playwright: MapLibre GL JS 2, 3, 4, 5, 6 and Mapbox GL JS 1.13 (the support table in the README says what is checked on which version). The app needs one line to register the map. It imports nothing, and nothing ships with it. In TypeScript, /// <reference types="geowright/app" /> makes that line compile as written (it adds types only).

  • Assert with retrying matchers (.not supported): toHaveFeature, toHaveFeatureCount, toBeVisibleOnMap, toHaveLayerPixels (raster, image, canvas, video, heatmap, hillshade), toHaveGeometry (the full geometry from the source, not the tile-clipped one; where the source cannot give it back, a tiled one, a mismatch against the one drawn piece is no verdict), toHavePopup, toHaveView. A verdict of absence (a .not, a count, "not visible") is taken only from a map that has settled: idle, or seen to be one that never becomes idle with nothing else still loading. A matcher that cannot reach a verdict fails whichever way it is written, and says why: the map is not registered or was removed, a named layer does not exist (to assert that a layer was removed, check map.layers()), several features match where one is required, the camera is moving (toHavePopup), the map was never registered (toHaveView), no pixel of the feature's geometry hit-tests back to it (toBeVisibleOnMap: a feature drawn away from its geometry, such as a label with text-offset; an action on it fails with not-found). A removed or 0×0 map is an error for features(q).count() and all(), never an empty map. On a page too busy to answer, a matcher, map.marker() and map.popup() fail at their timeout with "the page did not answer" (the pixel probes of toBeVisibleOnMap and toHaveLayerPixels are the exception: they wait for the page).
  • Query by layer, layerType, source (for layer ids a wrapper generates), id, where (equality, RegExp, eq ne gt gte lt lte in matches exists; any other value, such as an array, an unknown operator or an operator object with none in it, is an error, and so is an operand of the wrong kind: in that is not an array, exists that is not a boolean, a bound that is not a number, matches / flags that is not a string, undefined), at a location (one on the far side of the globe or behind a mountain matches nothing). By default, queries match only what a user can SEE. The style's paint properties are evaluated for each feature, as the renderer draws them (a zoom-and-data expression at the tile zoom): opacity 0, a transparent colour and zero size do not count as visible. A layer's visibility (including a visibility expression, MapLibre 5.16+), minzoom and maxzoom are respected. visible: 'any' opts out. Features need no ids: identity is id + properties, and look-alikes are told apart by where they are drawn. id is the id in the app's data: a GeoJSON id that MapLibre draws as another number (a string such as a UUID, a negative or fractional number) is still found; on a tiled source, where only promoteId can give a feature such an id, a query by one is an error that says so.
  • Act with real input only (page.mouse / keyboard / touchscreen, CDP for multi-touch, never map.fire()): click, dblclick, rightClick, hover, tap on a feature; the same at a location; dragTo, dragBy, dragAt, wheelAt, keyboard.press; pinchAt, twoFingerRotateAt, touchDragAt, doubleTapAt (Chromium). Draw helpers (polygon, line, rectangle, circle, cancel) take explicit finish gestures and are tested with terra-draw and mapbox-gl-draw.
  • Actionability: before any input, the target must be the only match, painted, still (the same pixel two frames running, and its source's data not replaced in between: a source the app re-sets on every frame is never at rest, even standing still — it needs moving: 'allow'), under the pointer, the topmost visible hit, and not covered by a DOM element. A raster, image, video, canvas, heatmap, hillshade, color-relief, background or custom layer (deck.gl's interleaved layers are custom layers; MapLibre 3 and later) drawn above the target at that pixel is checked with a pixel probe of the target's own layer: an opaque one refuses the action and is named, also while it changes colour by itself (pixels that change between the probe's reads are no evidence that the target shows through; README, Limits); a translucent one, or a transparent gap in an opaque one, lets it through (ignoreLayers skips the probe; moving: 'allow' is not checked). Every action probes anew. A heatmap, which MapLibre 5 and later hit-test over its whole radius, covers only by its pixels, as on every other version. For click, dblclick, rightClick and tap, the hit test runs again when the press arrives and must find the same feature, still painted (not a look-alike that slid under the pointer, not one made invisible since the aim); a late press is swallowed, a tap's touch events included, and retried. A hover is verified the same way as the pointer arrives (nothing is swallowed: the app saw the move, as it would a person's, and the action aims again). A press that reached the app is never sent again: not when the app re-creates its map in the click handler, not when the click navigates, not when the input call itself throws (the action ends with that error), not when the app stops the press before the check can see it (the action ends "sent once, not verified"), and not when the press was let through but the target was no longer under the pointer at the click event that followed, where MapLibre decides what was clicked (the app moved the map on the press, a moving target moved on): the hit test runs again there, and the action ends "sent once … the app's click landed there, not on the target". Options: force, ignoreLayers, autoPan, modifiers, and moving: 'allow' for a feature that never stops moving, on click, dblclick, rightClick, tap and hover (the pointer leads it; it can time out, but it never presses another feature, and for dblclick that holds for its first press only. It is checked at the press, and MapLibre decides its layer click at click: a fast target can move off in between, and then the action reports success while the app's click missed the target). dragTo, dragBy, lngLat and the location actions (clickAt, tapAt, …; their options are SteadyActionOptions) refuse moving, in their types and at run time: they have no check when the input arrives. Works on rotated, pitched, globe and terrain maps, inside a container scaled with CSS transform: scale(), and in an open or closed shadow root (a web component). A location on the far side of the globe or behind a mountain is an error, never a click on something else; so is a location outside the browser window on a map larger than it (force included; autoPan may bring it in). A location just across the antimeridian is the one in the copy of the world drawn in view (seen from lng 179.5, lng -179.8 is 0.7° east), and reported longitudes are in [-180, 180].
  • When it fails, it says what WAS on the map: the features per layer with their properties, the values of the property you asked about, and the reason. Reasons: not rendered, layer hidden, off-screen, occluded (naming the occluder), ambiguous, or not visible. For "not visible" it names the style property, its value, the expression branch that produced it and the zoom (fill-opacity is 0 because the match on ["get","kind"] took its fallback for kind="park"). A failure also attaches a screenshot with every pixel it tried marked on it, and every successful action records the pixel it used.
  • waitForIdle(), and when the map never becomes idle, why: an animated source, a source re-set in a loop, continuous repainting, tiles still loading, a moving camera, or a dead MapLibre web worker. Timeouts follow the test's own timeout (actions a third of it, at least 10 s; waitForIdle() two thirds; matchers use Playwright's expect timeout). An action's timeout is wall-clock until its input is sent (finding its pixel, arming click() / tap()'s check, an autoPan pan, lngLat(), and its failure report), even on a page too busy to answer. Real input is never abandoned half-way: once it is sent, a feature's click() and tap(), and hover() with moving: 'allow', wait for the page to say where it landed — until the action's timeout: a page that has not said by then makes the action fail after input ("sent once, and the page did not say where it landed"), and it is never sent again. An action with several points (a drag, a shape) has one timeout for all of them. waitForIdle({ timeout }) is wall-clock too. When a page answered the earlier attempts and only the last one ran past the deadline, the failure is what it answered (slow, not stalled). The first call on a page also injects the engine: every time budget the page is given (an idle wait, an action's look, a pixel probe) is sized after that, from the time actually left, and an abandoned first call says that the engine was missing, what the page answered and when, and how long the call itself had. autoPan pans once per action. As in Playwright, timeout: 0 means no timeout (a pixel probe then gets 5 s a look).
  • DOM markers and popups as normal Locators (map.marker(), map.popup()), pinned to their element. They are found inside the map's container, also when a web component hosts the map in its shadow root.
  • Typings: expect is typed from @playwright/test alone (so it keeps its matcher types under pnpm and Yarn PnP), every type a public signature names can be imported from 'geowright' (LayerInfo, SnapshotOptions, WhereValue, ActionPointOptions, PixelPoint, and GeowrightMatchers for merging expects), and types / typesVersions let resolvers that do not read exports find geowright, geowright/tiles and geowright/app.
  • Vector tiles without a tile server: routeVectorTiles() / vectorTileHandler() (geowright/tiles) cut real MVT tiles from GeoJSON inside page.route, at any zoom: above maxZoom (default 14) a tile is cut at that detail, so a source without maxzoom keeps its features (setting maxzoom on the source is still cheaper). Tile URLs may end in @2x and any extension (.pbf, .mvt, .vector.pbf), and may be on another origin with tile requests that carry credentials (credentials: 'include'). { scheme: 'tms' } serves a source that declares scheme: 'tms' (rows counted from the south); the default is 'xyz', and the option must match the source.
  • Browser agents (Playwright MCP): a text snapshot of the map with clickable pixels and evaluated colours, and an accessibility mirror that makes features clickable buttons (dist/inpage.js, dist/inpage-agent.js, --init-script). On a basemap with 100 layers, the snapshot shrinks the basemap first so that the app's own features stay listed. The snapshot does not probe pixels: a feature under a raster, image or other layer the hit test cannot see is listed with under-pixel-layer:<ids> (EntityView blocked), since that layer may hide it there.
  • npx geowright init writes a fixture, the software-WebGL flags and a first spec that prints the snapshot. It also prints the hook for your wrapper (react-map-gl, @vis.gl/react-maplibre, svelte-maplibre-gl, vue-maplibre-gl). A whole playwright.config is written only where there is none; where there is one, init prints what to add to it (with projects there, the flags go in the use of its Chromium projects; into the launchOptions a use has already), unless it launches with them already, writes where its projects look for tests (each project's own testDir and testMatch, else the config's, as Playwright reads them), and warns when no project would find the first spec, or says where it cannot tell (a testDir from a variable, a config that spreads another). A maplibre-gl or mapbox-gl range outside the tested versions gets a warning. --dir and --url take --dir e2e or --dir=e2e; a flag without its value is an error, not a guess. It checks the flags in every Chromium project’s effective launch options, including project overrides; unused objects do not count. Escaped config strings are reported as unreadable rather than used to guess a test directory or collection match.
  • Every runnable example in the README and docs/RECIPES.md is type-checked and runs in the test suite.
  • Tested against 33 apps its authors did not write: 23 MapLibre examples, plus 10 apps built on react-map-gl, svelte-maplibre-gl, vue-maplibre-gl, deck.gl's MapboxOverlay and PMTiles (wild/). If geowright does not work on your app, wild/README.md ("Bring your app") shows how it becomes one of them. Known limitations: docs/DESIGN.md, "Costs and known limitations".
  • Apache-2.0. The injected scripts bundle MapLibre's style-spec evaluator and three small libraries; their licenses ship as dist/THIRD_PARTY_LICENSES.txt. In the repository, the third-party code and fonts under wild/ and demo/ (the test apps' builds, their fixtures, the demo's MapLibre build and glyphs) have their license texts beside them.

Requires Node 22.12 or newer (the package is ES modules only; require() of it works without a flag from 22.12) and @playwright/test 1.49 or newer (peer dependency). CI runs the full suite on Chromium with Playwright 1.49.0 and with the current version.