browsertime
Version:
Get performance metrics from your web page using Browsertime.
539 lines (477 loc) • 16.3 kB
JavaScript
import { promisify } from 'node:util';
import { unlink as _unlink, rm as _rm } from 'node:fs';
import path from 'node:path';
import { logging } from 'selenium-webdriver';
import intel from 'intel';
import usbPowerProfiler from 'usb-power-profiling/usb-power-profiling.js';
const log = intel.getLogger('browsertime.chrome');
const { Type } = logging;
import { longTaskMetrics } from '../longTaskMetrics.js';
import { parseCPUTrace } from '../parseCpuTrace.js';
import { getHar } from '../har.js';
import { getEmptyHAR, mergeHars } from '../../support/har/index.js';
import { toArray } from '../../support/util.js';
import { logging as _logging } from 'selenium-webdriver';
import { parse } from '../traceCategoriesParser.js';
import { pathToFolder } from '../../support/pathToFolder.js';
import { ChromeDevtoolsProtocol } from '../chromeDevtoolsProtocol.js';
import { NetworkManager } from '../networkManager.js';
import { Android, isAndroidConfigured } from '../../android/index.js';
import { getRenderBlocking } from './traceUtilities.js';
const unlink = promisify(_unlink);
const rm = promisify(_rm);
export class Chromium {
constructor(storageManager, options) {
this.options = options;
this.chrome = options.chrome || {};
this.skipHar = options.skipHar || false;
this.logPerfEntries = !!this.chrome.collectPerfLog;
this.collectTracingEvents =
this.chrome.traceCategories || this.chrome.timeline;
this.baseDir = storageManager.directory;
this.storageManager = storageManager;
// We keep track of all alias and URLs
this.aliasAndUrl = {};
this.isTracing = false;
// Keep the HAR file for all runs
this.hars = [];
this.androidTmpDir = '/data/local/tmp/';
this.testStartTime = undefined;
}
/**
* Before the brrowser is started.
*/
async beforeBrowserStart() {
this.events = [];
if (isAndroidConfigured(this.options)) {
this.android = new Android(this.options);
if (this.options.androidPower) {
await this.android.startPowerTesting();
}
if (this.chrome.collectNetLog) {
// The file needs to exist for netlog to work
await this.android._runCommand(
`touch ${this.androidTmpDir}chromeNetlog.json`
);
}
}
}
/**
* The browser is up and running, now its time to start to
* configure what you need.
*/
async afterBrowserStart() {
// We create the cdp as early as possible so it can be used in scripts
// https://bugs.chromium.org/p/chromium/issues/detail?id=824626
// https://github.com/cyrus-and/chrome-remote-interface/issues/332
if (isAndroidConfigured(this.options)) {
await this.android.addDevtoolsFw();
if (this.options.androidUsbPower) {
usbPowerProfiler.startSampling();
}
}
this.cdpClient = new ChromeDevtoolsProtocol(this.options);
return this.cdpClient.setup();
}
/**
* Before the first iteration of your tests start.
*/
async beforeStartIteration(runner) {
// Here we setup everything we need using CDP
if (this.options.injectJs) {
await this.cdpClient.injectJavaScript(this.options.injectJs);
}
if (this.options.block) {
await this.cdpClient.blockUrls(this.options.block);
}
if (this.options.cacheClearRaw) {
await this.cdpClient.clearBrowserCache();
await this.cdpClient.clearBrowserCookies();
}
if (this.options.clearCacheKeepCookies) {
await this.cdpClient.clearBrowserCache();
}
if (this.options.basicAuth) {
await this.cdpClient.setBasicAuth(this.options.basicAuth);
}
if (this.options.requestheader) {
await this.cdpClient.setRequestHeaders(this.options.requestheader);
}
if (this.chrome.CPUThrottlingRate && this.chrome.CPUThrottlingRate > 1) {
await this.cdpClient.setupCPUThrottling(this.chrome.CPUThrottlingRate);
}
if (this.chrome.appendToUserAgent || this.options.appendToUserAgent) {
const currentUserAgent = await runner.runScript(
'return navigator.userAgent;',
'GET_USER_AGENT'
);
await this.cdpClient.setUserAgent(
currentUserAgent +
' ' +
(this.chrome.appendToUserAgent || this.options.appendToUserAgent)
);
} else if (this.chrome.mobileEmulation && this.options.userAgent) {
// Hack to set user agent for mobile emulation
await this.cdpClient.setUserAgent(this.options.userAgent);
}
await this.cdpClient.loadingFailed();
// Make sure we clear the console log
// Hopefully one time is enough?
return runner.getLogs(Type.BROWSER);
// remove irrelevant entries from performance log
// and since Chromedriver 2.29 there's a bug
// https://bugs.chromium.org/p/chromedriver/issues/detail?id=1811
// that can be fixed by emptying the logs twice :|
// await runner.getLogs(Type.PERFORMANCE);
// await runner.getLogs(Type.PERFORMANCE);
}
/**
* Before each URL/test runs.
*/
async beforeEachURL(runner, url) {
// remove irrelevant entries from performance log
// and since Chromedriver 2.29 there's a bug
// https://bugs.chromium.org/p/chromedriver/issues/detail?id=1811s
await runner.getLogs(Type.PERFORMANCE);
await runner.getLogs(Type.PERFORMANCE);
if (this.options.cookie && url) {
await this.cdpClient.setCookies(url, this.options.cookie);
} else if (this.options.cookie) {
log.info('Could not set cookie because the URL is unknown');
}
if (this.android) {
if (this.options.androidPower) {
await this.android.resetPowerUsage();
} else if (this.options.androidUsbPower) {
await usbPowerProfiler.resetPowerData();
}
}
if (
this.collectTracingEvents &&
!this.isTracing &&
this.options.chrome.timelineRecordingType !== 'custom'
) {
this.isTracing = true;
return this.cdpClient.startTrace();
}
this.testStartTime = Date.now();
}
/**
* When the page has finished loading, this functions runs (before
* collecting metrics etc). This is the place to get you HAR file,
* stop trace logging, stop measuring etc.
*
*/
async afterPageCompleteCheck(runner, index, url, alias) {
const result = { url, alias };
if (
this.collectTracingEvents &&
this.isTracing &&
this.options.chrome.timelineRecordingType !== 'custom'
) {
// We are ready and can stop collecting events
this.isTracing = false;
this.events = await this.cdpClient.stopTrace();
}
// We get the long tasks early because on slow devices
// we want to make usre our own script isnt't picked up
let longTaskScript = `
const minLength = ${this.options.minLongTaskLength || 50};
const observer = new PerformanceObserver(list => {});
observer.observe({type: 'longtask', buffered: true});
const entries = observer.takeRecords();
const cleaned = [];
for (let event of entries) {
if (event.duration >= minLength) {
const e = {};
e.duration = event.duration;
e.name = event.name;
e.startTime = event.startTime;
e.attribution = [];
for (let at of event.attribution) {
const a = {};
a.containerId = at.containerId;
a.containerName = at.containerName;
a.containerSrc = at.containerSrc;
a.containerType = at.containerType;
e.attribution.push(a);
}
cleaned.push(e);
}
}
return cleaned;
`;
this.longTaskInfo = await runner.runScript(
longTaskScript,
'GET_LONG_TASKS'
);
if (this.android) {
if (this.options.androidPower) {
result.power = await this.android.measurePowerUsage(
this.chrome.android.package
);
} else if (this.options.androidUsbPower) {
result.power = await this.android.measureUsbPowerUsage(
this.testStartTime,
Date.now()
);
await this.android.getUsbPowerUsageProfile(
index,
url,
result,
this.options,
this.storageManager
);
}
}
if (this.options.verbose >= 2 || this.chrome.enableChromeDriverLog) {
await this.storageManager.gzip(
`${this.baseDir}/chromedriver.log`,
`${this.baseDir}/chromedriver-${index}.log.gz`,
true
);
}
if (this.chrome.collectNetLog && !this.chrome.android) {
await this.storageManager.createSubDataDir(
path.join(pathToFolder(result.url, this.options))
);
await this.storageManager.gzip(
`${this.baseDir}/chromeNetlog.json`,
path.join(
this.baseDir,
pathToFolder(result.url, this.options),
`chromeNetlog-${index}.json.gz`
)
);
}
if (this.chrome.collectConsoleLog) {
if (!result.extraJson) {
result.extraJson = {};
}
result.extraJson[`console-${index}.json`] = await runner.getLogs(
_logging.Type.BROWSER
);
}
if (!this.skipHar) {
this.hars.push(
await getHar(
runner,
result,
index,
this.cdpClient,
this.logPerfEntries,
this.chrome.includeResponseBodies,
this.chrome.mobileEmulation,
this.android,
this.chrome,
this.aliasAndUrl
)
);
}
if (this.chrome.cdp && this.chrome.cdp.performance) {
const rawCDPMetrics = await this.cdpClient.getPerformanceMetrics();
const cleanedMetrics = {};
let ns, fmp;
for (let m of rawCDPMetrics.metrics) {
switch (m.name) {
case 'DomContentLoaded':
case 'Timestamp': {
// skip
break;
}
case 'FirstMeaningfulPaint': {
fmp = m.value;
break;
}
case 'NavigationStart': {
ns = m.value;
break;
}
default: {
cleanedMetrics[m.name] = m.name.includes('Duration')
? m.value * 1000
: m.value;
}
}
}
cleanedMetrics['FirstMeaningfulPaint'] = (fmp - ns) * 1000;
result.cdp = { performance: cleanedMetrics };
}
return result;
}
/**
* The URL/test is finished, all metrics are collected.
*/
async afterEachURL(runner, index, result) {
// When we move long tasks to run direct after the test end
// we still want to keep the old structure
// this hack fixes that
if (result.browserScripts && result.browserScripts.pageinfo) {
result.browserScripts.pageinfo.longTask = this.longTaskInfo;
}
if (this.chrome.collectNetLog && this.chrome.android) {
// THIS needs to be unique per page
const filename = path.join(
this.baseDir,
pathToFolder(result.url, this.options),
`chromeNetlog-${index}.json`
);
const gzFilename = path.join(
this.baseDir,
pathToFolder(result.url, this.options),
`chromeNetlog-${index}.json.gz`
);
await this.android.pullNetLog(filename);
await this.storageManager.gzip(filename, gzFilename, true);
}
if (
this.collectTracingEvents &&
this.options.chrome.timelineRecordingType !== 'custom'
) {
const trace = parse(this.events, result.url);
const name = this.options.enableProfileRun
? `trace-${index}-extra-run.json`
: `trace-${index}.json`;
result.extraJson[name] = trace;
const cpu = await parseCPUTrace(trace, result.url);
result.cpu = cpu;
// Collect render blocking info
const render = await getRenderBlocking(trace);
result.renderBlocking = render.renderBlocking;
if (!this.options.skipHar) {
for (let harRequest of this.hars[index - 1].log.entries) {
if (render.renderBlockingInfo[harRequest.request.url]) {
harRequest._renderBlocking =
render.renderBlockingInfo[harRequest.request.url];
}
}
}
result.renderBlocking.requests = render.renderBlockingInfo;
}
// Google Web Vitals hacksery
result.googleWebVitals = {};
if (result.browserScripts.pageinfo) {
result.googleWebVitals.cumulativeLayoutShift =
result.browserScripts.pageinfo.cumulativeLayoutShift;
}
if (result.browserScripts.timings) {
if (result.browserScripts.timings.ttfb) {
result.googleWebVitals.ttfb = result.browserScripts.timings.ttfb;
}
if (result.browserScripts.timings.largestContentfulPaint) {
result.googleWebVitals.largestContentfulPaint =
result.browserScripts.timings.largestContentfulPaint.renderTime ||
result.browserScripts.timings.largestContentfulPaint.loadTime;
}
if (
result.browserScripts.timings.paintTiming &&
result.browserScripts.timings.paintTiming['first-contentful-paint']
) {
result.googleWebVitals.firstContentfulPaint =
result.browserScripts.timings.paintTiming['first-contentful-paint'];
result.googleWebVitals.firstInputDelay = result.browserScripts.timings
.firstInput
? result.browserScripts.timings.firstInput.delay
: 0;
}
if (result.browserScripts.timings.interactionToNextPaint) {
result.googleWebVitals.interactionToNextPaint =
result.browserScripts.timings.interactionToNextPaint;
}
// Add LCP to the HAR
if (
result.browserScripts.timings.largestContentfulPaint &&
result.browserScripts.timings.largestContentfulPaint.tagName ===
'IMG' &&
!this.options.skipHar
) {
for (let harRequest of this.hars[index - 1].log.entries) {
if (
harRequest.request.url ===
result.browserScripts.timings.largestContentfulPaint.url
) {
harRequest._isLCP = true;
}
}
}
}
// lets take the time and format the CPU long tasks
if (
result.browserScripts.pageinfo &&
result.browserScripts.pageinfo.longTask
) {
const cpuData = longTaskMetrics(result, this.options);
if (result.cpu) {
result.cpu.longTasks = cpuData.longTasks;
} else {
result.cpu = cpuData;
}
result.googleWebVitals.totalBlockingTime =
cpuData.longTasks.totalBlockingTime;
}
}
/**
* Get the CDP client (used by scripting) for browsers that supports it.
*/
getCDPClient() {
return this.cdpClient;
}
/**
* THis method is called if a runs fail
*/
failing(url) {
if (this.skipHar) {
return;
}
this.hars.push(getEmptyHAR(url, this.options.browser));
}
/**
* Before the browser is stopped/closed.
*/
async beforeBrowserStop() {
if (this.chrome.collectNetLog && !this.chrome.android) {
const netlog = `${this.baseDir}/chromeNetlog.json`;
await unlink(netlog);
}
if (this.android && this.options.androidPower) {
await this.android.stopPowerTesting();
}
if (this.cdpClient) {
await this.cdpClient.close();
}
}
/**
* Before the browser is stopped/closed.
*/
async afterBrowserStopped() {
// If we supply a user data dir on desktop, we want to clean up before each start
if (
this.chrome.args &&
this.chrome.cleanUserDataDir &&
!this.chrome.android
) {
const args = toArray(this.chrome.args);
for (let arg of args) {
if (arg.includes('user-data-dir')) {
const userDataDir = arg.split('=')[1];
try {
await rm(userDataDir, { recursive: true });
log.info(`Deleted user data dir: ${userDataDir}`);
} catch (error) {
log.error(`Could not delete user data dir: ${userDataDir}`, error);
}
}
}
}
}
async getHARs() {
return this.skipHar ? {} : { har: mergeHars(this.hars) };
}
async setCookies(url, cookies) {
return this.cdpClient.setCookies(url, cookies);
}
async waitForNetworkIdle() {
let network = new NetworkManager(this.cdpClient, this.options);
return network.waitForNetworkIdle();
}
}