Masking Live Traffic Incident Markers
An incident marker looks like a single element and behaves like five. The icon and the position come from the incident record and are stable. The reported time, the elapsed duration and any severity that escalates with age are all functions of the clock. The queue-position badge is a function of how many other incidents happen to be open. Reaching for a mask over the whole callout makes all five go away at once, including the two the test was written to check. This procedure separates them and masks only what genuinely cannot be made deterministic.
This is a task within Live Data Overlay Stabilization, under Dynamic Element Masking & UI Stability. Any mask it does end up justifying belongs in the manifest described in Interactive Overlay Masking Rules.
Prerequisites
Step-by-step procedure
1. Classify every property the marker renders
Walk the marker’s template and put each rendered value into one of three buckets: stable, clock-derived, or window-derived. This takes ten minutes and determines everything that follows.
2. Rewrite record timestamps rather than masking their labels
A reported-time label, an elapsed-duration string and an age-escalating severity colour are all deterministic once the record’s timestamp sits at a fixed offset from the frozen epoch.
const OFFSETS_MIN = [3, 12, 40, 95]; // straddles the escalation thresholds
incidents.forEach((incident, i) => {
incident.reportedAt = EPOCH - OFFSETS_MIN[i % OFFSETS_MIN.length] * 60_000;
});
Choosing offsets that cross the escalation boundaries is what makes the capture exercise the severity styling instead of accidentally testing one branch of it.
3. Fix the window so the queue badge is deterministic
A badge showing “3 of 17 open” varies with the feed window, not with the clock. The fix is at the fixture rather than at the marker: a fixed set of incidents produces a fixed count, and the badge becomes an assertion rather than noise.
4. Size any remaining mask to the volatile text alone
If a value genuinely cannot be made deterministic — a third-party estimated-clearance time, say — mask the text and nothing else.
await page.screenshot({
mask: [page.locator('.incident-callout .clearance-estimate')],
maskColor: '#ff00ff',
});
A garish, fixed mask colour is deliberate: it is instantly recognisable in a review, and a fixed colour cannot itself introduce a diff.
5. Prefer a placeholder over a mask where the geometry is fixed
If the volatile string always has the same shape — a duration like 14 min, a distance like 2.3 km — substituting a fixed value of the same form keeps the label’s layout, wrapping and collision behaviour under test while removing the variance.
await context.addInitScript(() => {
const original = window.formatClearance;
window.formatClearance = () => '14 min';
});
6. Assert the incident count before capturing
As with any data-driven layer, the count is the cheap guard that catches a dropped record, an over-eager expiry and a parse failure, none of which a pixel tolerance will separate from noise.
7. Record every surviving mask with a reason and an expiry
A mask added here is a permanent blind spot until someone removes it. The manifest entry names what is masked, why nothing narrower worked, who owns it, and when it should be revisited — which is what makes the quarterly audit able to ask whether the third-party field is still third-party.
8. Keep the marker template honest about what it derives
The classification in step 1 is only reliable if the marker’s template makes its derivations visible. Two habits keep it that way, and both are worth adopting in the application rather than in the test.
Compute derived values in one place and pass them in as props, rather than calling Date.now() inside the render path. A template that reads the clock in four places has four independent things to freeze and no obvious list of them; a template that receives ageMinutes as a prop has one. The refactor is small and it makes the marker unit-testable as well as capturable.
Give every volatile field its own class name. This sounds trivial and it is what decides whether a mask can be narrow. A callout whose duration, clearance estimate and queue badge all render inside anonymous spans forces any mask to cover the whole callout, because there is nothing narrower to select. Adding three class names converts a mask that removes the icon and the geometry into one that removes twenty pixels of text.
Both changes tend to be resisted as “changes to production code for the benefit of tests”, which they are, and they are also both improvements on their own terms: fewer clock reads in a render path is better code, and semantically named fields are better markup. The visual suite is simply the thing that made the cost of not having them visible.
Verification
Confirm the procedure worked before wiring it into a blocking gate:
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Every incident renders at the same severity | The rewritten offsets all fall inside one escalation band, so the styling has only one branch exercised | Choose offsets that deliberately cross each threshold, as in step 2 |
| The mask covers the icon as well as the text | The selector matched the callout container rather than the volatile field | Target the field’s own class; if it has none, add one — it is a smaller change than the coverage the wide mask costs |
| The queue badge still varies between runs | The badge counts incidents in the feed, not in view, so trimming the fixture to the camera did not fix it | Fix the whole incident set rather than the visible subset, and assert the total the badge reports |
Frequently asked questions
Why not mask the whole callout and assert the marker position separately?
Because the separate assertion rarely gets written, and when it does it checks a projected coordinate rather than what the user sees. The callout’s rendered geometry — its size, its leader line, whether it flipped to avoid the viewport edge — is genuinely visual, and it is exactly the kind of thing that breaks silently. Keeping it in the comparison costs nothing once the text inside it is deterministic.
What mask colour should be used?
Something that cannot occur in the map: a saturated magenta is the conventional choice. The two properties that matter are that it is fixed, so the mask itself never contributes a diff, and that it is obvious in a review, so a reviewer looking at a failing capture immediately understands which regions were excluded rather than wondering why an area shows no change.
Do masks need to be applied at capture or at comparison?
Both, driven by the same manifest, for the reason set out in Interactive Overlay Masking Rules: a runner without native masking still produces a comparable result if the comparator zeroes the same rectangles. Applying at only one of the two produces failures that reproduce on some runners and not others.
How do I stop the mask list growing forever?
The expiry date in the manifest, and an audit that reports expired entries as failures rather than warnings. In practice a third of masks turn out to be workarounds for determinism gaps that were fixed elsewhere months earlier, and nothing but a scheduled prompt ever causes anyone to check.
Related
- Up to Live Data Overlay Stabilization, and the section Dynamic Element Masking & UI Stability.
- Interactive Overlay Masking Rules — the manifest a surviving mask is recorded in.
- Masking CSS Selector Regions for Map Popups — computing a stable box for a coordinate-anchored callout.
- Freezing Map Time with Playwright addInitScript — the clock pin that makes the timestamp rewrite meaningful.