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 {
log.debug(`cdp event: ${method} with params ${JSON.stringify(data.params)}`)
} catch {
}
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, () => {
throw new Error(`Couldn't capture performance due to: ${err.message}`)
})
})
return new Promise<void>((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()
})
})
}
}