From 5fcc2b109233a835e2df6e39549f3a5eac2745fd Mon Sep 17 00:00:00 2001 From: Peter Hedenskog Date: Mon, 25 May 2026 08:09:19 +0200 Subject: [PATCH] coverage: count dead branches inside executed functions as unused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --chrome.coverage ships V8's detailed (block-level) coverage data, but the JS used-bytes calculation took the union of every range whose count was greater than zero. V8 returns nested ranges: an outer range over the function body and inner ranges over sub-blocks. If a function ran at all, its outer range has count > 0 and already covers the whole body — inner count == 0 ranges (the actual dead branches) get absorbed into the union and contribute nothing. The visible effect on real sites is that any function that executed even once reports zero unused bytes. On modern bundles where module-evaluation top-level code runs for almost every function, the per-script unused-% collapses to roughly zero across the board. Downstream consumers like sitespeed.io render the data faithfully and end up with a "0 B unused" column on pages that obviously ship plenty of dead code, making the feature misleading. Switch to a nested-interval walk: collect every range from every function, sort outer-first (start ascending, length descending), and paint a per-byte flag where each range overwrites the previous. The innermost range's count wins at every byte, which is the same definition Chrome DevTools' Coverage panel uses. Outer "function called" ranges no longer mask inner "branch never taken" ranges, so dead branches inside executed functions show up correctly. CSS rule-usage tracking returns flat, non-overlapping ranges and continues to use unionLength unchanged. Co-authored-by: Claude Opus 4.7 (1M context) noreply@anthropic.com Change-Id: I847673d9859f0e2aec1164c045d7f0b47c4816b5 --- lib/chrome/coverage.js | 50 ++++++++++-- test/unittests/coverageTest.js | 142 +++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 8 deletions(-) create mode 100644 test/unittests/coverageTest.js diff --git a/lib/chrome/coverage.js b/lib/chrome/coverage.js index 063f77139..343c2ba07 100644 --- a/lib/chrome/coverage.js +++ b/lib/chrome/coverage.js @@ -2,7 +2,8 @@ import { getLogger } from '@sitespeed.io/log'; const log = getLogger('browsertime.chrome.coverage'); -// Sum the union length of half-open [start, end) ranges. +// Sum the union length of half-open [start, end) ranges. Used for the +// flat, non-nested ranges that CSS rule-usage tracking returns. function unionLength(ranges) { if (ranges.length === 0) return 0; const sorted = ranges @@ -24,6 +25,45 @@ function unionLength(ranges) { return total; } +// Count used bytes for a script under detailed (block-level) V8 +// coverage. The CDP returns nested ranges: the outermost range is the +// function body and inner ranges are sub-blocks. An inner range with +// count == 0 inside a containing range with count > 0 represents dead +// code in an otherwise-executed function — those bytes must read as +// unused. A naive union of every range whose count > 0 is wrong: the +// outer range alone covers the entire function, so inner count == 0 +// ranges get masked and zero-count branches disappear, making typical +// modern bundles look as if every byte was executed. Walk every range +// across every function from outermost to innermost (start ascending, +// length descending), painting a per-byte coverage flag; inner ranges +// overwrite outer ones, so the final flag at each byte reflects the +// innermost range's count. +export function usedJsBytes(scriptCoverage, totalBytes) { + const ranges = []; + for (const fn of scriptCoverage.functions) { + for (const r of fn.ranges) { + if (r.endOffset > r.startOffset) ranges.push(r); + } + } + if (ranges.length === 0) return 0; + ranges.sort((a, b) => + a.startOffset === b.startOffset + ? b.endOffset - a.endOffset + : a.startOffset - b.startOffset + ); + const used = new Uint8Array(totalBytes); + for (const r of ranges) { + const start = Math.max(0, r.startOffset); + const end = Math.min(totalBytes, r.endOffset); + if (end > start) used.fill(r.count > 0 ? 1 : 0, start, end); + } + let count = 0; + for (let i = 0; i < totalBytes; i++) { + if (used[i] === 1) count++; + } + return count; +} + function summarize(files) { let totalBytes = 0; let usedBytes = 0; @@ -143,13 +183,7 @@ export class Coverage { const totalBytes = source.length; if (totalBytes === 0) continue; - const usedRanges = []; - for (const fn of script.functions) { - for (const r of fn.ranges) { - if (r.count > 0) usedRanges.push(r); - } - } - const usedBytes = unionLength(usedRanges); + const usedBytes = usedJsBytes(script, totalBytes); const unusedBytes = totalBytes - usedBytes; files.push({ url: script.url, diff --git a/test/unittests/coverageTest.js b/test/unittests/coverageTest.js new file mode 100644 index 000000000..cb582a8bb --- /dev/null +++ b/test/unittests/coverageTest.js @@ -0,0 +1,142 @@ +import test from 'ava'; +import { usedJsBytes } from '../../lib/chrome/coverage.js'; + +test('whole function used — single range, count > 0', t => { + t.is( + usedJsBytes( + { + functions: [{ ranges: [{ startOffset: 0, endOffset: 100, count: 5 }] }] + }, + 100 + ), + 100 + ); +}); + +test('whole function unused — single range, count = 0', t => { + t.is( + usedJsBytes( + { + functions: [{ ranges: [{ startOffset: 0, endOffset: 100, count: 0 }] }] + }, + 100 + ), + 0 + ); +}); + +test('function called with dead branch — inner count=0 punches a hole', t => { + // The bug case: the outer range alone would mark the whole function as + // used, masking the inner dead branch. Innermost-wins must keep the + // 30..50 hole. + t.is( + usedJsBytes( + { + functions: [ + { + ranges: [ + { startOffset: 0, endOffset: 100, count: 5 }, + { startOffset: 30, endOffset: 50, count: 0 } + ] + } + ] + }, + 100 + ), + 80 + ); +}); + +test('two sibling functions — one executed, one not', t => { + t.is( + usedJsBytes( + { + functions: [ + { ranges: [{ startOffset: 0, endOffset: 50, count: 3 }] }, + { ranges: [{ startOffset: 50, endOffset: 100, count: 0 }] } + ] + }, + 100 + ), + 50 + ); +}); + +test('nested function never called — overrides containing function', t => { + t.is( + usedJsBytes( + { + functions: [ + { ranges: [{ startOffset: 0, endOffset: 100, count: 5 }] }, + { ranges: [{ startOffset: 30, endOffset: 50, count: 0 }] } + ] + }, + 100 + ), + 80 + ); +}); + +test('nested function rescued from a containing dead branch', t => { + // The containing function ran, but it has a dead branch covering + // 20..60. A nested function whose body is at 35..45 with count > 0 + // must override the dead branch — its bytes are used. + t.is( + usedJsBytes( + { + functions: [ + { + ranges: [ + { startOffset: 0, endOffset: 100, count: 5 }, + { startOffset: 20, endOffset: 60, count: 0 } + ] + }, + { ranges: [{ startOffset: 35, endOffset: 45, count: 2 }] } + ] + }, + 100 + ), + 70 + ); +}); + +test('no functions returns zero', t => { + t.is(usedJsBytes({ functions: [] }, 100), 0); +}); + +test('empty ranges returns zero', t => { + t.is(usedJsBytes({ functions: [{ ranges: [] }] }, 100), 0); +}); + +test('zero-length ranges are ignored', t => { + t.is( + usedJsBytes( + { + functions: [ + { + ranges: [ + { startOffset: 0, endOffset: 100, count: 1 }, + { startOffset: 50, endOffset: 50, count: 0 } + ] + } + ] + }, + 100 + ), + 100 + ); +}); + +test('ranges past totalBytes are clamped', t => { + // A defensive case: V8 returning endOffset > script length must not + // walk off the end of the typed array. + t.is( + usedJsBytes( + { + functions: [{ ranges: [{ startOffset: 0, endOffset: 200, count: 1 }] }] + }, + 100 + ), + 100 + ); +});