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 + ); +});