Bidemi Ajala 🌍

New York

← Thoughts

A Box Around the Bug

A failed test hands you a full-page screenshot and an assertion error, and leaves you to find the element yourself. So I built a Cypress plugin that boxes it and labels it, and learned a lot about coordinate spaces on the way.

6 min read

A test fails in CI. You open the artifact and you're looking at a screenshot of an entire checkout page, plus an error that says expected '£240.00' to equal '£244.99'. Somewhere in those 1440 pixels is the thing that went wrong, and now it's your job to go find it.

I've done that scan more times than I can count. The information you need is technically all there, and it's still a scavenger hunt every single time. So I built cypress-annotate, which draws a box around the element that broke and writes the reason on it.

The simple version

You point it at a selector and give it a label.

it('flags the broken promo button', () => {
  cy.visit('/checkout');
  cy.annotate('#promo-apply', { label: 'Apply button escapes its card' });
});

You can pass an array and get several boxes in one shot, each with its own label.

A checkout page with two elements boxed in red, one labelled 'Overflows its field' and one labelled 'Escapes its card'
Two problems on one page, each boxed and labelled.

It overwrites the screenshot Cypress just recorded, so the annotated version is the one that lands in cypress/screenshots/, in your reports, and in your CI artifacts. There's no second file to collect and no reporter to wire up.

When you want something you can paste straight into a ticket, crop: true tightens it to the element and dimOutside pushes the rest of the page back.

cy.annotate('#promo-apply', {
  label: 'Apply button escapes its card',
  crop: true,
  cropPadding: 220,
  style: { dimOutside: 0.45 },
});
The Apply button boxed and labelled, cropped tight with the surrounding page dimmed
Cropped and dimmed, ready to drop into a bug report.

The part I actually use

Labelling things by hand is fine when you already know what's wrong. Most of the time you don't, because the test failed while you were asleep.

So there's a hook. Register it once and every failed test annotates itself.

// cypress/support/e2e.ts
import { registerFailureCapture } from 'cypress-annotate/cypress/failure-hook';

registerFailureCapture();

On failure it recovers the selector the failing command was aiming at, measures that element while the page is still sitting in its failed state, scrolls it into view if it needs to, and draws a box labelled from the assertion's own expected and actual values.

A failed assertion on a checkout total, boxed in red and labelled 'Expected THIS WILL NOT MATCH but got £244.99'
Nobody wrote that label. It came out of err.expected and err.actual.

The thing I care about most here is that this costs nothing. No LLM call, no API key in your test job, no per-run bill. The label is built from err.expected and err.actual, and the selector comes from Cypress's own command state with a fallback to the <tag#id> rendering chai-jquery puts in the error message. It's all deterministic, so every CI run captures this for free.

Some failures have no element to point at. An existence check, a cy.contains() chain, a general "the page is in the wrong state" assertion. For those there's nothing honest to box, so it captures a plain screenshot plus a live inventory of candidate elements and writes that to the report. A screenshot on its own carries no DOM information, and by the time anyone looks at it the browser session is long gone. Getting Claude to explain those is a separate step you opt into, which keeps the cost decision with the person reading the report.

The hard part was pixels

I assumed this would take a weekend. Drawing a rectangle is not hard. Drawing it in the right place turned out to be four separate things that all have to agree, and each one is its own chance to be off by a bit.

getBoundingClientRect() gives you viewport coordinates, already accounting for CSS transforms and internally scrolled ancestors. It knows nothing about page scroll or iframes. So scroll offset only applies to full-page captures, and adding it to a viewport capture gives you the classic off-by-one-scroll box floating somewhere below the thing you meant to mark. Iframes add their content origin, which is iframeRect.left + borderLeftWidth + paddingLeft rather than just the frame's position.

Device pixel ratio is the one that surprised me. Cypress scales the app when the window is smaller than the configured viewport, so the screenshot isn't reliably viewport × devicePixelRatio. The engine measures the real ratio off the produced image, and if it disagrees with what the browser claimed by more than 2px it uses the measured value and records a warning.

Then there's the trap that cost me an afternoon. Chromium's full-page capture paints position: fixed elements once, at whatever scroll offset the capture started from. A fixed element has no meaningful document position, so adding scroll to it puts your box thousands of pixels away from the header it was supposed to mark. The engine now detects fixed ancestry while measuring, skips the scroll correction for those elements, and tells you it did.

Proving it, rather than squinting at it

Looking at a screenshot and going "yeah, that looks right" proves nothing, and I didn't want to ship something whose whole value is accuracy on that basis.

So every target in the test fixture is painted a flat, unique colour with no radius or border or shadow, which means the pixels it paints are exactly its border box. The verifier scans the un-annotated capture for that colour, recovers where the element truly is in the image, and compares it against what the engine computed.

Twenty cases pass: device pixel ratios of 1, 2 and 3, CSS transforms, nested iframes with border and padding, internally scrolled containers, below-the-fold targets under both auto-scroll and manual scroll, cropped and full-page captures, and fixed elements under scroll.

Worst drift is 1.13px at dpr 3, and I can tell you exactly where it comes from. #target-top sits at y=147.375 CSS px, so its top edge lands on device pixel 294.75, and row 294 is only partly covered so it fails the colour test. Its x coordinate is a whole number and the horizontal delta is exactly 0.00. That same half-a-device-pixel signature shows up on every case, always on whichever axis has a fractional coordinate. Which is how I know it's the scanner quantising, and not the engine being wrong.

Things that bit me

Chai quotes your values. err.expected and err.actual hold chai's rendering of the value, not the raw value, so .should('have.text', 'X') gives you 'X' with the quote marks baked in. An early version of the label got this wrong and I only caught it by running a real failure and printing the field.

cy.scrollIntoView() always scrolls. Even when the element is already right there in front of you. Calling it unconditionally shoved a perfectly visible element up underneath a fixed header, so the annotation was technically correct and completely invisible. It now measures first and only scrolls when the element genuinely isn't fully in view, leaving clearance when it does.

Reading the code would not have found the packaging bug. I split the Claude reasoner out so that @anthropic-ai/sdk could be an optional peer dependency. It looked clean. But the reasoner shared a file with utility functions that the root barrel re-exports, and an ES module runs a whole file's top-level code the moment you import anything from it. So importing anything from cypress-annotate threw ERR_MODULE_NOT_FOUND for a package you never asked for. What caught it was installing the packed tarball into an empty scratch project with no peers present and importing each entry point for real. CI does that on every push now.

Electron shifts colours on macOS. A fixture painted #FF00E4 comes back as rgb(234,51,221). If you write exact-colour assertions against your own stylesheet in Cypress, this is waiting for you.

Where it is

It's on npm as cypress-annotate, MIT licensed, and sharp is the only thing that installs with it. No browser download, nothing to configure.

npm install --save-dev cypress-annotate

The plugin runs against real Cypress 15 in CI on Electron, Chrome and Firefox. There's also a skill file in the package if you want your coding agent to know how to use it without you explaining the options every time.

I built this because I was tired of hunting through screenshots, and it turned into a much longer education in coordinate spaces than I signed up for. Worth it. The failed test now tells me where to look.