From e5c661ea9f8fdc1d1753cc09dc7c5a1ee93adfbc Mon Sep 17 00:00:00 2001 From: Peter Hedenskog Date: Sat, 23 May 2026 16:29:26 +0200 Subject: [PATCH] Add JS/CSS coverage collection for Chrome Coverage is exposed two ways. --chrome.coverage runs it on every iteration for users who want the data and accept that detailed V8 coverage deoptimizes scripts and will skew their timing metrics. --enableProfileRun also turns coverage on for Chrome, alongside the existing trace, so users who want coverage without affecting their timings can lean on the same extra-iteration pattern they already use for tracing. When only the profile run produces coverage, the result is merged back into the main browsertime.json so consumers like sitespeed.io see the data in the same place they read every other metric. The per-iteration result includes a per-file breakdown (url, totalBytes, usedBytes, unusedBytes, unusedPercent), so the "which file should I tree-shake" question is answerable from the main JSON alone. Co-authored-by: Claude noreply@anthropic.com Change-Id: I28526039a799d0efa5f1f4da81d6dfa66d1ec8c3 --- .github/workflows/linux-chrome.yml | 9 ++ bin/browsertime.js | 34 ++++- lib/chrome/coverage.js | 198 +++++++++++++++++++++++++++++ lib/chrome/webdriver/chromium.js | 14 ++ lib/core/engine/collector.js | 8 ++ lib/support/cli.js | 16 ++- 6 files changed, 274 insertions(+), 5 deletions(-) create mode 100644 lib/chrome/coverage.js diff --git a/.github/workflows/linux-chrome.yml b/.github/workflows/linux-chrome.yml index 91397cff0..afcf9c34c 100644 --- a/.github/workflows/linux-chrome.yml +++ b/.github/workflows/linux-chrome.yml @@ -167,6 +167,15 @@ jobs: run: ./bin/browsertime.js -b chrome -n 1 --xvfb test/data/commandscripts/scrollToBottom.cjs - name: Test extra profile run Chrome run: ./bin/browsertime.js -b chrome http://127.0.0.1:3000/simple/ -n 1 --viewPort 1000x600 --xvfb --enableProfileRun + - name: Test Chrome JS/CSS coverage + run: | + ./bin/browsertime.js -b chrome -n 1 --xvfb --skipHar --chrome.coverage --resultDir /tmp/coverage http://127.0.0.1:3000/simple/ + echo "--- coverage block ---" + jq '.[0].coverage' /tmp/coverage/browsertime.json + jq -e '(.[0].coverage.js | type) == "array" and (.[0].coverage.js | length) > 0' /tmp/coverage/browsertime.json + jq -e '(.[0].coverage.css | type) == "array" and (.[0].coverage.css | length) > 0' /tmp/coverage/browsertime.json + jq -e '.[0].coverage.js[0] | has("totalBytes") and has("usedBytes") and has("unusedBytes") and has("unusedPercent") and has("files")' /tmp/coverage/browsertime.json + jq -e '.[0].coverage.css[0] | has("totalBytes") and has("usedBytes") and has("unusedBytes") and has("unusedPercent") and has("files")' /tmp/coverage/browsertime.json - name: Test Chrome soft navigation detection run: | ./bin/browsertime.js -b chrome -n 1 --xvfb --spa --resultDir /tmp/soft-nav test/data/commandscripts/softNavigation.cjs diff --git a/bin/browsertime.js b/bin/browsertime.js index 527e29a9a..255598f67 100755 --- a/bin/browsertime.js +++ b/bin/browsertime.js @@ -95,12 +95,16 @@ async function run(urls, options) { scriptsByCategory = merge(scriptsByCategory, userScripts); } + let result; + let storageManager; + let jsonName; + try { if (options.preWarmServer) { await preWarmServer(urls, options); } await engine.start(); - const result = await engine.runMultiple(urls, scriptsByCategory); + result = await engine.runMultiple(urls, scriptsByCategory); let saveOperations = []; // TODO setup by name @@ -109,9 +113,9 @@ async function run(urls, options) { if (Array.isArray(firstUrl)) { firstUrl = firstUrl[0]; } - const storageManager = new StorageManager(firstUrl, options); + storageManager = new StorageManager(firstUrl, options); const harName = options.har ?? 'browsertime'; - const jsonName = options.output ?? 'browsertime'; + jsonName = options.output ?? 'browsertime'; saveOperations.push(storageManager.writeJson(jsonName + '.json', result)); @@ -169,6 +173,7 @@ async function run(urls, options) { options.chrome.traceCategory = [ 'disabled-by-default-v8.cpu_profiler' ]; + options.chrome.coverage = true; } } if (options.enableVideoRun) { @@ -186,9 +191,30 @@ async function run(urls, options) { } const traceEngine = new Engine(options); await traceEngine.start(); - await traceEngine.runMultiple(urls, scriptsByCategory); + const profileResult = await traceEngine.runMultiple( + urls, + scriptsByCategory + ); await traceEngine.stop(); log.info('Extra run finished'); + + // Fill the main JSON's coverage from the profile run only when + // the main iterations didn't collect any (--enableProfileRun + // without --chrome.coverage). Otherwise the main run's + // per-iteration samples are what the user asked for. + let mergedCoverage = false; + for (const [i, pr] of profileResult.entries()) { + const main = result[i]; + if (!main || !pr.coverage) continue; + if (main.coverage.js.length > 0 || main.coverage.css.length > 0) { + continue; + } + main.coverage = pr.coverage; + mergedCoverage = true; + } + if (mergedCoverage) { + await storageManager.writeJson(jsonName + '.json', result); + } } } catch (error) { log.error('Error running browsertime', error); diff --git a/lib/chrome/coverage.js b/lib/chrome/coverage.js new file mode 100644 index 000000000..063f77139 --- /dev/null +++ b/lib/chrome/coverage.js @@ -0,0 +1,198 @@ +import { getLogger } from '@sitespeed.io/log'; + +const log = getLogger('browsertime.chrome.coverage'); + +// Sum the union length of half-open [start, end) ranges. +function unionLength(ranges) { + if (ranges.length === 0) return 0; + const sorted = ranges + .map(r => [r.startOffset, r.endOffset]) + .toSorted((a, b) => a[0] - b[0]); + let total = 0; + let [curStart, curEnd] = sorted[0]; + for (let i = 1; i < sorted.length; i++) { + const [s, e] = sorted[i]; + if (s <= curEnd) { + if (e > curEnd) curEnd = e; + } else { + total += curEnd - curStart; + curStart = s; + curEnd = e; + } + } + total += curEnd - curStart; + return total; +} + +function summarize(files) { + let totalBytes = 0; + let usedBytes = 0; + for (const file of files) { + totalBytes += file.totalBytes; + usedBytes += file.usedBytes; + } + const unusedBytes = totalBytes - usedBytes; + return { + totalBytes, + usedBytes, + unusedBytes, + unusedPercent: totalBytes > 0 ? (unusedBytes / totalBytes) * 100 : 0 + }; +} + +export class Coverage { + constructor(cdp) { + this.rawClient = cdp.getRawClient(); + this.started = false; + this.styleSheetUrls = new Map(); + this.disposeStyleSheetListener = undefined; + } + + async start() { + const client = this.rawClient; + this.styleSheetUrls.clear(); + this.disposeStyleSheetListener = client.CSS.styleSheetAdded( + ({ header }) => { + if (header && header.styleSheetId) { + this.styleSheetUrls.set( + header.styleSheetId, + header.sourceURL || header.sourceMapURL || '' + ); + } + } + ); + + await client.Debugger.enable(); + await client.Profiler.enable(); + await client.DOM.enable(); + await client.CSS.enable(); + await client.Profiler.startPreciseCoverage({ + callCount: false, + detailed: true, + allowTriggeredUpdates: false + }); + await client.CSS.startRuleUsageTracking(); + this.started = true; + } + + async collect() { + if (!this.started) return; + const client = this.rawClient; + + let js; + let css; + try { + const jsResult = await client.Profiler.takePreciseCoverage(); + js = await this.#processJs(jsResult.result || []); + } catch (error) { + log.warn('Could not collect JS coverage: %s', error.message); + js = { + totalBytes: 0, + usedBytes: 0, + unusedBytes: 0, + unusedPercent: 0, + files: [] + }; + } + + try { + const cssResult = await client.CSS.stopRuleUsageTracking(); + css = await this.#processCss(cssResult.ruleUsage || []); + } catch (error) { + log.warn('Could not collect CSS coverage: %s', error.message); + css = { + totalBytes: 0, + usedBytes: 0, + unusedBytes: 0, + unusedPercent: 0, + files: [] + }; + } + + try { + await client.Profiler.stopPreciseCoverage(); + } catch { + // ignore — collection already returned data + } + + if (this.disposeStyleSheetListener) { + this.disposeStyleSheetListener(); + this.disposeStyleSheetListener = undefined; + } + + this.started = false; + return { js, css }; + } + + async #processJs(scripts) { + const client = this.rawClient; + const files = []; + for (const script of scripts) { + if (!script.url || !/^https?:/i.test(script.url)) { + continue; + } + let source; + try { + const r = await client.Debugger.getScriptSource({ + scriptId: script.scriptId + }); + source = r.scriptSource || ''; + } catch { + continue; + } + 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 unusedBytes = totalBytes - usedBytes; + files.push({ + url: script.url, + totalBytes, + usedBytes, + unusedBytes, + unusedPercent: (unusedBytes / totalBytes) * 100 + }); + } + return { ...summarize(files), files }; + } + + async #processCss(ruleUsage) { + const client = this.rawClient; + const byStylesheet = new Map(); + for (const u of ruleUsage) { + const list = byStylesheet.get(u.styleSheetId) || []; + list.push(u); + byStylesheet.set(u.styleSheetId, list); + } + const files = []; + for (const [styleSheetId, usages] of byStylesheet) { + let text; + try { + const r = await client.CSS.getStyleSheetText({ styleSheetId }); + text = r.text || ''; + } catch { + continue; + } + const totalBytes = text.length; + if (totalBytes === 0) continue; + + const usedBytes = unionLength(usages.filter(u => u.used)); + const unusedBytes = totalBytes - usedBytes; + files.push({ + url: this.styleSheetUrls.get(styleSheetId) || '', + styleSheetId, + totalBytes, + usedBytes, + unusedBytes, + unusedPercent: (unusedBytes / totalBytes) * 100 + }); + } + return { ...summarize(files), files }; + } +} diff --git a/lib/chrome/webdriver/chromium.js b/lib/chrome/webdriver/chromium.js index 1c69d4ffd..cd0f390e0 100644 --- a/lib/chrome/webdriver/chromium.js +++ b/lib/chrome/webdriver/chromium.js @@ -15,6 +15,7 @@ import { parse } from '../traceCategoriesParser.js'; import { pathToFolder } from '../../support/pathToFolder.js'; import { loadUsbPowerProfiler } from '../../support/usbPower.js'; import { ChromeDevtoolsProtocol } from '../chromeDevtoolsProtocol.js'; +import { Coverage } from '../coverage.js'; import { NetworkManager } from '../networkManager.js'; import { Android, isAndroidConfigured } from '../../android/index.js'; import { getRenderBlocking } from './traceUtilities.js'; @@ -181,6 +182,11 @@ export class Chromium { } } + if (this.chrome.coverage) { + this.coverage = new Coverage(this.cdpClient); + await this.coverage.start(); + } + if ( this.collectTracingEvents && !this.isTracing && @@ -325,6 +331,14 @@ export class Chromium { ); } + if (this.coverage) { + const coverage = await this.coverage.collect(); + this.coverage = undefined; + if (coverage) { + result.coverage = coverage; + } + } + if (this.chrome.cdp && this.chrome.cdp.performance) { const rawCDPMetrics = await this.cdpClient.getPerformanceMetrics(); const cleanedMetrics = {}; diff --git a/lib/core/engine/collector.js b/lib/core/engine/collector.js index 64da41c2a..690565f60 100644 --- a/lib/core/engine/collector.js +++ b/lib/core/engine/collector.js @@ -39,6 +39,7 @@ function getNewResult(url, options) { markedAsFailure: 0, failureMessages: [], cdp: { performance: [] }, + coverage: { js: [], css: [] }, android: { batteryTemperature: [], power: [] }, timestamps: [], browserScripts: [], @@ -185,6 +186,13 @@ export class Collector { }); } + // Only available for Chrome with --chrome.coverage or + // --enableProfileRun. + if (data.coverage) { + results.coverage.js.push(data.coverage.js); + results.coverage.css.push(data.coverage.css); + } + this._logIterationMetrics(data, url); if (data.visualMetrics) { diff --git a/lib/support/cli.js b/lib/support/cli.js index fbf65707d..322f03a3a 100644 --- a/lib/support/cli.js +++ b/lib/support/cli.js @@ -373,6 +373,20 @@ export function parseCommandLine() { 'If you use --user-data-dir as an argument to Chrome and want to clean that directory between each iteration you should use --chrome.cleanUserDataDir true.', group: 'chrome' }) + .option('chrome.coverage', { + type: 'boolean', + default: false, + describe: + 'Collect JavaScript and CSS code coverage on every iteration. ' + + 'Per-iteration summaries with a per-file breakdown land in the ' + + 'per-URL result as coverage.js[] and coverage.css[]. This has a ' + + 'real performance cost: detailed JS coverage causes V8 to ' + + 'deoptimize and will skew timing metrics like LCP, TBT and INP. ' + + 'Use --enableProfileRun instead if you want coverage without ' + + 'affecting your timing data - it collects coverage during the ' + + 'extra profile iteration only.', + group: 'chrome' + }) .option('cpu', { type: 'boolean', describe: @@ -651,7 +665,7 @@ export function parseCommandLine() { .option('enableProfileRun', { type: 'boolean', describe: - 'Make one extra run that collects the profiling trace log (no other metrics is collected). For Chrome it will collect the timeline trace, for Firefox it will get the Geckoprofiler trace. This means you do not need to get the trace for all runs and can skip the overhead it produces.' + 'Make one extra run that collects the profiling trace log (no other metrics is collected). For Chrome it will collect the timeline trace and JavaScript/CSS coverage, for Firefox it will get the Geckoprofiler trace. This means you do not need to get the trace for all runs and can skip the overhead it produces.' }) .option('enableVideoRun', { type: 'boolean',