DEV Community

Aayush Paudel
Aayush Paudel

Posted on Edited on AI-assisted

My debugging tool was reporting lines that never ran. Here's how V8 coverage fooled me.

**TL;DR:* my tool used V8 coverage to show which lines a click ran, and it was marking some lines as "ran" that never ran. Lines holding only } or ; were the culprit. Here's why, and the fix.

I've been building Lightpath, a tool that clicks a button in your React app and tells you which lines ran, in the browser and on the Node server, plus the lines that didn't run.

The whole point of a tool like that is trust. If it says a line ran, you go read that line. If it says a line didn't run, you go look for why.

So it was a bad day when I found out it was sometimes wrong.

[YOUR LINE: how you found it and how it felt. e.g. "I was checking a trace by hand and saw a line marked as run that I knew couldn't have run. My stomach dropped a bit."]

How it gets "what ran"

V8 already counts this for you. Over the Chrome DevTools Protocol (and the Node inspector) you can turn on precise coverage:

await cdp.send('Profiler.enable');
await cdp.send('Profiler.startPreciseCoverage', { callCount: true, detailed: true });
Enter fullscreen mode Exit fullscreen mode

Then Profiler.takePreciseCoverage gives you, for every function, a list of byte ranges with a count. Simplified, it looks something like this:

function label   [0, 180)   count 1
  block          [40, 70)   count 0
  block          [95, 125)  count 1
Enter fullscreen mode Exit fullscreen mode

Taking coverage also resets the counters. So one read right before the click and one right after gives you exactly that click's code. No instrumentation, no code changes to the app.

The ranges are bytes, not lines. So the last step is turning byte ranges into line numbers (and then through source maps back to your .ts files). That step is where I went wrong.

The bug

Here's a small function:

function label(user) {        // 1
  if (user.admin) {           // 2
    return 'admin';           // 3
  } else if (user.guest) {    // 4
    return 'guest';           // 5
  }                           // 6
  return 'member';            // 7
}                             // 8
Enter fullscreen mode Exit fullscreen mode

Call it with an admin. Lines 1–3 ran. Line 4 never ran: the else if condition is never even checked.

My tool said line 4 ran.

I wrote a set of tiny test apps where I listed by hand which lines should run, and compared them with what the tool reported. The same mistake showed up in a few places:

  • an untaken } else if (…) {
  • the body and the break; of an untaken case in a switch
  • the untaken arm of a multi-line ternary or &&

Why it happened

V8's block ranges don't start and end where you'd guess from reading the code.

A line like } else if (user.guest) { starts with a } that closes the block above it. A line like break; or ); holds only punctuation. In V8's ranges, characters like these often fall outside the inner block's range, so they land in the range around it. In my example, that's the whole function, which has a count of 1.

My rule for "what is this line's count" looked at the code on the line. When all it found was a }, a ; or a ), it took the count of the bigger range around it. So the line looked like it ran.

I even measured one: the range for case 'apple': … cents = 50; was [1332, 1388) with count 0, and the break right after it sat in the function's range, with count 1.

The fix: a line that holds only }, ), ; or a trailing comment doesn't get to decide its own count. It's treated as punctuation, and the line takes the count of the code it actually belongs to.

The second bug: lines after a throw

This one was sneakier:

function save(order) {
  validate(order);     // throws
  db.insert(order);    // never ran
  return order.id;     // never ran
}
Enter fullscreen mode Exit fullscreen mode

V8 counts the function as entered once. There's no separate counter after validate(order), so from V8's point of view, the whole body has count 1. The tool said all four lines ran.

There's no way to know from the coverage alone. So now Lightpath doesn't pretend: lines after a call that threw are marked unknown, with the line that threw. It doesn't call them run, and it doesn't call them not run.

What I took from it

  1. Coverage ranges are about bytes, not lines. Mapping one to the other is where bugs hide.
  2. Hand-written expected results catch what unit tests don't. I now run a small Node app and a Vite + React app, and compare every line against a list I wrote by hand (pnpm accuracy in the repo). Most of the new regression tests failed on the old code.
  3. "Unknown" is a valid answer. A tool that says "I can't tell" is more useful than one that guesses confidently.

The fixes are in Lightpath 0.1.2 and later.

Try it

If you have a React + Node app:

npx @lightpath-dev/cli dev -- npm run dev
Enter fullscreen mode Exit fullscreen mode

Click around, and each click prints the code path in the terminal. There's also a VS Code extension, and an MCP server so coding agents like Claude Code or Cursor can click and read the trace themselves.

It's free, runs locally, and it's MIT: github.com/Unknown-333/Lightpath

If you've worked with V8 coverage and hit other weird cases, I'd love to hear about them in the comments. And if the tool is useful to you, a ⭐ on the repo helps a solo project get found.

Top comments (0)