UNPKG

browsertime

Version:

Get performance metrics from your web page using Browsertime.

630 lines (568 loc) 19.7 kB
import { arch as _arch } from 'node:os'; import { getLogger } from '@sitespeed.io/log'; import { execaCommand as command } from 'execa'; import { StorageManager } from '../../support/storageManager.js'; import { Firefox } from '../../firefox/webdriver/firefox.js'; import { Chromium } from '../../chrome/webdriver/chromium.js'; import { Safari } from '../../safari/webdriver/safari.js'; import { addConnectivity, removeConnectivity } from '../../connectivity/index.js'; import { logResultLogLine, getProperty, toArray, merge } from '../../support/util.js'; import { getFullyLoaded, getMainDocumentTimings, addExtraFieldsToHar } from '../../support/har/index.js'; import { XVFB } from '../../support/xvfb.js'; import { Gnirehtet } from '../../android/gnirehtet.js'; import { Iteration } from './iteration.js'; import { Collector } from './collector.js'; import { Android, isAndroidConfigured } from '../../android/index.js'; import { RootedDevice } from '../../android/root.js'; import { run } from './run.js'; import { loadPrePostScripts, loadScript, loadPageCompleteScript } from '../../support/engineUtils.js'; import { getAvailablePort } from '../../support/getPort.js'; import { traceCategories as defaultChromeTraceCategories } from '../../chrome/settings/traceCategories.js'; const log = getLogger('browsertime'); const defaults = { scripts: [], iterations: 3, delay: 0, videoParams: {} }; const delay = ms => new Promise(res => setTimeout(res, ms)); function shouldDelay(index, total, delay) { const isLast = total - index === 0; return delay > 0 && !isLast; } /** * Create a new Browsertime Engine. * @class */ export class Engine { constructor(options) { this.options = merge({}, defaults, options); log.debug('Running with options: %:2j', this.options); this.myXVFB = new XVFB(this.options); } /** * Start the engine. Will prepare everything before you will start your run: * * Start XVFB (if it is configured) * * Set connectivity * * Start the extension server */ async start() { const options = this.options; options.devToolsPort = await getAvailablePort([9222, 9350]); log.debug( `Preparing port ${options.devToolsPort} for devtools on Chrome/Edge` ); options.safariDriverPort = await getAvailablePort([1234, 2000]); await addConnectivity(options); if (isAndroidConfigured(options)) { await this._setupAndroid(); } if (options.safari && options.safari.useSimulator) { await this._setupIOSSimulator(); } return this.myXVFB.start(); } async _setupAndroid() { const options = this.options; const android = new Android(options); await android._init(); this.android = android; if (options.android.gnirehtet === true && !options.webpagereplay) { this.gnirehtet = new Gnirehtet(options); await this.gnirehtet.start(); } const phoneState = await android.getPhoneState(); if (phoneState !== 'device') { const message = `The phone ${android.id} state is ${phoneState}`; log.error(message); throw new Error(message); } if (options.androidPretestPowerPress) { await android.clickPowerButton(); } if (options.androidPretestPressHomeButton) { await android.pressHomeButton(); } if (options.androidVerifyNetwork) { const pingAddress = getProperty(options, 'androidPingAddress', '8.8.8.8'); const connection = await android.ping(pingAddress); if (!connection) { const message = `No internet connection for ${android.id}. Could not ping ${pingAddress}`; log.error(message); throw new Error(message); } } await android.closeAppNotRespondingPopup(); // Make sure screen is on! await android._runCommand('svc power stayon true'); if (options.androidRooted) { const gotRoot = await android._runCommandAndGet( 'su - root -c "echo test"' ); if (gotRoot.includes('not found')) { log.info('Your phone do not have su, is it really rooted?'); } else { this.rooted = new RootedDevice(this.android, options); await this.rooted.start(); } } if (options.androidBatteryTemperatureLimit) { const maxTries = options.androidBatteryTemperatureMaxTries || 20; let batteryTry = 0; let temporary = await android.getTemperature(); const minTemporaryLimit = options.androidBatteryTemperatureLimit; const waitTime = (options.androidBatteryTemperatureWaitTimeInSeconds || 120) * 1000; if (temporary > minTemporaryLimit) { do { temporary = await android.getTemperature(); log.info( 'Battery temperature is %s, waiting it to go down under %s, will sleep for %s s', temporary, minTemporaryLimit, waitTime / 1000 ); batteryTry++; if (batteryTry === maxTries) { log.error( 'Battery temperature (%s) never got under %s after %s tries.', temporary, minTemporaryLimit, maxTries ); if (options.androidBatteryTemperatureReboot) { log.info( 'Rebooting the device, will wait until ADB sees the device' ); await android.reboot(); } throw new Error( 'Battery temperature never got under the configured limit' ); } await delay(waitTime); } while (temporary > minTemporaryLimit); } else { log.info('Battery temperature is %s, lets start the tests', temporary); } } if (options.connectivity && options.connectivity.engine === 'humble') { const wifiName = await android.getWifi(); log.info('The phone is using the WiFi: %s', wifiName); } } async _setupIOSSimulator() { const options = this.options; // Start the simulator try { await command( 'open -a /Applications/Xcode.app/Contents/Developer/Applications/Simulator.app/', { shell: true } ); await delay(4000); } catch (error) { log.error('Could not start the Simulator.app', error); throw error; } const { stdout } = await command('xcrun simctl list devices -j', { shell: true }); const simulatedDevices = JSON.parse(stdout); const types = Object.keys(simulatedDevices.devices); for (let type of types) { for (let device of simulatedDevices.devices[type]) { if (device.udid === options.safari.deviceUDID) { this.iosSimulatorDeviceName = device.name; log.info( 'Running test on %s simulator using %s', this.iosSimulatorDeviceName, _arch() ); break; } } } } async runByScript( navigationScript, name, scriptsByCategory, asyncScriptsByCategory ) { const options = this.options; if (Array.isArray(name)) { name = name[0]; } const storageManager = new StorageManager(name, options); const engineDelegate = this._createEngineDelegate(storageManager, options); let preScripts, postScripts, postURLScripts, pageCompleteCheck; try { preScripts = await loadPrePostScripts(options.preScript, options); postScripts = await loadPrePostScripts(options.postScript, options); postURLScripts = await loadPrePostScripts(options.postURLScript, options); pageCompleteCheck = options.pageCompleteCheck ? await loadPageCompleteScript(options.pageCompleteCheck) : undefined; } catch (error) { log.error(error.message); throw error; } const iteration = new Iteration( storageManager, engineDelegate, scriptsByCategory, asyncScriptsByCategory, preScripts, postScripts, postURLScripts, pageCompleteCheck, options ); await storageManager.createDataDir(); const collector = new Collector(name, storageManager, options); if (isAndroidConfigured(options)) { const model = await this.android.getMeta(); log.info( 'Run tests on %s [%s] using Android version %s', model.model, this.android.id, model.androidVersion ); } log.info( 'Running tests using %s - %s iteration(s)', `${options.browser[0].toUpperCase()}${options.browser.slice(1)}`, options.iterations ); const errorsOutsideTheBrowser = []; const failures = []; for (let index = 1; index < options.iterations + 1; index++) { const data = await iteration.run(navigationScript, index); // Only collect if it was succesful // Here we got room for improvements if (data && data[0] && data[0].url) { await collector.perIteration(data, index); // If we have a failure from scripting if (data.markedAsFailure) { failures.push(...data.failureMessages); } } else { log.error('No data to collect'); // We can have errors that happend before we started to test the page // like starting the browser, missmatching WebDriver if (data.error) { errorsOutsideTheBrowser.push(...data.error); } // Catch failures like starting the browser if (data.markedAsFailure) { failures.push(...data.failureMessages); } } if (shouldDelay(index, options.iterations, options.delay)) { await delay(options.delay); } } const extras = await engineDelegate.getHARs(); this._processHARResults(collector, extras, options); const totalResult = collector.getResults(); this._backfillBrowserInfo(totalResult, extras, options); if (!options.enableProfileRun) { logResultLogLine(totalResult); } await this._attachMetadata( totalResult, failures, errorsOutsideTheBrowser, options ); return totalResult; } _createEngineDelegate(storageManager, options) { switch (options.browser) { case 'firefox': { return new Firefox(storageManager, options); } case 'chrome': case 'edge': { return new Chromium(storageManager, options); } case 'safari': { return new Safari(storageManager, options); } } } _processHARResults(collector, extras, options) { // Backfill the fully loaded data that we extract from the HAR // Only do this if we actually had requests if ( !options.skipHar && extras.har && extras.har.log && extras.har.log.entries.length > 0 ) { const fullyLoadedPerUrl = getFullyLoaded(extras.har); for (let data of fullyLoadedPerUrl) { if (data.url === undefined) { log.error( 'There is an page without an URL in the HAR. Please inspect the HAR file and check whats wrong' ); } else { collector.addFullyLoaded(data.url, data.fullyLoaded); } } // Add the timings from the main document const timings = getMainDocumentTimings(extras.har); for (let timing of timings) { collector.addMainDocumentTimings(timing.url, timing.timings); } } } _backfillBrowserInfo(totalResult, extras, options) { // Add extra fields to the HAR // to make the HAR files better when we use them in // compare.sitespeed.io if (!options.skipHar && extras.har) { addExtraFieldsToHar(totalResult, extras.har, options); totalResult.har = extras.har; // Backfill browser and version for (let result of totalResult) { result.info.browser.name = extras.har.log.browser.name; result.info.browser.version = extras.har.log.browser.version; if (options.browser === 'firefox' && options.firefox) { // Issue #1622 — surface the full set of args/preferences/binary // that browsertime applied to Firefox, not just what the user // passed via --firefox.args / --firefox.preference. Captured by // configureBuilder; see lib/firefox/webdriver/builder.js. if (options.recordedBrowserSettings) { const recorded = options.recordedBrowserSettings; result.info.browser.args = recorded.args; result.info.browser.preferences = recorded.preferences; if (recorded.binary) { result.info.browser.binary = recorded.binary; } if (recorded.profile) { result.info.browser.profile = recorded.profile; } if (recorded.androidPackage) { result.info.browser.androidPackage = recorded.androidPackage; } if (recorded.androidActivity) { result.info.browser.androidActivity = recorded.androidActivity; } } else { result.info.browser.args = options.firefox.args; result.info.browser.preference = options.firefox.preference; } if (options.firefox.geckoProfiler === true) { result.info.browser.geckProfilerFeatures = options.firefox.geckoProfilerParams.features; } } else if (options.browser === 'chrome' || options.browser === 'edge') { // Issue #1622 — surface the full set of args/preferences/etc that // browsertime applied to the browser, not just what the user passed // via --chrome.args. Captured by setupChromiumOptions; see // lib/chrome/webdriver/setupChromiumOptions.js. if (options.recordedBrowserSettings) { const recorded = options.recordedBrowserSettings; result.info.browser.args = recorded.args; result.info.browser.preferences = recorded.preferences; if (recorded.mobileEmulation) { result.info.browser.mobileEmulation = recorded.mobileEmulation; } if (recorded.binaryPath) { result.info.browser.binaryPath = recorded.binaryPath; } if (recorded.extensions > 0) { result.info.browser.extensions = recorded.extensions; } } else if (options.chrome) { result.info.browser.args = options.chrome.args; } if ( options.chrome && (options.cpu || options.chrome.timeline || options.chrome.trace) ) { // get correct trace categories let chromeTraceCategories = options.chrome.traceCategories ? options.chrome.traceCategories.split(',') : [...defaultChromeTraceCategories]; if (options.chrome.enableTraceScreenshots) { chromeTraceCategories.push( 'disabled-by-default-devtools.screenshot' ); } if (options.chrome && options.chrome.traceCategory) { const extraCategories = toArray(options.chrome.traceCategory); Array.prototype.push.apply( chromeTraceCategories, extraCategories ); } result.info.browser.traceCategories = chromeTraceCategories; } } } } else if (options.browser === 'safari') { for (let result of totalResult) { result.info.browser.name = 'Safari'; // Hack to get Safari version // WHen the user agent change we do not want Browsertime to break so // swallow it try { const vString = result.info.browser.userAgent.split('Version/')[1]; result.info.browser.version = vString.split(' ')[0]; } catch { // Just swallow } } } // We don't have a HAR for Firefox on Android else if (options.browser === 'firefox' && isAndroidConfigured(options)) { for (let result of totalResult) { result.info.browser.name = 'Firefox'; try { const vString = result.info.browser.userAgent.split('Firefox/')[1]; result.info.browser.version = vString; } catch { // Just swallow } } } } async _attachMetadata( totalResult, failures, errorsOutsideTheBrowser, options ) { if (failures.length > 0) { // If we have a result if (totalResult[0]) { totalResult[0].markedAsFailure = 1; totalResult[0].failureMessages = failures; } else { // If we didn't have a result, we still want the failure totalResult[0] = { markedAsFailure: 1, failureMessages: failures }; } } if (errorsOutsideTheBrowser.length > 0) { if (totalResult[0]) { totalResult[0].errors ? totalResult[0].errors.push(...errorsOutsideTheBrowser) : (totalResult[0].errors = errorsOutsideTheBrowser); } else { totalResult[0] = { errors: errorsOutsideTheBrowser }; } } if (isAndroidConfigured(options)) { const model = await this.android.getMeta(); for (let result of totalResult) { result.info.android = model; } } if (options.safari && options.safari.useSimulator) { for (let result of totalResult) { result.info.ios = { deviceName: this.iosSimulatorDeviceName + ' simulator', deviceUDID: options.safari.deviceUDID, arch: _arch() }; } } } async run(url, scriptsByCategory, asyncScriptsByCategory) { return this.runByScript( run([url]), url, scriptsByCategory, asyncScriptsByCategory ); } async runMultiple(urlOrFiles, scriptsByCategory, asyncScriptsByCategory) { const options = this.options; const scripts = []; let name; for (let urlOrFile of urlOrFiles) { if (typeof urlOrFile == 'string' && urlOrFile.includes('http')) { scripts.push(urlOrFile); } else { let script = urlOrFile; if (Array.isArray(urlOrFile)) { script = urlOrFile[1]; } script = await loadScript(script, options, true); if (script.setUp) { if (!options.preScript) { options.preScript = []; } options.preScript.push(script.setUp); } if (script.tearDown) { if (!options.postScript) { options.postScript = []; } options.postScript.push(script.tearDown); } // here, url is the filename containing the script, and test the callable. if (script.test) { scripts.push(script.test); } else { scripts.push(script); } } if (!name) { name = urlOrFile; } } return this.runByScript( run(scripts), name, scriptsByCategory, asyncScriptsByCategory ); } /** * Stop the engine. Will stop everything started in start(). * * Stop XVFB (if it is configured) * * Remove connectivity * * Stop the extension server */ async stop() { const options = this.options; await removeConnectivity(options); if (options.androidRooted && this.rooted) { await this.rooted.stop(); } if (options.safari && options.safari.useSimulator) { try { await command('pkill -x Simulator', { shell: true }); } catch { log.error('Could not stop the iOS simulator'); } } if ( !options.webpagereplay && options.android && options.android.gnirehtet === true && this.gnirehtet ) { await this.gnirehtet.stop(); } return this.myXVFB.stop(); } }