browsertime
Version:
Get performance metrics from your web page using Browsertime.
417 lines (370 loc) • 12.6 kB
JavaScript
import { rename as _rename } from 'node:fs';
import { promisify } from 'node:util';
import path from 'node:path';
import intel from 'intel';
import get from 'lodash.get';
import usbPowerProfiler from 'usb-power-profiling/usb-power-profiling.js';
import { adapters } from 'ff-test-bidi-har-export';
import { getEmptyHAR, mergeHars } from '../../support/har/index.js';
import { pathToFolder } from '../../support/pathToFolder.js';
import { isAndroidConfigured, Android } from '../../android/index.js';
import { adjustVisualProgressTimestamps } from '../../support/util.js';
import { findFiles } from '../../support/fileUtil.js';
import { GeckoProfiler } from '../geckoProfiler.js';
import { FirefoxBidi } from '../firefoxBidi.js';
import { MemoryReport } from '../memoryReport.js';
import { PerfStats } from '../perfStats.js';
import { NetworkManager } from '../networkManager.js';
const log = intel.getLogger('browsertime.firefox');
const rename = promisify(_rename);
export class Firefox {
constructor(storageManager, options) {
// Lets keep this and hope that we in the future will have HAR for FF again
this.skipHar = options.skipHar;
this.baseDir = storageManager.directory;
this.storageManager = storageManager;
this.includeResponseBodies = options.firefox
? options.firefox.includeResponseBodies
: 'none';
this.firefoxConfig = options.firefox || {};
this.options = options;
// We keep track of all alias and URLs
this.aliasAndUrl = {};
// This keep the HAR files for all runs
this.hars = [];
this.testStartTime = undefined;
}
/**
* Before the browser is started.
*/
async beforeBrowserStart() {
if (isAndroidConfigured(this.options)) {
this.android = new Android(this.options);
}
if (isAndroidConfigured(this.options) && this.options.androidPower) {
await this.android.startPowerTesting();
}
}
/**
* The browser is up and running, now its time to start to
* configure what you need.
*/
async afterBrowserStart(runner) {
this.windowId = await runner.getDriver().getWindowHandle();
if (!this.options.skipHar) {
this.har = new adapters.SeleniumBiDiHarRecorder({
browsingContextIds: [this.windowId],
debugLogs:
this.options.verbose >= 2 || this.firefoxConfig.enableBidiHarLog,
driver: runner.getDriver()
});
}
this.bidi = await runner.getDriver().getBidi();
this.browsertimeBidi = new FirefoxBidi(
await runner.getDriver().getBidi(),
this.windowId,
runner.getDriver(),
this.options
);
if (isAndroidConfigured(this.options) && this.options.androidUsbPower) {
usbPowerProfiler.startSampling();
}
}
/**
* Get the Bidi client (used by scripting) for browsers that supports it.
*/
getBidi() {
return this.bidi;
}
getWindowHandle() {
return this.windowId;
}
/**
* Before the first iteration of your tests start.
*/
async beforeStartIteration(runner) {
if (this.options.injectJs) {
await this.browsertimeBidi.injectJavaScript(this.options.injectJs);
}
if (this.options.basicAuth) {
await this.browsertimeBidi.setBasicAuth(this.options.basicAuth);
}
if (this.options.requestheader) {
await this.browsertimeBidi.setRequestHeaders(this.options.requestheader);
if (this.options.block) {
await this.browsertimeBidi.blockUrls(this.options.block);
}
if (
this.firefoxConfig.appendToUserAgent ||
this.options.appendToUserAgent
) {
const currentUserAgent = await runner.runScript(
'return navigator.userAgent;',
'GET_USER_AGENT'
);
let script = `Services.prefs.setStringPref('general.useragent.override', '${currentUserAgent} ${
this.firefoxConfig.appendToUserAgent || this.options.appendToUserAgent
}');`;
return runner.runPrivilegedScript(script, 'SET_USER_AGENT');
}
}
}
/**
* Before each URL/test runs.
*/
async beforeEachURL(runner, url) {
await runner.runPrivilegedScript(`
new Promise(async function(resolve) {
await Services.fog.testFlushAllChildren(); // force any data that wasn't recorded yet to be immediately put in the buffers
Services.fog.testResetFOG(); // empty the buffers
resolve();
});
`);
if (!this.skipHar) {
await this.har.startRecording();
}
if (isAndroidConfigured(this.options)) {
if (this.options.androidPower) {
await this.android.resetPowerUsage();
} else if (this.options.androidUsbPower) {
await usbPowerProfiler.resetPowerData();
}
}
if (
this.firefoxConfig.geckoProfiler &&
this.firefoxConfig.geckoProfilerRecordingType !== 'custom'
) {
this.geckoProfiler = new GeckoProfiler(
runner,
this.storageManager,
this.options
);
await this.geckoProfiler.start();
}
if (this.options.cookie && url) {
await this.browsertimeBidi.setCookie(url, this.options.cookie);
} else if (this.options.cookie) {
log.info('Could not set cookie because the URL is unknown');
}
if (this.firefoxConfig.perfStats) {
this.perfStats = new PerfStats(runner, this.firefoxConfig);
return this.perfStats.start();
}
this.testStartTime = Date.now();
}
/**
* When the page has finsihed 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 (isAndroidConfigured(this.options)) {
if (this.options.androidPower) {
result.power = await this.android.measurePowerUsage(
this.firefoxConfig.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.firefoxConfig.geckoProfiler &&
this.firefoxConfig.geckoProfilerRecordingType !== 'custom'
) {
await this.geckoProfiler.stop(index, url, result);
}
if (this.firefoxConfig.perfStats) {
result.perfStats = await this.perfStats.collect();
await this.perfStats.stop();
}
if (this.firefoxConfig.memoryReport) {
const memoryReport = new MemoryReport(
runner,
this.storageManager,
this.firefoxConfig,
this.options
);
result.memory = await memoryReport.collect(index, url);
}
const useFirefoxAppConstants = get(
this.options || {},
'firefox.appconstants',
false
);
if (useFirefoxAppConstants === true) {
const appConstantsScript = `const { AppConstants } = ChromeUtils.import(
'resource://gre/modules/AppConstants.jsm'
);
return AppConstants;`;
this.appConstants = await runner.runPrivilegedScript(
appConstantsScript,
'APP_CONSTANTS'
);
}
let powerusage = await runner.runPrivilegedScript(`
return new Promise(async function(resolve) {
Services.fog.initializeFOG(); // prevents timeout when collecting CPU metrics
await Services.fog.testFlushAllChildren(); // force data from child processes to be sent to the parent.
resolve(Glean.power.totalCpuTimeMs.testGetValue());
});
`);
if (powerusage) {
log.info('CPU / Power usage: ' + powerusage);
}
result.cpu = powerusage;
if (this.skipHar) {
if (result.alias && !this.aliasAndUrl[result.alias]) {
this.aliasAndUrl[result.alias] = result.url;
}
return result;
} else {
const har = await this.har.stopRecording();
if (har.log.pages.length > 0) {
// Hack to add the URL from a SPA
if (result.alias && !this.aliasAndUrl[result.alias]) {
this.aliasAndUrl[result.alias] = result.url;
har.log.pages[0]._url = result.url;
} else if (result.alias && this.aliasAndUrl[result.alias]) {
har.log.pages[0]._url = this.aliasAndUrl[result.alias];
} else {
har.log.pages[0]._url = result.url;
}
}
this.hars.push(har);
return result;
}
}
/**
* The URL/test is finished, all metrics are collected.
*/
async afterEachURL(runner, index, result) {
if (this.appConstants) {
result.browserScripts.browser.appConstants = this.appConstants;
}
result.googleWebVitals = {};
if (
result.browserScripts.timings &&
result.browserScripts.timings.largestContentfulPaint
) {
result.googleWebVitals.largestContentfulPaint =
result.browserScripts.timings.largestContentfulPaint.renderTime ||
result.browserScripts.timings.largestContentfulPaint.loadTime;
}
if (
result.browserScripts.timings &&
result.browserScripts.timings.paintTiming &&
result.browserScripts.timings.paintTiming['first-contentful-paint']
) {
result.googleWebVitals.firstContentfulPaint =
result.browserScripts.timings.paintTiming['first-contentful-paint'];
}
}
async postWork(index, results) {
for (const result of results) {
if (this.firefoxConfig.collectMozLog) {
const files = await findFiles(this.baseDir, 'moz_log.txt');
for (const file of files) {
await rename(
`${this.baseDir}/${file}`,
path.join(
this.baseDir,
pathToFolder(result.url, this.options),
`${file}-${index}.txt`
)
);
}
}
if (this.firefoxConfig.geckoProfiler && this.options.visualMetrics) {
const profileFilename = `geckoProfile-${index}.json.gz`;
const profileSubdir = pathToFolder(result.url, this.options);
try {
const geckoProfile = JSON.parse(
await this.storageManager.readData(profileFilename, profileSubdir)
);
if (!geckoProfile.meta) {
geckoProfile.meta = {};
}
// Here we are calculating the unix timestamp of the first frame after orange screen
// by adding the time to first frame, time to orange screen to the unix timestamp of
// video recording start time
const firstFrameStartTime =
result.recordingStartTime +
result.videoRecordingStart +
result.timeToFirstFrame;
for (let progress of [
'VisualProgress',
'ContentfulSpeedIndexProgress',
'PerceptualSpeedIndexProgress'
]) {
// You can configure to not use content and perceptual.
if (result.visualMetrics[progress]) {
result.visualMetrics[progress] = adjustVisualProgressTimestamps(
result.visualMetrics[progress],
geckoProfile.meta.startTime,
firstFrameStartTime
);
}
}
geckoProfile.meta.visualMetrics = result.visualMetrics;
await this.storageManager.writeJson(
path.join(profileSubdir, `geckoProfile-${index}.json`),
geckoProfile,
true
);
} catch (error) {
log.error(
`Could not rewrite visual progress using startimes and add visual metrics to ${profileFilename}`,
error
);
}
}
}
}
/**
* This method is called if a runs fail
*/
failing(url) {
if (this.skipHar) {
return;
}
this.hars.push(getEmptyHAR(url, 'Firefox'));
}
/**
* Get the HAR file for all the runs.
*/
async getHARs() {
return !this.skipHar && this.hars.length > 0
? { har: mergeHars(this.hars) }
: {};
}
/**
* Before the browser is stopped/closed.
*/
async beforeBrowserStop() {
if (isAndroidConfigured(this.options) && this.options.androidPower) {
await this.android.stopPowerTesting();
}
}
/**
* Before the browser is stopped/closed.
*/
async afterBrowserStopped() {}
async waitForNetworkIdle(driver) {
const windowId = await driver.getWindowHandle();
const bidi = await driver.getBidi();
let network = new NetworkManager(bidi, [windowId], this.options);
return network.waitForNetworkIdle();
}
}