import { EventEmitter } from 'node:events'
import NetworkRecorder from 'lighthouse/lighthouse-core/lib/network-recorder.js'
import NetworkMonitor from 'lighthouse/lighthouse-core/gather/driver/network-monitor.js'
import ProtocolSession from 'lighthouse/lighthouse-core/fraggle-rock/gather/session.js'
import { waitForFullyLoaded } from 'lighthouse/lighthouse-core/gather/driver/wait-for-condition.js'
import logger from '@wdio/logger'
import type { Protocol } from 'devtools-protocol'
import type { TraceEvent, TraceEventArgs } from '@tracerbench/trace-event'
import type { HTTPRequest } from 'puppeteer-core/lib/esm/puppeteer/api/HTTPRequest.js'
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 registerPerformanceObserverInPage from '../scripts/registerPerformanceObserverInPage.js'
import {
FRAME_LOAD_START_TIMEOUT, TRACING_TIMEOUT, MAX_TRACE_WAIT_TIME,
CLICK_TRANSITION, NETWORK_RECORDER_EVENTS
} from '../constants.js'
import { isSupportedUrl } from '../utils.js'
import type { GathererDriver } from '../types.js'
const log = logger('@wdio/lighthouse-service:TraceGatherer')
export interface Trace {
traceEvents: TraceEvent[]
frameId?: string
loaderId?: string
pageUrl?: string
traceStart?: number
traceEnd?: number
}
interface StartedInBrowserEvent extends TraceEventArgs {
data: {
frames: {
parent: unknown
processId: number
}[]
}
}
interface NavigationStartEvent extends TraceEventArgs {
data: {
isLoadingMainFrame: boolean
}
}
export interface WaitPromise {
promise: Promise<unknown>
cancel: Function
}
export default class TraceGatherer extends EventEmitter {
private _failingFrameLoadIds: string[] = []
private _pageLoadDetected = false
private _networkListeners: Record<string, (params: unknown) => void> = {}
private _frameId?: string
private _loaderId?: string
private _pageUrl?: string
private _networkStatusMonitor: typeof NetworkRecorder
private _networkMonitor: typeof NetworkMonitor
private _protocolSession: typeof ProtocolSession
private _trace?: Trace
private _traceStart?: number
private _clickTraceTimeout?: NodeJS.Timeout
private _waitConditionPromises: Promise<void>[] = []
constructor (private _session: CDPSession, private _page: Page, private _driver: GathererDriver) {
super()
NETWORK_RECORDER_EVENTS.forEach((method) => {
this._networkListeners[method] = (params) => this._networkStatusMonitor.dispatch({ method, params })
})
this._protocolSession = new ProtocolSession(_session)
this._networkMonitor = new NetworkMonitor(_session)
}
async startTracing (url: string) {
* delete old trace
*/
delete this._trace
* register listener for network status monitoring
*/
this._networkStatusMonitor = new NetworkRecorder()
NETWORK_RECORDER_EVENTS.forEach((method) => {
this._session.on(method, this._networkListeners[method])
})
this._traceStart = Date.now()
log.info(`Start tracing frame with url ${url}`)
await this._driver.beginTrace()
* if this tracing was started from a click transition
* then we want to discard page trace if no load detected
*/
if (url === CLICK_TRANSITION) {
log.info('Start checking for page load for click')
this._clickTraceTimeout = setTimeout(async () => {
log.info('No page load detected, canceling trace')
return this.finishTracing()
}, FRAME_LOAD_START_TIMEOUT)
}
* register performance observer
*/
await this._page.evaluateOnNewDocument(registerPerformanceObserverInPage)
this._waitConditionPromises.push(
waitForFullyLoaded(this._protocolSession, this._networkMonitor, { timedOut: 1 })
)
}
* store frame id of frames that are being traced
*/
async onFrameNavigated (msgObj: Protocol.Page.FrameNavigatedEvent) {
if (!this.isTracing) {
return
}
* page load failed, cancel tracing
*/
if (this._failingFrameLoadIds.includes(msgObj.frame.url)) {
this._waitConditionPromises = []
this._frameId = '"unsuccessful loaded frame"'
this.finishTracing()
this.emit('tracingError', new Error(`Page with url "${msgObj.frame.url}" failed to load`))
if (this._clickTraceTimeout) {
clearTimeout(this._clickTraceTimeout)
}
}
* ignore event if
*/
if (
this._frameId ||
msgObj.frame.parentId ||
!isSupportedUrl(msgObj.frame.url)
) {
log.info(`Ignore navigated frame with url ${msgObj.frame.url}`)
return
}
this._frameId = msgObj.frame.id
this._loaderId = msgObj.frame.loaderId
this._pageUrl = msgObj.frame.url
log.info(`Page load detected: ${this._pageUrl}, set frameId ${this._frameId}, set loaderId ${this._loaderId}`)
* clear click tracing timeout if it's still waiting
*
* the reason we have to tie this to Page.frameNavigated instead of Page.frameStartedLoading
* is because the latter can sometimes occur without the former, which will cause a hang
* e.g. with duolingo's sign-in button
*/
if (this._clickTraceTimeout && !this._pageLoadDetected) {
log.info('Page load detected for click, clearing click trace timeout}')
this._pageLoadDetected = true
clearTimeout(this._clickTraceTimeout)
}
this.emit('tracingStarted', msgObj.frame.id)
}
* once the page load event has fired, we can grab some performance
* metrics and timing
*/
async onLoadEventFired () {
if (!this.isTracing) {
return
}
* Ensure that page is fully loaded and all metrics can be calculated.
*/
const loadPromise = Promise.all(this._waitConditionPromises).then(() => async () => {
* ensure that we trace at least for 5s to ensure that we can
* calculate "interactive"
*/
const minTraceTime = TRACING_TIMEOUT - (Date.now() - (this._traceStart || 0))
if (minTraceTime > 0) {
log.info(`page load happen to quick, waiting ${minTraceTime}ms more`)
await new Promise((resolve) => setTimeout(resolve, minTraceTime))
}
return this.completeTracing()
})
const cleanupFn = await Promise.race([
loadPromise,
this.waitForMaxTimeout()
])
this._waitConditionPromises = []
return cleanupFn()
}
onFrameLoadFail (request: HTTPRequest) {
const frame = request.frame()
if (frame) {
this._failingFrameLoadIds.push(frame.url())
}
}
get isTracing () {
return typeof this._traceStart === 'number'
}
* once tracing has finished capture trace logs into memory
*/
async completeTracing () {
const traceDuration = Date.now() - (this._traceStart || 0)
log.info(`Tracing completed after ${traceDuration}ms, capturing performance data for frame ${this._frameId}`)
* download all tracing data
* in case it fails, continue without capturing any data
*/
try {
const traceEvents = await this._driver.endTrace()
* modify pid of renderer frame to be the same as where tracing was started
* possibly related to https://github.com/GoogleChrome/lighthouse/issues/6968
*/
const startedInBrowserEvt = traceEvents.traceEvents.find(e => e.name === 'TracingStartedInBrowser')
const mainFrame = (
startedInBrowserEvt &&
startedInBrowserEvt.args &&
(startedInBrowserEvt.args as StartedInBrowserEvent).data.frames?.find((frame: { parent?: unknown }) => !frame.parent)
)
if (mainFrame && mainFrame.processId) {
const threadNameEvt = traceEvents.traceEvents.find(e => e.ph === 'R' &&
e.cat === 'blink.user_timing' && e.name === 'navigationStart' && (e.args as NavigationStartEvent).data.isLoadingMainFrame)
if (threadNameEvt) {
log.info(`Replace mainFrame process id ${mainFrame.processId} with actual thread process id ${threadNameEvt.pid}`)
mainFrame.processId = threadNameEvt.pid
} else {
log.info(`Couldn't replace mainFrame process id ${mainFrame.processId} with actual thread process id`)
}
}
this._trace = {
...traceEvents,
frameId: this._frameId,
loaderId: this._loaderId,
pageUrl: this._pageUrl,
traceStart: this._traceStart,
traceEnd: Date.now()
}
this.emit('tracingComplete', this._trace)
this.finishTracing()
} catch (err) {
log.error(`Error capturing tracing logs: ${(err as Error).stack}`)
this.emit('tracingError', err)
return this.finishTracing()
}
}
* clear tracing states and emit tracingFinished
*/
finishTracing () {
log.info(`Tracing for ${this._frameId} completed`)
this._pageLoadDetected = false
* clean up the listeners
*/
NETWORK_RECORDER_EVENTS.forEach(
(method) => this._session.off(method, this._networkListeners[method]))
delete this._networkStatusMonitor
delete this._traceStart
delete this._frameId
delete this._loaderId
delete this._pageUrl
this._failingFrameLoadIds = []
this._waitConditionPromises = []
this.emit('tracingFinished')
}
waitForMaxTimeout (maxWaitForLoadedMs = MAX_TRACE_WAIT_TIME) {
return new Promise(
(resolve) => setTimeout(resolve, maxWaitForLoadedMs)
).then(() => async () => {
log.error('Neither network nor CPU idle time could be detected within timeout, wrapping up tracing')
return this.completeTracing()
})
}
}