playwright-performance-reporter
Version:
Measure and publish performance metrics from browser dev-tools when running playwright
246 lines (245 loc) • 8.08 kB
JavaScript
import CDP from 'chrome-remote-interface';
import { Lock, Logger, } from '../../helpers/index.js';
import { AllPerformanceMetrics, HeapDump, TotalJsHeapSize, UsedJsHeapSize, } from './observers/index.js';
export class ChromiumDevelopmentTools {
options;
/**
* Chrome dev tools protocol client
*/
clients = {};
/**
* Chrome dev tools target for metadata extraction
*/
targets = {};
/**
* Lock to indicate if connection request is going on
*/
connectLock = new Lock();
/**
* Indicates if all options are correctly set for connection
*/
areClientOptionsValid = false;
/**
* @inheritdoc
*/
constructor(options) {
this.options = options;
}
/**
* @inheritdoc
*/
async connect() {
let unlockCallback;
while (!unlockCallback) {
unlockCallback = this.connectLock.lock();
}
try {
const customOptions = this.buildOptions();
const targetList = await CDP.List(customOptions);
await Promise.allSettled(targetList.map(async (target) => this.connectToTarget(target)));
}
catch { }
unlockCallback();
}
/**
* @inheritdoc
*/
async getMetric(metric, hookOrder) {
return new Promise(async (resolve) => {
let newConnectionRequests;
if (this.connectLock.isLocked()) {
await this.connectLock.notifyOnUnlock();
}
else {
newConnectionRequests = this.connect();
}
const currentAvailableTargets = Object.keys(this.clients);
const targetMetric = {};
const metricRequests = [];
// Get metrics from available targets
metricRequests.push(...currentAvailableTargets.map(async (targetId) => this.runPredefinedMetricFetch(targetId, targetMetric, metric, hookOrder)));
// Check if new targets were yielded from connection
await newConnectionRequests;
const newTargets = Object.keys(this.clients).filter(targetId => !currentAvailableTargets.includes(targetId));
if (newTargets.length > 0) {
metricRequests.push(...newTargets.map(async (targetId) => this.runPredefinedMetricFetch(targetId, targetMetric, metric, hookOrder)));
}
// Wait for metric request to be done and fill targets with metadata
await Promise.allSettled(metricRequests);
for (const targetId of Object.keys(targetMetric)) {
Object.assign(targetMetric[targetId], { ...this.targets[targetId] });
}
resolve(Object.values(targetMetric));
});
}
/**
* @inheritdoc
*/
async runCustomObserver(customMetric, hookOrder) {
return new Promise(async (resolve) => {
const targetMetric = [];
await this.connect();
await Promise.allSettled(Object.keys(this.clients).map(async (targetId) => {
const newTargetMetric = {
...this.targets[targetId],
metric: {},
};
const client = this.clients[targetId];
if (!client) {
return;
}
await this.runPlugins(targetId, customMetric);
await customMetric[hookOrder](newTargetMetric.metric, client);
targetMetric.push(newTargetMetric);
}));
resolve(targetMetric);
});
}
/**
* Cleans up client connection.
* If `targetId` is not provided, then all clients are destroyed.
*
* @param {string=} targetId id to indicate which client to destroy
*/
async destroy(targetId) {
const listOfClients = [];
if (targetId) {
listOfClients.push(targetId);
}
else {
listOfClients.push(...Object.keys(this.clients));
}
for (const id of listOfClients) {
const client = this.clients[id];
if (!client) {
continue;
}
try {
client.send('IO.close', () => { });
}
catch { }
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete this.clients[id];
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete this.targets[id];
}
}
/**
* @inheritdoc
*/
getBrowserName() {
return 'chromium';
}
/**
* Fetch metric for a target
*
* @param targetId target to fill
* @param targetMetric mapping of all targets
* @param metric type of metric
* @param hookOrder hook order
*/
async runPredefinedMetricFetch(targetId, targetMetric, metric, hookOrder) {
const newTargetMetric = {
metric: {},
};
const mapping = this.mapMetric(metric);
const client = this.clients[targetId];
if (!mapping || !client) {
return;
}
await this.runPlugins(targetId, mapping);
await mapping[hookOrder](newTargetMetric.metric, client);
targetMetric[targetId] = newTargetMetric;
}
/**
* Builds options object to use for CDP
*
* @param {string=} targetId id to specify target to run CDP on
*/
buildOptions(targetId) {
const options = {
host: 'localhost',
port: 9222,
};
let foundPort = false;
for (const argument of (this.options.launchOptions?.args ?? [])) {
if (argument.includes('--remote-debugging-port')) {
const port = argument.split('=')[1].trim();
options.port = Number(port);
foundPort = true;
}
}
if (!this.areClientOptionsValid && foundPort) {
Logger.info('Port for Chromium found', options.port);
this.areClientOptionsValid = true;
}
else if (!this.areClientOptionsValid && !foundPort) {
Logger.error('Port for Chromium not found. Metrics fetch will not work!');
}
if (targetId) {
options.target = targetId;
}
return options;
}
/**
* Check client to target and create new connection if not available
*
* @param target reference to run CDP commands on
*/
async connectToTarget(target) {
if (this.clients[target.id]) {
this.targets[target.id] = target;
return;
}
try {
const customOptions = this.buildOptions(target.id);
this.clients[target.id] = await CDP(customOptions);
this.targets[target.id] = target;
this.clients[target.id].on('disconnect', async () => {
await this.destroy(target.id);
});
}
catch { }
}
/**
* Runs every plugin for a metric.
*
* @param targetId target to fill
* @param metric
*/
async runPlugins(targetId, metric) {
const client = this.clients[targetId];
if (!client) {
return;
}
for (const plugin of metric.plugins) {
try {
// Plugins can depend on the execution order
// eslint-disable-next-line no-await-in-loop
await plugin(client);
}
catch { }
}
}
/**
* Provide observer to collect metric
*
* @param metric observer to create
*/
mapMetric(metric) {
switch (metric) {
case 'usedJsHeapSize': {
return new UsedJsHeapSize();
}
case 'totalJsHeapSize': {
return new TotalJsHeapSize();
}
case 'allPerformanceMetrics': {
return new AllPerformanceMetrics();
}
case 'heapDump': {
return new HeapDump();
}
}
}
}