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-marker when markers carry dynamic hover states, and disable .mapboxgl-ctrl-group pointer events.
  • Leaflet attaches tooltips to .leaflet-tooltip and re-renders them on mousemove; disable container pointer events during capture with map.getContainer().style.pointerEvents = 'none'.
  • OpenLayers manages overlays through ol.Overlay instances—iterate them and call overlay.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",
});
Routing a cursor or tooltip overlay to the right mask by rendering substrate A top-to-bottom decision flow. A volatile pointer cursor or hover tooltip enters a substrate decision: DOM node versus canvas or WebGL draw. The DOM branch applies a scoped stylesheet with visibility hidden plus pointer-events none and a cursor default reset. The canvas branch proxies drawImage and drawArrays behind a test flag or projects geographic bounds to screen pixels and clips the rectangle. Both branches converge on a gate that awaits the map idle anchor, after which a single deterministic capture surface with the native mask backstop yields only stable geographic pixels. Pointer cursor / hover tooltip volatile overlay entering the frame Drawn where? substrate check DOM overlay path scoped stylesheet visibility:hidden + pointer-events:none cursor:default reset Canvas / WebGL path proxy drawImage / drawArrays behind the test flag or project bounds + clip rect DOM node context draw Await map “idle” anchor overlays settled · no re-inject Deterministic capture + native mask stable geographic pixels only

Verification

Confirm the masking held before trusting the baseline:

  1. Run a generation pass that deliberately dispatches mousemove and hover events over markers, then inspect the artifact for residual cursor glyphs or tooltip fragments.
  2. Diff two consecutive captures of the same view with the comparator set to threshold: 0.0 and antialiasing: 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 before idle.
  3. 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.
  4. Verify the proxied draw methods were restored: assert CanvasRenderingContext2D.prototype.drawImage is 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.