UNPKG

browsertime

Version:

Get performance metrics from your web page using Browsertime.

691 lines (649 loc) 23.1 kB
import intel from 'intel'; import merge from 'lodash.merge'; import get from 'lodash.get'; import { Condition } from 'selenium-webdriver'; import { isAndroidConfigured } from '../android/index.js'; import { UrlLoadError, BrowserError, TimeoutError } from '../support/errors.js'; import { createWebDriver } from './webdriver/index.js'; import { getViewPort } from '../support/getViewPort.js'; import { defaultPageCompleteCheck } from './pageCompleteChecks/defaultPageCompleteCheck.js'; import { pageCompleteCheckByInactivity } from './pageCompleteChecks/pageCompleteCheckByInactivity.js'; import { spaInactivity as spaCheck } from './pageCompleteChecks/spaInactivity.js'; const log = intel.getLogger('browsertime'); const defaults = { timeouts: { browserStart: 60_000, pageLoad: 300_000, script: 120_000, logs: 90_000, pageCompleteCheck: 60_000 }, index: 0 }; const delay = ms => new Promise(res => setTimeout(res, ms)); /** * @function timeout * @description Wraps a promise with a timeout, rejecting the promise with a TimeoutError if it does not settle within the specified time. * * @param {Promise} promise - The promise to wrap with a timeout. * @param {number} ms - The number of milliseconds to wait before timing out. * @param {string} errorMessage - The error message for the TimeoutError. * * @returns {Promise} - A promise that resolves with the value of the input promise if it settles within time, or rejects with a TimeoutError otherwise. */ async function timeout(promise, ms, errorMessage) { let timerId; let finished = false; // Create a new promise that rejects after `ms` milliseconds. const timer = new Promise((_, reject) => { timerId = setTimeout(() => { if (!finished) { // Reject with a TimeoutError if the input promise has not yet settled. reject(new Error(errorMessage)); } }, ms); }); try { // Race the input promise against the timer. const result = await Promise.race([promise, timer]); finished = true; clearTimeout(timerId); return result; } catch (error) { finished = true; clearTimeout(timerId); throw error; } } /** * Wrapper for Selenium. * @class */ export class SeleniumRunner { constructor(baseDir, options) { this.options = merge({}, defaults, options); this.baseDir = baseDir; this.browserRestartTries = this.options.browserRestartTries || 3; } /** * Start the browser. Will timout after * --timeouts.browserStart time. It will try to start the * browser X times. * @throws {BrowserError} if the browser can't start */ async start() { const tries = this.browserRestartTries; for (let index = 0; index < tries; ++index) { try { this.driver = await timeout( createWebDriver(this.baseDir, this.options), this.options.timeouts.browserStart, `Failed to start ${this.options.browser} in ${ this.options.timeouts.browserStart / 1000 } seconds.` ); break; } catch (error) { log.info( `${this.options.browser} failed to start, trying ${ tries - index - 1 } more time(s): ${error.message}` ); } } if (!this.driver) { throw new BrowserError( `Could not start ${this.options.browser} with ${tries} tries` ); } try { await this.driver.manage().setTimeouts({ script: this.options.timeouts.script, pageLoad: this.options.timeouts.pageLoad }); let viewPort = getViewPort(this.options); if (viewPort) { if (viewPort !== 'maximize') { viewPort = viewPort.split('x'); } const window = this.driver.manage().window(); if (viewPort === 'maximize') { if (this.options.xvfb) { log.info( 'Maximizing window in XVFB may not work, make sure that you verify that it works.' ); } await window.maximize(); } else { // Android do not support set position nor set size if ( // Hack for Wallmart labs !isAndroidConfigured(this.options) ) { await window.setRect({ width: Number(viewPort[0]), height: Number(viewPort[1]), x: 0, y: 0 }); } } } } catch (error) { throw new BrowserError(error.message, { cause: error }); } } async extraWait(pageCompleteCheck) { await delay(this.options.beforePageCompleteWaitTime || 5000); return this._waitOnPageCompleteCheck(pageCompleteCheck); } /** * Wait for pageCompleteCheck to end before we return. * @param {string} pageCompleteCheck - JavaScript that checks if the page has finished loading * @throws {UrlLoadError} */ async _waitOnPageCompleteCheck(pageCompleteCheck, url) { const waitTime = this.options.pageCompleteWaitTime || 5000; if (!pageCompleteCheck) { pageCompleteCheck = this.options.pageCompleteCheckInactivity ? pageCompleteCheckByInactivity : defaultPageCompleteCheck; // if using SPA just override if (this.options.spa) { pageCompleteCheck = spaCheck; } } const driver = this.driver, pageCompleteCheckTimeout = this.options.timeouts.pageCompleteCheck; try { const pageCompleteCheckCondition = new Condition( 'for page complete check script to return true', function (d) { return d .executeScript(pageCompleteCheck, waitTime) .then(function (t) { log.verbose('PageCompleteCheck returned %s', t); return t === true; }); } ); log.debug( `Waiting for script pageCompleteCheck at most ${pageCompleteCheckTimeout} ms` ); log.verbose(`Waiting for script ${pageCompleteCheck}`); await timeout( driver.wait( pageCompleteCheckCondition, pageCompleteCheckTimeout, undefined, this.options.pageCompleteCheckPollTimeout || 1500 ), pageCompleteCheckTimeout, `Running page complete check ${pageCompleteCheck} took too long` ); } catch (error) { if (error instanceof TimeoutError) { log.info( `The page did not finished loading in ${pageCompleteCheckTimeout} ms. You can adjust the timeout by setting the --maxLoadTime option (in ms).` ); } else { log.error( `Failed waiting on page ${ url ?? '' } to finished loading, timed out after ${pageCompleteCheckTimeout} ms`, error ); throw new UrlLoadError( `Failed waiting on page ${ url ?? '' } to finished loading, timed out after ${pageCompleteCheckTimeout} ms `, { cause: error } ); } } } /** * Load and and wait for pageCompleteCheck to end before we return. * @param {string} url -The URL that will be tested. * @param {string} pageCompleteCheck - JavaScript that checks if the page has finished loading * @throws {UrlLoadError} */ async loadAndWait(url, pageCompleteCheck, engine) { const driver = this.driver; // Browsers may normalize 'https://x.com' differently; in particular, Firefox normalizes to // 'https://x.com/'. This is a first normalization attempt; there are deeper options that order // query parameters, order fragments, etc. We don't want to change url itself 'cuz it can be // used for indexing collected data and it is possible that normalization could change a key. const normalizedURI = new URL(url).toString(); // See https://github.com/sitespeedio/browsertime/issues/1698 const retries = 5; let documentURI; for (var index = 0; index < retries; index++) { documentURI = await driver.executeScript('return document.documentURI;'); if (documentURI != undefined) { break; } await delay(1000); } let startURI; try { startURI = new URL(documentURI).toString(); } catch (error) { log.error(`Failed to get documentURI ${documentURI}`, error); } if (this.options.webdriverPageload) { const clearOrange = `(function() { const orange = document.getElementById('browsertime-orange'); if (orange) { orange.parentNode.removeChild(orange); } })();`; await driver.executeScript(clearOrange); log.debug('Using webdriver.get to navigate'); await driver.get(url); } else { // To learn more about the event loop and request animation frame // watch Jake Archibald on ‘The Event Loop’ https://vimeo.com/254947206 // TODO do we only want to do this when we record a video? const navigate = `(function() { const orange = document.getElementById('browsertime-orange'); if (orange) { orange.parentNode.removeChild(orange); } window.requestAnimationFrame(function(){ window.requestAnimationFrame(function(){ window.location="${url}"; }); }); })();`; // Navigate to the page log.debug('Using window.location to navigate'); await driver.executeScript(navigate); } if (this.options.pageCompleteCheckNetworkIdle) { return engine.waitForNetworkIdle(driver); } else { // If you run with default settings, the webdriver will give back // control ASAP. Therefore you want to wait some extra time // before you start to run your page complete check this.options.pageLoadStrategy === 'none' ? await delay(this.options.pageCompleteCheckStartWait || 5000) : await delay(2000); // We give it a couple of times to finish loading, this makes it // more stable in real case scenarios on slow servers. let totalWaitTime = 0; const tries = get(this.options, 'retries', 5) + 1; for (let index = 0; index < tries; ++index) { try { await this._waitOnPageCompleteCheck(pageCompleteCheck, normalizedURI); const newURI = new URL( await driver.executeScript('return document.documentURI;') ).toString(); // If we use a SPA it could be that we don't test a new URL so just do one try // and make sure your page complete check take care of other things if (this.options.spa) { break; } else if (normalizedURI === startURI) { // You are navigating to the current page break; } else if (normalizedURI.startsWith('data:text')) { // Navigations between data/text seems to don't change the URI break; } else if (newURI === 'chrome-error://chromewebdata/') { // This is the timeout URL for Chrome, just continue to try throw new UrlLoadError( `Could not load ${url} is the web page down?`, url ); } else if (newURI === startURI) { const waitTime = (this.options.retryWaitTime || 10_000) * (index + 1); totalWaitTime += waitTime; log.debug( `URL ${url} failed to load, the ${ this.options.browser } are still on ${startURI} , trying ${ tries - index - 1 } more time(s) but first wait for ${waitTime} ms.` ); if (index === tries - 1) { // If the last tries through an error, rethrow as before const message = `Could not load ${url} - the navigation never happend after ${tries} tries and total wait time of ${totalWaitTime} ms`; log.error(message); throw new UrlLoadError(message, url); } else { // We add some wait time before we try again await delay(waitTime); log.info( 'Will check again if the browser has navigated to the page' ); } } else { // We navigated to a new page, we don't need to test anymore break; } } catch (error) { log.info( `URL failed to load, trying ${tries - index - 1} more time(s): ${ error.message }` ); // if (index === tries - 1) { // If the last tries through an error, rethrow as before log.error('Could not load URL %s', url, error); throw new UrlLoadError('Failed to load ' + url, url, { cause: error }); } else { await delay(1000); } } } } } /** * Take a screenshot. * @param {string} url -The URL that is tested for logging purposes * @throws {BrowserError} */ async takeScreenshot(url) { try { const base64EncodedPng = await this.driver.takeScreenshot(); if (typeof base64EncodedPng === 'string') { return Buffer.from(base64EncodedPng, 'base64'); } else { // Sometimes for Chrome, driver.takeScreenshot seems to succeed, // but the result is not a string. log.warning( `Failed to take screenshot (type was ${typeof base64EncodedPng}` ); throw new BrowserError( `Failed to take screenshot (type was ${typeof base64EncodedPng}` ); } } catch (error) { log.error( 'Failed to take screenshot' + (url ? ' for ' + url : ''), error ); throw new BrowserError('Failed to take screenshot', { cause: error }); } } /** * Run a synchrously JavaScript with args. * @param {string} script - the actual script * @param {string} name - the name of the script (for logging) * @param {*} args - arguments to the script * @throws {BrowserError} */ async runScript(script, name, arguments_) { let scriptTimeout = this.options.timeouts.script; if (log.isEnabledFor(log.TRACE)) { log.verbose('Executing script %s', script); } else if (log.isEnabledFor(log.VERBOSE)) { log.verbose('Executing script %s', name); } try { const result = await timeout( this.driver.executeScript(script, arguments_), scriptTimeout, `Running script ${script} took too long (${scriptTimeout} ms).` ); return result; } catch (error) { log.error("Couldn't execute script named " + name + ' error:' + error); throw error; } } /** * Run synchronous privileged JavaScript with args. * @param {string} script - the actual script * @param {string} name - the name of the script (for logging) * @param {*} args - arguments to the script * @throws {BrowserError} */ async runPrivilegedScript(script, name, arguments_) { if (this.options.browser !== 'firefox') { throw new BrowserError( `Only Firefox browsers can run privileged JavaScript: ${this.options.browser}`, { cause: 'PrivilegeError' } ); } log.verbose('Executing privileged script %s', script); log.verbose('Executing privileged script %s', name); const oldContext = await this.driver.getContext(); try { await this.driver.setContext('chrome'); const result = await this.driver.executeScript(script, arguments_); await this.driver.setContext(oldContext); return result; } catch (error) { log.error( "Couldn't execute privileged script named " + name + ' error:' + error ); await this.driver.setContext(oldContext); throw error; } } /** * Run a asynchrously JavaScript. * @param {string} script - the actual script * @param {string} name - the name of the script (for logging) * @param {*} args - arguments to the script * @throws {BrowserError} */ async runAsyncScript(script, name, arguments_) { if (log.isEnabledFor(log.TRACE)) { log.verbose('Executing async script %s', script); } else if (log.isEnabledFor(log.VERBOSE)) { log.verbose('Executing async script %s', name); } return this.driver.executeAsyncScript(script, arguments_); } /** * Run asynchronous privileged JavaScript with args. * @param {string} script - the actual script * @param {string} name - the name of the script (for logging) * @param {*} args - arguments to the script * @throws {BrowserError} */ async runPrivilegedAsyncScript(script, name, arguments_) { if (this.options.browser !== 'firefox') { throw new BrowserError( `Only Firefox browsers can run privileged JavaScript: ${this.options.browser}`, { cause: 'PrivilegeError' } ); } log.verbose('Executing privileged async script %s', script); log.verbose('Executing privileged async script %s', name); try { const oldContext = await this.driver.getContext(); await this.driver.setContext('chrome'); const result = await this.driver.executeAsyncScript(script, arguments_); await this.driver.setContext(oldContext); return result; } catch (error) { log.error( "Couldn't execute async script named " + name + ' error:' + error ); throw error; } } /** * Get logs from the browser. * @param {*} logType * @throws {BrowserError} */ async getLogs(logType) { return timeout( this.driver.manage().logs().get(logType), this.options.timeouts.logs, `Extracting logs from ${this.options.browser} took more than ${ this.options.timeouts.logs / 1000 } seconds.` ); } /** * Stop the driver/browser. * @throws {BrowserError} */ async stop() { if (this.driver) { try { return timeout( this.driver.quit(), 120_000, 'Could not close the browser using driver.quit' ); } catch (error) { throw new BrowserError(error.message, { cause: error }); } } } /** * * Scripts should be valid statements or IIFEs '(function() {...})()' that can run * on their own in the browser console. Prepend with 'return' to return result of statement to Browsertime. * @param {*} script - the script * @param {boolean} isAsync - is the script synchrously or async? * @param {*} name - the name of the script * @param {[String]} requires - the requirements for executing the script */ async runScriptFromCategory(script, isAsync, name, requires) { const privilegeWanted = requires && requires.privilege; let scriptRunner = this.runScript.bind(this); if (isAsync) { scriptRunner = privilegeWanted ? this.runPrivilegedAsyncScript.bind(this) : this.runAsyncScript.bind(this); } else { if (privilegeWanted) { scriptRunner = this.runPrivilegedScript.bind(this); } } if (privilegeWanted) { log.verbose('Executing script ' + name + ' with privilege.'); } // Scripts should be valid statements or IIFEs '(function() {...})()' that can run // on their own in the browser console. Prepend with 'return' to return result of statement to Browsertime. if (isAsync) { const source = ` const callback = arguments[arguments.length - 1]; return (${script}) .then((r) => callback({'result': r})) .catch((e) => callback({'error': e})); `; const result = await scriptRunner(source, name); if (result.error) { throw result.error; } else { return result.result; } } else { const source = 'return ' + script; return this.options.scriptInput && this.options.scriptInput[name] ? scriptRunner(source, name, this.options.scriptInput[name]) : scriptRunner(source, name); } } /** * Get the driver from Selenium. */ getDriver() { return this.driver; } /** * Run scripts by category. * @param {*} scriptsByCategory * @param {boolean} isAsync - is the script synchrously or async? */ async runScripts(scriptsByCategory, isAsync) { const categoryNames = Object.keys(scriptsByCategory); const results = {}; for (let categoryName of categoryNames) { const category = scriptsByCategory[categoryName]; try { results[categoryName] = await this.runScriptInCategory( category, isAsync ); } catch (error) { if (error.extra && error.extra.cause === 'PrivilegeError') { // Ignore those scripts that fail to execute because they // wanted privileges that the browser cannot provide. log.verbose( 'Did not have enough privileges to execute user script: ' + error + '; ignoring.' ); } else { log.error('Failed to execute user script: ' + error); results[categoryName] = undefined; } } } return results; } /** * Run scripts in category. * @param {*} category * @param {boolean} isAsync - is the script synchrously or async? */ async runScriptInCategory(category, isAsync) { const scriptNames = Object.keys(category); const results = {}; let requires; for (let scriptName of scriptNames) { let isAsyncOverride = false; requires = {}; let script = category[scriptName]; if (typeof script != 'string') { script = script.content; } // Assume that if the string in content is null // the function is not null and we want to run // the function. if (!script) { let function_ = category[scriptName].function; if (!function_) { throw ( 'Function and script cannot both be null in ' + scriptName + '.' ); } // We wrap the source code of the function in parenthesis // "(...)" to contain it in a separate scope. We add a // "();" to the source of the function to do the actual // invocation. The script writer cannot do this in their // source because it would force the evaluation of the // function too soon and cause undefined reference errors. script = '( ' + function_ + ' )()'; requires = category[scriptName].requires; isAsyncOverride = category[scriptName].isAsync; } if ( Object.keys(requires).length > 0 && this.options.browser !== 'firefox' ) { // Require is only for running in Firefox } else { const result = await this.runScriptFromCategory( script, isAsync || isAsyncOverride, scriptName, requires ); if (!(result === null || result === undefined)) { results[scriptName] = result; } } } return results; } }