Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 42 additions & 8 deletions lib/chrome/coverage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
142 changes: 142 additions & 0 deletions test/unittests/coverageTest.js
Original file line number Diff line number Diff line change
@@ -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
);
});
Loading