import logger from '@wdio/logger' import type { TraceEvent } from '@tracerbench/trace-event' import type { CDPSession } from 'puppeteer-core/lib/esm/puppeteer/api/CDPSession.js' import type { Page } from 'puppeteer-core/lib/esm/puppeteer/api/Page.js' import type { TracingOptions } from 'puppeteer-core/lib/esm/puppeteer/cdp/Tracing.js' import type { RequestPayload } from './handler/network.js' 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 type { DevtoolsConfig, EnablePerformanceAuditsOptions, FormFactor, GathererDriver, PWAAudits } from './types.js' import type { CDPSessionOnMessageObject } from './gatherer/devtools.js' import DevtoolsGatherer from './gatherer/devtools.js' import Auditor from './auditor.js' import PWAGatherer from './gatherer/pwa.js' import TraceGatherer from './gatherer/trace.js' const log = logger('@wdio/lighthouse-service:CommandHandler') const TRACE_COMMANDS = ['click', 'navigateTo', 'url'] function isCDPSessionOnMessageObject( data: unknown ): data is CDPSessionOnMessageObject { return ( data !== null && typeof data === 'object' && Object.prototype.hasOwnProperty.call(data, 'params') && Object.prototype.hasOwnProperty.call(data, 'method') ) } export default class CommandHandler { private _isTracing = false private _networkHandler: NetworkHandler private _traceEvents?: TraceEvent[] private _shouldRunPerformanceAudits = false private _cacheEnabled?: boolean private _cpuThrottling?: number private _networkThrottling?: keyof typeof NETWORK_STATES private _formFactor?: FormFactor private _traceGatherer?: TraceGatherer private _devtoolsGatherer?: DevtoolsGatherer private _pwaGatherer?: PWAGatherer constructor ( private _session: CDPSession, private _page: Page, private _driver: GathererDriver, private _options: DevtoolsConfig, private _browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser ) { 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 as keyof CommandHandler].bind(this) )) this._devtoolsGatherer = new DevtoolsGatherer() _session.on('*', this._propagateWSEvents.bind(this)) } /** * Start tracing the browser. You can optionally pass in custom tracing categories and the * sampling frequency. */ startTracing ({ categories = DEFAULT_TRACING_CATEGORIES, path, screenshots = true }: TracingOptions = {}) { 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()) this._isTracing = false } catch (err) { throw new Error(`Couldn't parse trace events: ${(err as Error).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) as RequestPayload[] 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 }: EnablePerformanceAuditsOptions = 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 } /** * helper method to set throttling profile */ async setThrottlingProfile( networkThrottling = DEFAULT_THROTTLE_STATE.networkThrottling, cpuThrottling: number = DEFAULT_THROTTLE_STATE.cpuThrottling, cacheEnabled: boolean = 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?: PWAAudits[]) { const auditor = new Auditor() const artifacts = await this._pwaGatherer!.gatherData() return auditor._auditPWA(artifacts, auditsToBeRun) } private _propagateWSEvents (data: unknown) { 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 () { /** * enable domains for client */ await Promise.all(['Page', 'Network', 'Runtime'].map( (domain) => Promise.all([ this._session?.send(`${domain}.enable` as 'Page.enable' | 'Network.enable' | 'Runtime.enable'), ]) )) } _beforeCmd (commandName: string, params: unknown[]) { 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] as string : CLICK_TRANSITION return this._traceGatherer.startTracing(url) } _afterCmd (commandName: string) { 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 as WebdriverIO.Browser) }) this._traceGatherer.once('tracingError', (err: Error) => { const auditor = new Auditor() auditor.updateCommands(this._browser as WebdriverIO.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() }) }) } }