@wdio/devtools-service
Version:
A WebdriverIO service that allows you to run Chrome DevTools commands in your tests
235 lines (234 loc) • 9.61 kB
JavaScript
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 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';
const log = logger('@wdio/devtools-service:TraceGatherer');
export default class TraceGatherer extends EventEmitter {
_session;
_page;
_driver;
_failingFrameLoadIds = [];
_pageLoadDetected = false;
_networkListeners = {};
_frameId;
_loaderId;
_pageUrl;
_networkStatusMonitor;
_networkMonitor;
_protocolSession;
_trace;
_traceStart;
_clickTraceTimeout;
_waitConditionPromises = [];
constructor(_session, _page, _driver) {
super();
this._session = _session;
this._page = _page;
this._driver = _driver;
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) {
/**
* 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) {
if (!this.isTracing) {
return;
}
/**
* page load failed, cancel tracing
*/
if (this._failingFrameLoadIds.includes(msgObj.frame.id)) {
delete this._traceStart;
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 (
// we already detected a frameId before
this._frameId ||
// the event was thrown for a sub frame (e.g. iframe)
msgObj.frame.parentId ||
// we don't support the url of given frame
!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) {
const frame = request.frame();
if (frame) {
this._failingFrameLoadIds.push(frame._id);
}
}
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.data.frames?.find((frame) => !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.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.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();
});
}
}