UNPKG

browsertime

Version:

Get performance metrics from your web page using Browsertime.

630 lines (582 loc) 19.4 kB
import path from 'node:path'; import { getLogger } from '@sitespeed.io/log'; import { timestamp as _timestamp } from '../../../support/engineUtils.js'; import { pathToFolder } from '../../../support/pathToFolder.js'; import { filterAllowlisted, filterBlocklisted } from '../../../support/userTiming.js'; import { isAndroidConfigured, Android } from '../../../android/index.js'; import { TCPDump } from '../../../support/tcpdump.js'; import { getProperty, merge } from '../../../support/util.js'; import { MeasureScreenshots } from './measure/screenshots.js'; import { MeasureVideo } from './measure/video.js'; const log = getLogger('browsertime.command.measure'); // In some cases we could have one alias mapped to multiple URLs // we've seen it for login etc where the user get different query parameter // values. So map alias = url 1:1 const aliasAndUrl = {}; function getNewResult() { return { extraJson: {}, timestamp: _timestamp(), extras: {} }; } /** * A measurement tool for browser-based metrics, handling various aspects * of metric collection including navigation, video recording, and data collection. * * @class * @hideconstructor */ export class Measure { constructor({ browser, index, pageCompleteCheck, result, engineDelegate, storageManager, videos, scriptsByCategory, asyncScriptsByCategory, postURLScripts, context, screenshotManager, options }) { /** * @private */ this.browser = browser; /** * @private */ this.pageCompleteCheck = pageCompleteCheck; /** * @private */ this.index = index; /** * @private */ this.result = result; /** * @private */ this.engineDelegate = engineDelegate; /** * @private */ this.options = options; /** * @private */ this.storageManager = storageManager; /** * @private */ this.scriptsByCategory = scriptsByCategory; /** * @private */ this.asyncScriptsByCategory = asyncScriptsByCategory; /** * @private */ this.postURLScripts = postURLScripts; /** * @private */ this.context = context; /** * @private */ this.numberOfMeasuredPages = 0; /** * @private */ this.numberOfVisitedPages = 0; /** * @private */ this.areWeMeasuring = false; /** * @private */ this.testedURLs = {}; /** * @private */ this.tcpDump = new TCPDump(storageManager.directory, options); /** * @private */ this.measureVideo = new MeasureVideo( storageManager, browser, videos, options ); /** * @private */ this.screenshots = new MeasureScreenshots( browser, screenshotManager, options ); } /** * Have a consistent way of adding an error * @private */ _error(message) { // If we already are measuring a page, associate the error woth that page if (this.areWeMeasuring === true) { if (this.result[this.numberOfMeasuredPages].error) { this.result[this.numberOfMeasuredPages].error.push(message); } else { this.result[this.numberOfMeasuredPages].error = [message]; } } else { // If we have tested pages before, but not in the making of // measuring a page, associate the error with that latest page. if (this.result.length > 0) { if (this.result.at(-1).error === undefined) { this.result.at(-1).error = [message]; } else { this.result.at(-1).error.push(message); } } } // Add the error in the generic failure if (this.result.failureMessages) { this.result.failureMessages.push(message); } else { this.result.failureMessages = [message]; } } /** * Have a consistent way of adding an failure * @private */ _failure(message) { log.info('Mark run as failure with message: %s', message); this.result.markedAsFailure = 1; if (this.result.failureMessages) { this.result.failureMessages.push(message); } else { this.result.failureMessages = [message]; } } /** * Navigates to a specified URL and handles additional setup for the first page visit. * * This function is responsible for setting up the browser with necessary configurations and * navigating to the URL. It also waits for the page * to load completely based on configured page completion check. * * @async * @private * @param {string} url - The URL to navigate to. * @throws {Error} Throws an error if navigation or setup fails. * @returns {Promise<void>} A promise that resolves when the navigation and setup are complete. */ async _navigate(url) { log.info('Navigating to url %s iteration %s', url, this.index); if ( this.numberOfVisitedPages === 0 && // There's a bug that we introduced when moving cookie handling to CDP // and this is the hack to fix that. ((this.options.cookie && this.options.browser === 'chrome') || this.options.browser === 'edge') ) { await this.engineDelegate.setCookies(url, this.options.cookie); } if (this.numberOfVisitedPages === 0) { await this.engineDelegate.beforeStartIteration(this.browser, this.index); } this.numberOfVisitedPages++; return this.browser.loadAndWait( url, this.pageCompleteCheck, this.engineDelegate ); } /** * Starts the measurement process for a given URL or an alias. * * It supports starting measurements by either directly providing a URL or using an alias. * If a URL is provided, it navigates to that URL and performs the measurement. * If an alias is provided, or no URL is available, it sets up the environment for a user-driven navigation. * * @async * @example * await commands.measure.start('https://www.example.org'); * // Or start the measurement and click on a link * await commands.measure.start(); * await commands.click.byLinkTextAndWait('Documentation'); * // Remember to stop the measurements if you do not provide a URL * await commands.measure.stop(); * @param {string} urlOrAlias - The URL to navigate to, or an alias representing the test. * @param {string} [optionalAlias] - An optional alias that can be used if the first parameter is a URL. * @throws {Error} Throws an error if navigation fails or if there are issues in the setup process. * @returns {Promise<void>} A promise that resolves when the start process is complete, or rejects if there are errors. */ async start(urlOrAlias, optionalAlias) { let url, alias; if ( urlOrAlias && (urlOrAlias.startsWith('http') || urlOrAlias.startsWith('data:text')) ) { url = urlOrAlias; log.info('Testing url %s iteration %s', url, this.index); } else if (urlOrAlias) { alias = urlOrAlias; log.info('Start to measure %s', alias); } else { log.info('Start to measure'); } if (this.numberOfVisitedPages === 0) { await this.engineDelegate.beforeStartIteration(this.browser, this.index); } this.result.push(getNewResult()); this.result[this.numberOfMeasuredPages].timestamp = _timestamp(); if (alias || optionalAlias) { this.result[this.numberOfMeasuredPages].alias = alias || optionalAlias; } if (this.options.spa) { // Make sure that the resource timing is empty await this.browser .getDriver() .executeScript('window.performance.clearResourceTimings();'); } // Record current soft navigation count so browser scripts // only collect entries from this measurement await this.browser .getDriver() .executeScript( 'window.__bt_softNavCount = (performance.getEntriesByType("soft-navigation") || []).length;' ); if (this.measureVideo.shouldRecord()) { await this.measureVideo.start(this.numberOfMeasuredPages, this.index); // if we do not have the URL, we should remove the orange and // start meausuring because the user themself will navigate to // the URL that will be tested. if (!url) { const removeOrange = `(function() { const orange = document.getElementById('browsertime-orange'); if (orange) { orange.parentNode.removeChild(orange); } })();`; await this.browser.getDriver().executeScript(removeOrange); } } // await this.engineDelegate.clear(this.browser); if (this.options.tcpdump) { await this.tcpDump.start(this.index); } if (url) { // We jack in the URL that we wanna test and then override it from the browser this.result[this.numberOfMeasuredPages].url = url; try { await this.engineDelegate.beforeEachURL(this.browser, url); await this.browser.loadAndWait( url, this.pageCompleteCheck, this.engineDelegate ); return this.stop(url); } catch (error) { this.result[this.numberOfMeasuredPages].error = [error.name]; // The page failed loading but make sure we keep track of // how many pages we tried to measure this.numberOfMeasuredPages++; this.numberOfVisitedPages++; this.engineDelegate.failing(url); // If we got an error we still should stop the video to make sure we can use it to // try to understand what went wrong await this.measureVideo.stop(url); throw error; } } else { this.areWeMeasuring = true; return this.engineDelegate.beforeEachURL(this.browser); } } /** * Stop the current measurement and mark it as a failure. This stop function will not measure anything on a page. This is useful if you need to stop a measurement in a (try) catch and you * know something has failed. * * @async * @param {string} errorMessage - The message about the error. This will end up on the HTML report for sitespeed.io so give it a good message so you know what's gone wrong. * @returns {Promise<void>} A promise that resolves when the stop process has completed. * @since 21.2.0 */ async stopAsError(errorMessage) { this._failure(errorMessage); this.areWeMeasuring = false; // Remove the last result this.result.pop(); await this.measureVideo.stop(); if (this.options.tcpdump) { await this.tcpDump.stop(); } return; } /** * @private */ setClick(click) { this._click = click; } /** * Starts a measurement, clicks on an element matching the given CSS selector, * waits for the page complete check, and stops the measurement. This is * a shortcut for the common pattern of measure.start/click/measure.stop. * * @async * @param {string} alias - The friendly name for this measurement. * @param {string} selector - The CSS selector of the element to click. * @returns {Promise<unknown>} A promise that resolves with the collected metrics data. * @throws {Error} Throws an error if the element is not found or the measurement fails. */ async clickAndMeasure(alias, selector) { await this.start(alias); await this._click.bySelectorAndWait(selector); return this.stop(); } /** * Stops the measurement process, collects metrics, and handles any post-measurement tasks. * It finalizes the URL being tested, manages any URL-specific metadata, stops any ongoing video recordings, * and initiates the data collection process. * * @async * @param {string} testedStartUrl - The URL that was initially tested. If not provided, it will be obtained from the browser. * @throws {Error} Throws an error if there are issues in stopping the measurement or collecting data. * @returns {Promise<unknown>} A promise that resolves with the collected metrics data. */ async stop(testedStartUrl) { log.debug('Stop measuring'); // If we don't have a URL (tested using clicking on link etc) get the URL from the browser let url = testedStartUrl || (await this.browser.runScript('return document.URL', 'PAGE_URL')); if (this.testedURLs[url]) { log.info( '%s has been tested before within the same run, it will get an extra query parameter named browsertime_run. Make sure to use alias to keep track of the URLs', url ); this.testedURLs[url] = this.testedURLs[url] + 1; url = url.includes('?') ? url + '&browsertime_run=' + this.testedURLs[url] : url + '?browsertime_run=' + this.testedURLs[url]; } else { this.testedURLs[url] = 1; } // There's a use case where you only add an alias in script, if that's the case // we also need to add that to the meta data so that the correct folder is created if (this.result[this.numberOfMeasuredPages].alias) { if (this.options.urlMetaData) { this.options.urlMetaData[url] = this.result[this.numberOfMeasuredPages].alias; } else { this.options.urlMetaData = {}; this.options.urlMetaData[url] = this.result[this.numberOfMeasuredPages].alias; } } // We have the URL, create the data dir await this.storageManager.createSubDataDir( path.join(pathToFolder(url, this.options)) ); await this.measureVideo.stop(url); const alias = this.options.urlMetaData ? this.options.urlMetaData[url] : // eslint-disable-next-line no-constant-binary-expression undefined || this.result[this.numberOfMeasuredPages].alias; const res = await this.engineDelegate.afterPageCompleteCheck( this.browser, this.index, url, alias ); // Add the alias from the options to the result to follow the same pattern as if we have the alias in the script file if ( this.options.urlMetaData && this.options.urlMetaData[url] && !res.alias ) { res.alias = this.options.urlMetaData[url]; } this.result[this.numberOfMeasuredPages] = merge( this.result[this.numberOfMeasuredPages], res ); if (this.options.tcpdump) { await this.tcpDump.stop(); } return this._collect(url); } /** * Adds a custom metric to the current measurement result. * The metric will be attached to the latest tested page, meaming * you need to have measured a URL and stopped the measurement before * you add the metric. * @param {string} name - The name of the metric. * @param {*} value - The value of the metric. * @throws {Error} Throws an error if called before a measurement cycle has started. */ add(name, value) { if (this.result[this.numberOfMeasuredPages - 1]) { this.result[this.numberOfMeasuredPages - 1].extras[name] = value; } else { log.error( 'You need to have done one (start/stop) measurement before you can add any metrics to a result.' ); } } /** * Adds multiple custom metrics to the current measurement result. * This method accepts an object containing multiple key-value pairs representing different metrics. * The metric will be attached to the latest tested page, meaming * you need to have measured a URL and stopped the measurement before * you add the metric. * * @param {Object} object - An object containing key-value pairs of metrics to add. * @throws {Error} Throws an error if called before a measurement cycle has started. */ addObject(object) { if (this.result[this.numberOfMeasuredPages - 1]) { merge(this.result[this.numberOfMeasuredPages - 1].extras, object); } else log.error( 'You need to have done one (start/stop) measurement before you can add any metrics to a result.' ); } /** * @private */ _resolveAlias(url) { if ( this.result[this.numberOfMeasuredPages] && this.result[this.numberOfMeasuredPages].alias ) { const alias = this.result[this.numberOfMeasuredPages].alias; if (aliasAndUrl[alias]) { return aliasAndUrl[alias]; } aliasAndUrl[alias] = url; } return url; } /** * @private */ _attachVideoMetadata() { const metadata = this.measureVideo.getRecordingMetadata(); if (metadata) { this.result[this.numberOfMeasuredPages].recordingStartTime = metadata.recordingStartTime; this.result[this.numberOfMeasuredPages].timeToFirstFrame = metadata.timeToFirstFrame; } } /** * @private */ async _collectBrowserScripts() { const syncScripts = this.scriptsByCategory ? await this.browser.runScripts(this.scriptsByCategory) : {}, asyncScripts = this.asyncScriptsByCategory ? await this.browser.runScripts(this.asyncScriptsByCategory, true) : {}; this.result[this.numberOfMeasuredPages].browserScripts = merge( {}, syncScripts, asyncScripts ); } /** * @private */ _filterUserTimings() { // Some sites has crazy amount of user timings: // strip them if you want if (this.options.userTimingAllowList) { filterAllowlisted( this.result[this.numberOfMeasuredPages].browserScripts.timings .userTimings, this.options.userTimingAllowList ); } if (this.options.userTimingBlockList) { filterBlocklisted( this.result[this.numberOfMeasuredPages].browserScripts.timings .userTimings, this.options.userTimingBlockList ); } } /** * @private */ async _collectAndroidProcessTime() { if (isAndroidConfigured(this.options) && this.options.processStartTime) { const packageName = this.options.browser === 'firefox' ? getProperty( this.options, 'firefox.android.package', 'org.mozilla.firefox' ) : getProperty(this.options, 'chrome.android.package'); const android = new Android(this.options); const pid = await android.pidof(packageName); if (pid) { const processStartTime = await android.processStartTime(pid); this.result[ this.numberOfMeasuredPages ].browserScripts.browser.processStartTime = processStartTime; } } } /** * @private */ async _runPostURLScripts() { for (const postURLScript of this.postURLScripts) { await postURLScript(this.context); } } /** * @private */ async _collect(url) { log.verbose('Collecting metrics'); url = this._resolveAlias(url); this.result[this.numberOfMeasuredPages].url = url; this._attachVideoMetadata(); await this.screenshots.afterPageCompleteCheck(url, this.index); await this.screenshots.layoutShift(url, this.index); await this.screenshots.largestContentfulPaint(url, this.index); await this._collectBrowserScripts(); this._filterUserTimings(); await this._collectAndroidProcessTime(); await this._runPostURLScripts(); if (this.options.tcpdump) { await this.tcpDump.mv(url, this.index); } await this.engineDelegate.afterEachURL( this.browser, this.index, this.result[this.numberOfMeasuredPages] ); this.numberOfMeasuredPages++; this.numberOfVisitedPages++; this.areWeMeasuring = false; } }