@wdio/devtools-service
Version:
A WebdriverIO service that allows you to run Chrome DevTools commands in your tests
194 lines (193 loc) • 7.55 kB
JavaScript
import fs from 'node:fs';
import path from 'node:path';
import { EventEmitter } from 'node:events';
import { transformAsync as babelTransform } from '@babel/core';
import babelPluginIstanbul from 'babel-plugin-istanbul';
import libCoverage from 'istanbul-lib-coverage';
import libReport from 'istanbul-lib-report';
import reports from 'istanbul-reports';
import logger from '@wdio/logger';
const log = logger('@wdio/devtools-service:CoverageGatherer');
const MAX_WAIT_RETRIES = 10;
const CAPTURE_INTERVAL = 1000;
const DEFAULT_REPORT_TYPE = 'json';
const DEFAULT_REPORT_DIR = path.join(process.cwd(), 'coverage');
export default class CoverageGatherer extends EventEmitter {
_page;
_options;
_coverageLogDir;
_coverageMap;
_captureInterval;
_client;
constructor(_page, _options) {
super();
this._page = _page;
this._options = _options;
this._coverageLogDir = path.resolve(process.cwd(), this._options.logDir || DEFAULT_REPORT_DIR);
this._page.on('load', this._captureCoverage.bind(this));
}
async init() {
this._client = await this._page.target().createCDPSession();
await this._client.send('Fetch.enable', {
patterns: [{ requestStage: 'Response' }]
});
this._client.on('Fetch.requestPaused', this._handleRequests.bind(this));
}
async _handleRequests(event) {
const { requestId, request, responseStatusCode = 200 } = event;
if (!this._client) {
return;
}
/**
* continue with requests that aren't JS files
*/
let skipCoverageFlag = false;
if (!request.url.endsWith('.js')) {
skipCoverageFlag = true;
}
/**
* continue with requests that are part of exclude patterns
*/
if (this._options.exclude) {
for (const excludeFile of this._options.exclude) {
if (request.url.match(excludeFile)) {
skipCoverageFlag = true;
break;
}
}
}
if (skipCoverageFlag) {
return this._client.send('Fetch.continueRequest', { requestId }).catch(/* istanbul ignore next */ (err) => log.debug(err.message));
}
/**
* fetch original response
*/
const { body, base64Encoded } = await this._client.send('Fetch.getResponseBody', { requestId });
const inputCode = base64Encoded ? Buffer.from(body, 'base64').toString('utf8') : body;
const url = new URL(request.url);
const fullPath = path.join(this._coverageLogDir, 'files', url.hostname, url.pathname);
const dirPath = path.dirname(fullPath);
/**
* create dir if not existing
*/
if (!fs.existsSync(dirPath)) {
await fs.promises.mkdir(dirPath, { recursive: true });
}
await fs.promises.writeFile(fullPath, inputCode, 'utf-8');
try {
const result = await babelTransform(inputCode, {
auxiliaryCommentBefore: ' istanbul ignore next ',
babelrc: false,
caller: {
name: '@wdio/devtools-service'
},
configFile: false,
filename: path.join(url.hostname, url.pathname),
plugins: [
[
babelPluginIstanbul,
{
compact: false,
exclude: [],
extension: false,
useInlineSourceMaps: false,
},
],
],
sourceMaps: false
});
return this._client.send('Fetch.fulfillRequest', {
requestId,
responseCode: responseStatusCode,
/** do not mock body if it's undefined */
body: !result ? undefined : Buffer.from(result.code, 'utf8').toString('base64')
});
}
catch (err) {
log.warn(`Couldn't instrument file due to: ${err.stack}`);
return this._client.send('Fetch.fulfillRequest', {
requestId,
responseCode: responseStatusCode,
body: inputCode
});
}
}
_clearCaptureInterval() {
if (!this._captureInterval) {
return;
}
clearInterval(this._captureInterval);
delete this._captureInterval;
}
_captureCoverage() {
if (this._captureInterval) {
this._clearCaptureInterval();
}
this._captureInterval = setInterval(async () => {
log.info('capturing coverage data');
try {
const globalCoverageVar = await this._page.evaluate(
/* istanbul ignore next */
() => window['__coverage__']);
this._coverageMap = libCoverage.createCoverageMap(globalCoverageVar);
log.info(`Captured coverage data of ${this._coverageMap.files().length} files`);
}
catch (err) {
log.warn(`Couldn't capture data: ${err.message}`);
this._clearCaptureInterval();
}
}, CAPTURE_INTERVAL);
}
async _getCoverageMap(retries = 0) {
/* istanbul ignore if */
if (retries > MAX_WAIT_RETRIES) {
return Promise.reject(new Error('Couldn\'t capture coverage data for page'));
}
if (!this._coverageMap) {
log.info('No coverage data collected, waiting...');
await new Promise((resolve) => setTimeout(resolve, CAPTURE_INTERVAL));
return this._getCoverageMap(++retries);
}
return this._coverageMap;
}
async logCoverage() {
this._clearCaptureInterval();
// create a context for report generation
const coverageMap = await this._getCoverageMap();
const context = libReport.createContext({
dir: this._coverageLogDir,
// The summarizer to default to (may be overridden by some reports)
// values can be nested/flat/pkg. Defaults to 'pkg'
defaultSummarizer: 'nested',
coverageMap,
sourceFinder: (source) => {
const f = fs.readFileSync(path.join(this._coverageLogDir, 'files', source.replace(process.cwd(), '')));
return f.toString('utf8');
}
});
// create an instance of the relevant report class, passing the
// report name e.g. json/html/html-spa/text
const report = reports.create(this._options.type || DEFAULT_REPORT_TYPE, this._options.options);
// call execute to synchronously create and write the report to disk
// @ts-ignore
report.execute(context);
}
async getCoverageReport() {
const files = {};
const coverageMap = await this._getCoverageMap();
const summary = libCoverage.createCoverageSummary();
for (const f of coverageMap.files()) {
const fc = coverageMap.fileCoverageFor(f);
const s = fc.toSummary();
files[f] = s;
summary.merge(s);
}
return {
...summary.data,
files: Object.entries(files).reduce((obj, [filename, { data }]) => {
obj[filename.replace(process.cwd(), '')] = data;
return obj;
}, {})
};
}
}