How to Mask Dynamic User Cursors and Tooltips in Map Tests
Custom cursors, hover-triggered tooltips, and dynamic popups shift pixel positions, change z-index stacking, and inject variable content during a synthetic test run. When they intersect tile rendering, vector layers, or raster overlays they corrupt the baseline and trigger false-positive failures. This page is the exact procedure for neutralizing those two volatile overlay categories—pointer cursors and hover tooltips—so the comparator only ever sees stable geographic pixels.
This task is one concrete rule set within Interactive Overlay Masking Rules, the page that defines how to bind a masking target to a strategy and a lifecycle anchor. The broader contract between deterministic rendering and non-deterministic UI lives in the parent reference, Dynamic Element Masking & UI Stability.
Prerequisites
Step-by-Step Procedure
1. Pin the environment and lock the viewport
Cursor and tooltip artifacts are amplified by DPR scaling and GPU rasterization drift. Before any masking is meaningful, force a fixed scale factor and a non-interactive map so no synthetic pointer can spawn a hover state mid-capture.
const context = await browser.newContext({
viewport: { width: 1280, height: 800 },
deviceScaleFactor: 1,
reducedMotion: "reduce",
});
// Map config: interactive:false stops pointer-driven cursor/tooltip changes.
2. Wait for the map idle lifecycle anchor
Apply masks only after the map’s terminal render event, never during network transit or animation. Masking before idle either captures a mid-transition tooltip or lets the element re-inject after the rule has run. This synchronization gate is the same one described under screenshot capture synchronization.
await page.evaluate(
() => new Promise((resolve) => window.map.once("idle", resolve))
);
3. Mask DOM tooltips and popups with a scoped stylesheet
Tooltips and popups in most libraries are detached DOM nodes. Target them by framework selector and apply visibility: hidden with pointer-events: none—never display: none. Collapsing the box reflows neighbouring nodes and can shift the map container itself, manufacturing the very baseline drift you are trying to avoid.
await page.addStyleTag({
content: `
.mapboxgl-popup, .maplibregl-popup,
.leaflet-popup, .leaflet-tooltip,
.ol-tooltip, .ol-tooltip-box {
visibility: hidden !important;
pointer-events: none !important;
}
`,
});
4. Reset and freeze the cursor
Override the cursor property on every interactive map surface so the OS-level pointer cannot bleed a custom icon into the frame. Inject the rule and also set it inline on the canvas to bypass any cursor caching the browser applies on pointer move.
await page.addStyleTag({
content: `
.map-container, .mapboxgl-canvas, .maplibregl-canvas,
.leaflet-container, .ol-viewport {
cursor: default !important;
}
`,
});
await page.evaluate(() => {
const c = document.querySelector(".mapboxgl-canvas, .maplibregl-canvas");
if (c) c.style.cursor = "auto";
});
5. Intercept canvas- and WebGL-drawn cursors and tooltips
Not every overlay lives in the DOM. Crosshairs, measurement readouts, and highlight halos are drawn straight into the rendering context with requestAnimationFrame, and CSS cannot touch them. Proxy the draw calls during initialization, guard them behind a global test flag, and return early for the offending textures. Always restore the originals after capture to prevent state leaking across the suite.
await page.addInitScript(() => {
window.__VISUAL_TEST_MODE__ = true;
const proto = CanvasRenderingContext2D.prototype;
const realDrawImage = proto.drawImage;
proto.drawImage = function (img, ...rest) {
if (window.__VISUAL_TEST_MODE__ && img?.dataset?.role === "cursor") return;
return realDrawImage.call(this, img, ...rest);
};
});
For overlays you cannot reach this way, project the element’s geographic bounds to screen pixels and clip the rectangle, exactly as the canvas branch of the parent cluster describes.
6. Apply the framework-specific neutralization
Each library names and mounts its overlays differently, so the selectors and the kill switch change:
- Mapbox / MapLibre GL JS render popups as detached nodes appended to the map container; also mask
.mapboxgl-markerwhen markers carry dynamic hover states, and disable.mapboxgl-ctrl-grouppointer events. - Leaflet attaches tooltips to
.leaflet-tooltipand re-renders them onmousemove; disable container pointer events during capture withmap.getContainer().style.pointerEvents = 'none'. - OpenLayers manages overlays through
ol.Overlayinstances—iterate them and calloverlay.setPosition(undefined)before the snapshot.
await page.evaluate(() => {
const m = window.map;
if (m.getOverlays) m.getOverlays().forEach((o) => o.setPosition(undefined));
});
7. Capture with the runner’s native mask as a backstop
Layer the framework-native mask option on top of the manifest so any residual locator is painted over before the screenshot is encoded.
await page.screenshot({
path: "baseline/map-overview.png",
animations: "disabled",
mask: [page.locator(".cursor-readout"), page.locator(".leaflet-tooltip")],
maskColor: "#101820",
});
Verification
Confirm the masking held before trusting the baseline:
- Run a generation pass that deliberately dispatches
mousemoveand hover events over markers, then inspect the artifact for residual cursor glyphs or tooltip fragments. - Diff two consecutive captures of the same view with the comparator set to
threshold: 0.0andantialiasing: false. A clean run produces a zero-pixel delta; any non-zero region over a former tooltip means a selector missed or the mask fired beforeidle. - Check the CI gate is green across all engine/OS/DPR combinations in the matrix—an artifact that passes only on Chromium signals an environment mismatch rather than a fixed mask.
- Verify the proxied draw methods were restored: assert
CanvasRenderingContext2D.prototype.drawImageis the native function after the suite tears down.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Tooltip still appears in the screenshot | Mask injected before the map reached idle, so the tooltip re-rendered afterwards |
Move addStyleTag/addInitScript to fire only after awaiting the terminal idle event |
| Whole map shifted a few pixels between runs | A rule used display: none, reflowing the container |
Switch to visibility: hidden plus pointer-events: none to preserve the bounding box |
| Custom crosshair persists despite CSS rules | Cursor is drawn into the canvas/WebGL context, not the DOM | Proxy drawImage/drawArrays behind the test flag, or project bounds and clip the rectangle |
Frequently Asked Questions
Should I mask a tooltip or just prevent it from opening?
Prevent it when the closed state is what you are testing—launch the map with interactive: false and never dispatch the hover, which gives a real, comparable basemap with no exclusion. Mask it only when the tooltip must stay open for the scenario but its dynamic contents (timestamps, live coordinates) carry no cartographic meaning. Every masked region is a region you are no longer testing.
Why does my cursor reset work locally but fail in CI?
Almost always an environment mismatch: a different GPU rasterization path or DPR. Pin deviceScaleFactor: 1, force CPU rasterization with --use-gl=swiftshader --disable-gpu, and reproduce the failing run inside the same container image before touching the selector. See Noise Reduction for Map Artifacts.
Can I rely on Playwright's native mask option alone?
Only for DOM elements with stable locators. The native mask paints opaque boxes over matched locators, which cleanly handles popups and tooltips, but it cannot reach a cursor drawn into the WebGL context. Use it as the backstop in step 7, with the manifest and the draw-call proxy doing the substrate-specific work.
Related
- Interactive Overlay Masking Rules — the parent rule set this recipe extends, covering the manifest and substrate model.
- Disabling CSS animations for consistent visual baselines — settling tooltips and markers into their final state before masking.
- Marker Cluster Stability — keeping aggregation overlays reproducible across zoom and pan.
- Dynamic Threshold Configuration — stratifying tolerance for the regions that survive masking.
- Dynamic Element Masking & UI Stability — the parent reference for deterministic versus non-deterministic UI.