@wdio/devtools-service
Version:
A WebdriverIO service that allows you to run Chrome DevTools commands in your tests
286 lines (285 loc) • 12.2 kB
JavaScript
import logger from '@wdio/logger';
import NetworkHandler from './handler/network.js';
import { CLICK_TRANSITION, DEFAULT_THROTTLE_STATE, DEFAULT_TRACING_CATEGORIES, NETWORK_STATES } from './constants.js';
import { sumByKey } from './utils.js';
import { KnownDevices } from 'puppeteer-core';
import DevtoolsGatherer from './gatherer/devtools.js';
import Auditor from './auditor.js';
import PWAGatherer from './gatherer/pwa.js';
import TraceGatherer from './gatherer/trace.js';
import CoverageGatherer from './gatherer/coverage.js';
const log = logger('@wdio/devtools-service:CommandHandler');
const TRACE_COMMANDS = ['click', 'navigateTo', 'url'];
function isCDPSessionOnMessageObject(data) {
return (data !== null &&
typeof data === 'object' &&
Object.prototype.hasOwnProperty.call(data, 'params') &&
Object.prototype.hasOwnProperty.call(data, 'method'));
}
export default class CommandHandler {
_session;
_page;
_driver;
_options;
_browser;
_isTracing = false;
_networkHandler;
_traceEvents;
_shouldRunPerformanceAudits = false;
_cacheEnabled;
_cpuThrottling;
_networkThrottling;
_formFactor;
_traceGatherer;
_devtoolsGatherer;
_coverageGatherer;
_pwaGatherer;
constructor(_session, _page, _driver, _options, _browser) {
this._session = _session;
this._page = _page;
this._driver = _driver;
this._options = _options;
this._browser = _browser;
this._networkHandler = new NetworkHandler(_session);
this._traceGatherer = new TraceGatherer(_session, _page, _driver);
this._pwaGatherer = new PWAGatherer(_session, _page, _driver);
_session.on('Page.loadEventFired', this._traceGatherer.onLoadEventFired.bind(this._traceGatherer));
_session.on('Page.frameNavigated', this._traceGatherer.onFrameNavigated.bind(this._traceGatherer));
_page.on('requestfailed', this._traceGatherer.onFrameLoadFail.bind(this._traceGatherer));
this._pwaGatherer = new PWAGatherer(_session, _page, _driver);
/**
* register browser commands
*/
const commands = Object.getOwnPropertyNames(Object.getPrototypeOf(this)).filter(fnName => fnName !== 'constructor' && !fnName.startsWith('_'));
commands.forEach(fnName => _browser.addCommand(fnName, this[fnName].bind(this)));
this._devtoolsGatherer = new DevtoolsGatherer();
_session.on('*', this._propagateWSEvents.bind(this));
}
/**
* The cdp command is a custom command added to the browser scope that allows you
* to call directly commands to the protocol.
*/
cdp(domain, command, args = {}) {
log.info(`Send command "${domain}.${command}" with args: ${JSON.stringify(args)}`);
return this._session.send(`${domain}.${command}`, args);
}
/**
* Helper method to get the nodeId of an element in the page.
* NodeIds are similar like WebDriver node ids an identifier for a node.
* It can be used as a parameter for other Chrome DevTools methods, e.g. DOM.focus.
*/
async getNodeId(selector) {
const document = await this._session.send('DOM.getDocument');
const { nodeId } = await this._session.send('DOM.querySelector', { nodeId: document.root.nodeId, selector });
return nodeId;
}
/**
* Helper method to get the nodeId of an element in the page.
* NodeIds are similar like WebDriver node ids an identifier for a node.
* It can be used as a parameter for other Chrome DevTools methods, e.g. DOM.focus.
*/
async getNodeIds(selector) {
const document = await this._session.send('DOM.getDocument');
const { nodeIds } = await this._session.send('DOM.querySelectorAll', { nodeId: document.root.nodeId, selector });
return nodeIds;
}
/**
* Start tracing the browser. You can optionally pass in custom tracing categories and the
* sampling frequency.
*/
startTracing({ categories = DEFAULT_TRACING_CATEGORIES, path, screenshots = true } = {}) {
if (this._isTracing) {
throw new Error('browser is already being traced');
}
this._isTracing = true;
this._traceEvents = undefined;
return this._page.tracing.start({ categories, path, screenshots });
}
/**
* Stop tracing the browser.
*/
async endTracing() {
if (!this._isTracing) {
throw new Error('No tracing was initiated, call `browser.startTracing()` first');
}
try {
const traceBuffer = await this._page.tracing.stop();
if (!traceBuffer) {
throw new Error('No tracebuffer captured');
}
this._traceEvents = JSON.parse(traceBuffer.toString('utf8'));
this._isTracing = false;
}
catch (err) {
throw new Error(`Couldn't parse trace events: ${err.message}`);
}
return this._traceEvents;
}
/**
* Returns the tracelogs that was captured within the tracing period.
* You can use this command to store the trace logs on the file system to analyse the trace
* via Chrome DevTools interface.
*/
getTraceLogs() {
return this._traceEvents;
}
/**
* Returns page weight information of the last page load.
*/
getPageWeight() {
const requestTypes = Object.values(this._networkHandler.requestTypes).filter(Boolean);
const pageWeight = sumByKey(requestTypes, 'size');
const transferred = sumByKey(requestTypes, 'encoded');
const requestCount = sumByKey(requestTypes, 'count');
return { pageWeight, transferred, requestCount, details: this._networkHandler.requestTypes };
}
/**
* set flag to run performance audits for page transitions
*/
enablePerformanceAudits({ networkThrottling, cpuThrottling, cacheEnabled, formFactor } = DEFAULT_THROTTLE_STATE) {
if (!NETWORK_STATES[networkThrottling]) {
throw new Error(`Network throttling profile "${networkThrottling}" is unknown, choose between ${Object.keys(NETWORK_STATES).join(', ')}`);
}
if (typeof cpuThrottling !== 'number') {
throw new Error(`CPU throttling rate needs to be typeof number but was "${typeof cpuThrottling}"`);
}
this._networkThrottling = networkThrottling;
this._cpuThrottling = cpuThrottling;
this._cacheEnabled = Boolean(cacheEnabled);
this._formFactor = formFactor;
this._shouldRunPerformanceAudits = true;
}
/**
* custom command to disable performance audits
*/
disablePerformanceAudits() {
this._shouldRunPerformanceAudits = false;
}
/**
* set device emulation
*/
async emulateDevice(device, deviceOptions) {
if (!this._page) {
throw new Error('No page has been captured yet');
}
if (typeof device === 'string') {
const deviceName = device + (deviceOptions?.inLandscape ? ' landscape' : '');
const deviceCapabilities = KnownDevices[deviceName];
if (!deviceCapabilities) {
const deviceNames = Object.values(KnownDevices)
.map((device) => device.name)
.filter((device) => !device.endsWith('landscape'));
throw new Error(`Unknown device, available options: ${deviceNames.join(', ')}`);
}
else {
const osVersion = deviceOptions?.osVersion?.replaceAll('.', '_');
deviceCapabilities.userAgent = deviceCapabilities.userAgent.replace(/(?:Android|iPhone OS)\s?([\d._]+)?/, (match, group1) => {
return osVersion ? match.replace(group1 || '', osVersion) : match;
});
}
return this._page.emulate(deviceCapabilities);
}
return this._page.emulate(device);
}
/**
* helper method to set throttling profile
*/
async setThrottlingProfile(networkThrottling = DEFAULT_THROTTLE_STATE.networkThrottling, cpuThrottling = DEFAULT_THROTTLE_STATE.cpuThrottling, cacheEnabled = DEFAULT_THROTTLE_STATE.cacheEnabled) {
if (!this._page || !this._session) {
throw new Error('No page or session has been captured yet');
}
await this._page.setCacheEnabled(Boolean(cacheEnabled));
await this._session.send('Emulation.setCPUThrottlingRate', { rate: cpuThrottling });
await this._session.send('Network.emulateNetworkConditions', NETWORK_STATES[networkThrottling]);
}
async checkPWA(auditsToBeRun) {
const auditor = new Auditor();
const artifacts = await this._pwaGatherer.gatherData();
return auditor._auditPWA(artifacts, auditsToBeRun);
}
getCoverageReport() {
return this._coverageGatherer.getCoverageReport();
}
async _logCoverage() {
if (this._coverageGatherer) {
await this._coverageGatherer.logCoverage();
}
}
_propagateWSEvents(data) {
if (!isCDPSessionOnMessageObject(data)) {
return;
}
this._devtoolsGatherer?.onMessage(data);
const method = data.method || 'event';
try {
// can fail due to "Cannot convert a Symbol value to a string"
log.debug(`cdp event: ${method} with params ${JSON.stringify(data.params)}`);
}
catch {
// ignore
}
if (this._browser) {
this._browser.emit(method, data.params);
}
}
async _initCommand() {
/**
* register coverage gatherer if options is set by user
*/
if (this._options.coverageReporter?.enable) {
this._coverageGatherer = new CoverageGatherer(this._page, this._options.coverageReporter);
this._browser.addCommand('getCoverageReport', this.getCoverageReport.bind(this));
await this._coverageGatherer.init();
}
/**
* enable domains for client
*/
await Promise.all(['Page', 'Network', 'Runtime'].map((domain) => Promise.all([
this._session?.send(`${domain}.enable`)
])));
}
_beforeCmd(commandName, params) {
const isCommandNavigation = ['url', 'navigateTo'].some(cmdName => cmdName === commandName);
if (!this._shouldRunPerformanceAudits || !this._traceGatherer || this._traceGatherer.isTracing || !TRACE_COMMANDS.includes(commandName)) {
return;
}
/**
* set browser profile
*/
this.setThrottlingProfile(this._networkThrottling, this._cpuThrottling, this._cacheEnabled);
const url = isCommandNavigation
? params[0]
: CLICK_TRANSITION;
return this._traceGatherer.startTracing(url);
}
_afterCmd(commandName) {
if (!this._traceGatherer || !this._traceGatherer.isTracing || !TRACE_COMMANDS.includes(commandName)) {
return;
}
/**
* update custom commands once tracing finishes
*/
this._traceGatherer.once('tracingComplete', (traceEvents) => {
const auditor = new Auditor(traceEvents, this._devtoolsGatherer?.getLogs(), this._formFactor);
auditor.updateCommands(this._browser);
});
this._traceGatherer.once('tracingError', (err) => {
const auditor = new Auditor();
auditor.updateCommands(this._browser, /* istanbul ignore next */ () => {
throw new Error(`Couldn't capture performance due to: ${err.message}`);
});
});
return new Promise((resolve) => {
log.info(`Wait until tracing for command ${commandName} finishes`);
/**
* wait until tracing stops
*/
this._traceGatherer?.once('tracingFinished', async () => {
log.info('Disable throttling');
await this.setThrottlingProfile('online', 0, true);
log.info('continuing with next WebDriver command');
resolve();
});
});
}
}