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
9 changes: 9 additions & 0 deletions .github/workflows/linux-chrome.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 30 additions & 4 deletions bin/browsertime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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));

Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down
198 changes: 198 additions & 0 deletions lib/chrome/coverage.js
Original file line number Diff line number Diff line change
@@ -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 };
}
}
14 changes: 14 additions & 0 deletions lib/chrome/webdriver/chromium.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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 = {};
Expand Down
8 changes: 8 additions & 0 deletions lib/core/engine/collector.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ function getNewResult(url, options) {
markedAsFailure: 0,
failureMessages: [],
cdp: { performance: [] },
coverage: { js: [], css: [] },
android: { batteryTemperature: [], power: [] },
timestamps: [],
browserScripts: [],
Expand Down Expand Up @@ -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) {
Expand Down
16 changes: 15 additions & 1 deletion lib/support/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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',
Expand Down
Loading