DEV Community

Seth Wheeler
Seth Wheeler

Posted on Originally published at sethwheeler.dev

What a Browser Extension's Test Suite Cannot Reach

Longshot is a Firefox screenshot extension I wrote to replace FireShot: full page, visible area, drag region and element capture, an editor with eleven annotation tools, export to PNG, JPEG, WebP and PDF, and local OCR that produces a searchable text layer. It has no runtime dependencies. The code is not public, so this is a description rather than an invitation to read it.

At one point it had 130 passing Node assertions across six suites, zero failing. Printing could not open a dialog at all.

Not "printed the wrong thing". The print command hung indefinitely and no dialog ever appeared. The suites did not go amber, or flake, or report a warning. They reported 130 passed, 0 failed, which is what they had reported the day before and what they would have gone on reporting.

Why nothing caught it

printCanvas encoded each slice of the image to a blob URL and awaited img.decode(). That call does not resolve for an image inside a display:none subtree, and the print stylesheet creates exactly such a subtree by design, since the container has to be hidden on screen. So the await never returned, and the dialog never opened.

Every part of that failure is a meeting point between my code and the browser: the decode promise's behaviour, the stylesheet's effect on the subtree, and the ordering between them. None of it is reachable by a function you can call from Node. The six suites test band arithmetic, canvas dimension limits, filename sanitising, the background module graph under stubbed extension APIs, PDF structure and scan geometry. All of that is worth testing and none of it goes near a real DOM.

The second bug in the same batch has the same shape one level in. Choosing PDF broke "Open in editor", because deliver() handed the editor the PDF blob and createImageBitmap cannot decode one. That is not a browser boundary; it is one internal stage handing another something it cannot accept. Both stages were tested; the seam between them was not.

That is the pattern worth naming. Every bug this project has produced lived at a boundary, either mine to a browser API or one of my stages to another, and a suite made of pure functions cannot represent a boundary at all. It is not that the coverage was too low; it is that the thing being covered was the wrong shape.

The fix I nearly missed

printCanvas now appends canvas elements directly instead of encoding slices to blob URLs. A canvas is painted synchronously, so there is nothing to load, nothing to decode, and no object URLs to revoke.

The point is that this removes the failure class rather than working around it. The obvious fix was to keep the images and stop awaiting decode(), or to move the container out of the hidden subtree until decoding finished. Either would have made printing work, and both would have left a component whose correctness depended on a promise resolving inside a display:none subtree, which is a fact about Firefox I would have had to keep being right about. Removing the decode step means there is no longer a question to get wrong.

Choosing between those two is the part I would not have got right without knowing why it failed. A green suite that goes green again is not evidence of which fix you applied.

The harness, and its control

test-pages/harness.html runs the options page and the print pipeline against stubbed extension APIs: storage backed by a plain object, window.print intercepted, so the real page logic runs unmodified. Thirteen checks; it found the total print failure on its first run.

The part I want to underline is not the thirteen checks. It is that the harness was verified against an injected hang, which it caught and reported as "timed out after 8s".

Without that step the harness is worth roughly what the Node suites were worth. A test that has never been shown to fail is indistinguishable from a test that cannot fail; a harness for a hang is especially prone to this, because the failure mode it exists to catch is the one most likely to make it silently do nothing. Every check is bounded by a timeout so a hang is reported rather than truncating the run, and the imports are cache-busted, because a stale cached module once made an already-fixed file look broken and cost more time than the bug had.

The OCR harness is built the same way and is the clearest example of what a boundary test can assert that a unit test cannot. It renders known text, recognises it, and checks the result against that ground truth: 13 of 13 words, mean confidence 95, digits included. Then it checks that the word boxes land where the text was drawn, with "fox" at y=40 against a rendered 40 and "dog" at y=121 against 120, which matters because a mis-scaled box highlights the wrong row when someone searches the PDF. Then it feeds those boxes through the PDF writer and inflates the resulting content stream to confirm the invisible layer is real: text render mode 3 present, all 13 words emitted, visible image still drawn. The file validates independently as "PDF document, version 1.7, 1 pages".

Every one of those assertions is about what something downstream received, not about what my function returned.

What is still unreachable, and I would rather say so

The capture engines have no browser harness. They are the largest and most browser-coupled part of the extension, they choose between two strategies per page based on how the displayport behaves, and they are currently covered only by the same shape of pure-function test that missed the print bug. That is the identical blind spot, still open, in the component with the most surface against the browser.

Two other things are unverified and worth stating rather than leaving to be assumed. The email draft is a mailto: navigation I could not trigger without hijacking a real mail client. And for printing, I have confirmed the pipeline builds correct sheets and opens the dialog, not what comes out of a printer.

What generalises

The useful question after a green suite is not "what percentage of lines did this cover" but "could this suite represent the bug I am most afraid of". Mine could not represent a promise that never resolves, a stylesheet interacting with a decode call, or one stage handing another an object it cannot read, and those are three of the three bugs the project produced.

The cheap version of the check is to ask where your code stops and something you did not write begins, then count how many of your tests have anything to say about that line. In my case the answer was none of the 130, and the fix was not more assertions of the same kind but eight fewer of a different kind, in a place where a real DOM and a real canvas exist.

And whichever kind you write, make one of them fail on purpose before you trust it.

Top comments (0)