UNPKG

browsertime

Version:

Get performance metrics from your web page using Browsertime.

530 lines (479 loc) 16.1 kB
import { arch as _arch } from 'node:os'; import intel from 'intel'; import merge from 'lodash.merge'; import getPort, { portNumbers } from 'get-port'; import { execaCommand as command } from 'execa'; import get from 'lodash.get'; 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 } 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 { IOSRecorder } from '../../video/screenRecording/ios/iosRecorder.js'; import { loadPrePostScripts, loadScript, loadPageCompleteScript } from '../../support/engineUtils.js'; const log = intel.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); if (log.isEnabledFor(log.DEBUG)) { 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 getPort({ port: portNumbers(9222, 9350), host: '127.0.0.1' }); log.debug( `Preparing port ${options.devToolsPort} for devtools on Chrome/Edge` ); options.safariDriverPort = await getPort({ port: portNumbers(1234, 2000), host: '127.0.0.1' }); if (options.browser === 'safari' && options.safari.ios && options.video) { await IOSRecorder.activate(); await delay(2000); } await addConnectivity(options); if (isAndroidConfigured(options)) { const android = new Android(options); await android._init(); this.android = android; if (options.gnirehtet) { 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 = get(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); } } if (options.safari && options.safari.useSimulator) { // 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; } } } } return this.myXVFB.start(); } async runByScript( navigationScript, name, scriptsByCategory, asyncScriptsByCategory ) { const options = this.options; if (Array.isArray(name)) { name = name[0]; } const storageManager = new StorageManager(name, options); let engineDelegate; switch (options.browser) { case 'firefox': { engineDelegate = new Firefox(storageManager, options); break; } case 'chrome': { engineDelegate = new Chromium(storageManager, options); break; } case 'edge': { engineDelegate = new Chromium(storageManager, options); break; } case 'safari': { engineDelegate = new Safari(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(); await engineDelegate.beforeBrowserStart(name, options); 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(); // 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); } } const totalResult = collector.getResults(); // 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; } } 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 } } } if (!options.enableProfileRun) { logResultLogLine(totalResult); } 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() }; } } return totalResult; } 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.gnirehtet && this.gnirehtet) { await this.gnirehtet.stop(); } return this.myXVFB.stop(); } }