blob: 4cd37db6d01707b6a7d36f0e81418caa9878478c [file] [log] [blame]
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Url suffix of the code coverage service API from which to fetch per-cl
// coverage data.
const COVERAGE_SERVICE_ENDPOINT_SUFFIX = '/coverage/api/coverage-data';
// The gob coverage host which is used to process internal project.
const GOB_COVERAGE_HOST = 'https://gob-coverage.googleplex.com';
// The chromium coverage host which is used to process external project.
const CHROMIUM_COVERAGE_HOST = 'https://findit-for-me.appspot.com';
// Dict of gerrit review host and corresponding code coverage service host
// from which to fetch per-cl coverage data.
const COVERAGE_SERVICE_HOST = {
'chromium-review.googlesource.com': CHROMIUM_COVERAGE_HOST,
'libassistant-internal-review.git.corp.google.com': GOB_COVERAGE_HOST,
'libassistant-internal-review.googlesource.com': GOB_COVERAGE_HOST,
};
// Used to identify host prefixes that should be stripped. This is needed
// so that the plugin can work in different environments, such as 'canary-'.
const HOST_PREFIXES = ['canary-', 'polymer1-', 'polymer2-'];
/**
* Provides APIs to fetch and cache coverage data.
*/
export class CoverageClient {
constructor(plugin) {
this.provideCoverageRanges = this.provideCoverageRanges.bind(this);
this.prefetchCoverageRanges = this.prefetchCoverageRanges.bind(this);
this.provideCoveragePercentages =
this.provideCoveragePercentages.bind(this);
this.plugin = plugin;
// Used to cache coverage config for a project.
this.coverageConfig = {
// Used to validate/invalidate the cache.
project: null,
// Used to indicate an async fetch of per-project configuration, and it
// is exepcted to be resolved to an object defined by:
// https://chromium.googlesource.com/infra/gerrit-plugins/code-coverage/+/refs/heads/master/src/main/java/com/googlesource/chromium/plugins/coverage/GetConfig.java#34
configPromise: null,
}
// Used to cache coverage date for a patchset.
this.coverageData = {
// Used to validate/invalidate the cache.
changeInfo: {
host: null,
project: null,
changeNum: null,
patchNum: null,
},
// Used to indicate that an async fetch of coverage ranges, and it is
// expeccted to be resolved to an object with following format:
// An object whose properties are file paths and corresponding values
// are arrays of coverage ranges with the following format:
// {
// side: 'right',
// type: 'COVERED',
// code_range: {
// start_line: 1,
// end_line: 3,
// },
// };
rangesPromise: null,
// Used to indicate an async fetch of coverage percentages, and it is
// expected to be resolved to an object with following format:
// An object whose properties are file paths and corresponding values
// are also objects that look like:
// {
// absolute: 99, // Coverage percentage of all lines.
// incremental: 75, // Coverage percentages of added lines.
// };
percentagesPromise: null,
};
}
/**
* Gets the normalized host name.
* @param {string} host The host name of the window location.
*/
getNormalizedHost(host) {
for (const prefix of HOST_PREFIXES) {
if (host.startsWith(prefix)) {
host = host.substring(prefix.length);
break;
}
}
return host;
}
/**
* Parses project name from the path name.
*
* The path name is expected to be in one of the following forms:
* '/c/chromium/src/+/1369646'
* '/c/chromium/src/+/1369646/3'
* '/c/chromium/src/+/1369646/3/base/base/test.cc'
* @param {string} pathName The path name of the window location.
* @return {string} Returns current project such as chromium/src if the url
* is valid, otherwise, returns null.
*/
parseProjectFromPathName(pathName) {
if (!pathName.startsWith('/c/')) {
throw new Error(`${pathName} is expected to start with "/c/"`);
}
const indexEnd = pathName.indexOf('/+');
if (indexEnd == -1) {
throw new Error(`${pathName} is expected to contain "/+"`)
}
return pathName.substring(3, indexEnd);
}
/**
* Fetches code coverage data from coverage service for a patchset.
*
* There are two types of data can be fetched: 'lines' or 'percentages'.
* The response of 'lines' fetch looks like:
*
* {
* data: {
* files: [
* {
* path: 'base/task/task_scheduler/priority_queue_unittest.cc',
* lines: [
* {
* line: 168,
* count: 10
* },
* ]
* }
* ]
* }
* }
*
* The response of 'percentages' fetch looks like:
* {
* data: {
* files: [
* {
* path: 'base/files/file_util_unittest.cc',
* absolute_coverage: {
* covered: 2729,
* total: 2744,
* },
* incremental_coverage: {
* covered: 9,
* total: 12,
* },
* }
* ]
* }
* }
* The value of 'incremental_coverage' is null if there are no added lines.
*
* @param {object} changeInfo Has host, project, changeNum and patchNum.
* @param {string} type Type of data to fetch: "lines" or "percentages".
* @return {promise} Resolves to parsed JSON response body if the coverage
* data is successfully retrieved, otherwise, resolves to null.
*/
async fetchCoverageJsonData(changeInfo, type) {
if (type !== 'lines' && type !== 'percentages') {
throw new Error(
'Type is expected to be either "lines" or "percentages"');
}
const params = new URLSearchParams({
host: changeInfo.host,
project: changeInfo.project,
change: changeInfo.changeNum,
patchset: changeInfo.patchNum,
type: type,
format: 'json',
concise: '1',
});
let coverageHost = COVERAGE_SERVICE_HOST[changeInfo.host];
// If the host is not found, use CHROMIUM_COVERAGE_HOST by default.
if (coverageHost === undefined) {
coverageHost = CHROMIUM_COVERAGE_HOST;
}
const endpoint = coverageHost + COVERAGE_SERVICE_ENDPOINT_SUFFIX;
const url = `${endpoint}?${params.toString()}`;
const response = await fetch(url);
const responseJson = await response.json();
if (response.status == 400 &&
responseJson.is_project_supported === false) {
throw new Error(`"${changeInfo.project}" project is not supported ` +
`for code coverage`);
}
if (response.status == 500 &&
responseJson.is_service_enabled === false) {
throw new Error('Code coverage service is temporarily disabled');
}
if (!response.ok) {
throw new Error(`Request code coverage data returned http ` +
`${response.status}`);
}
return responseJson;
}
/**
* Converts the JSON response to coverage ranges needed by coverage layer.
* @param {object} responseJson The JSON response returned from coverage
* service.
* @return {object} Returns an object whose properties are file paths and
* corresponding values are a list of coverage ranges if the JSON
* response has valid format, otherwise, returns null.
*/
convertResponseJsonToCoverageRanges(responseJson) {
if (!responseJson.data) {
throw new Error('Invalid coverage lines response format. Expecting ' +
'"data" property');
}
const responseData = responseJson.data;
if (!responseData.files) {
throw new Error('Invalid coverage lines response format. Expecting ' +
'"files" property');
}
const responseFiles = responseData.files;
let coverageRanges = {};
for (var i = 0; i < responseFiles.length; i++) {
const responseFile = responseFiles[i];
if (!responseFile.path || !responseFile.lines) {
throw new Error('Invalid coverage lines response format. Expecting ' +
'"path" and "lines" properties');
}
coverageRanges[responseFile.path] = new Array();
const responseLines = responseFile.lines;
responseLines.sort((a, b) => (a.line > b.line)? 1 : -1);
let startLine = -1;
let endLine = -1;
let isCovered = null;
for (var j = 0; j < responseLines.length; j++) {
const responseLine = responseLines[j];
if (!responseLine.line || responseLine.count == null) {
throw new Error('Invalid coverage lines response format. ' +
'Expecting "line" and "count" properties');
}
if (startLine != -1 &&
responseLine.line == endLine + 1 &&
isCovered == (responseLine.count > 0)) {
endLine += 1;
continue;
}
if (startLine != -1) {
coverageRanges[responseFile.path].push({
side: 'right',
type: (isCovered? 'COVERED' : 'NOT_COVERED'),
code_range: {
start_line: startLine,
end_line: endLine,
}
});
}
startLine = responseLine.line;
endLine = startLine;
isCovered = (responseLine.count > 0);
}
if (startLine != -1) {
coverageRanges[responseFile.path].push({
side: 'right',
type: (isCovered? 'COVERED' : 'NOT_COVERED'),
code_range: {
start_line: startLine,
end_line: endLine,
}
});
}
}
return coverageRanges;
}
/**
* Fetches code coverage ranges from coverage service for a patchset.
*
* @param {object} changeInfo Has host, project, changeNum and patchNum.
* @return {promise} Resolves to a map of files to coverage ranges if the
* coverage ata is successfully retrieved and parsed, otherwise,
* resolves to null.
*/
async fetchCoverageRanges(changeInfo) {
const responseJson = await this.fetchCoverageJsonData(changeInfo,
'lines');
return this.convertResponseJsonToCoverageRanges(responseJson);
}
/**
* Converts the JSON response to coverage percentages.
* @param {object} responseJson The JSON response returned from coverage
* service.
* @return {object} Returns an object whose properties are file paths and
* corresponding values are objects representing absolute and
* incremental coverage percentages if the JSON response has valid
* format, otherwise, returns null.
*/
convertResponseJsonToCoveragePercentages(responseJson) {
if (!responseJson.data) {
throw new Error('Invalid coverage percentages response format. ' +
'Expecting "data" property');
}
const responseData = responseJson.data;
if (!responseData.files) {
throw new Error('Invalid coverage percentages response format. ' +
'Expecting "files" property');
}
const coveragePercentages = {};
for (let responseFile of responseData.files) {
if (!responseFile.path || !responseFile.absolute_coverage) {
throw new Error('Invalid coverage percentages response format. ' +
'Expecting "path" and "absolute_coverage" ' +
'properties');
}
const fileCov = {
absolute: Math.round(responseFile.absolute_coverage.covered * 100 /
responseFile.absolute_coverage.total),
incremental: null,
};
if (responseFile.incremental_coverage) {
fileCov.incremental = Math.round(
responseFile.incremental_coverage.covered * 100 /
responseFile.incremental_coverage.total);
}
coveragePercentages[responseFile.path] = fileCov;
}
return coveragePercentages;
}
/**
* Fetches code coverage percentages from coverage service for a patchset.
*
* @param {object} changeInfo Has host, project, changeNum and patchNum.
* @return {promise} Resolves to a map of files to coverage percentages if
* coverage data is successfully retrieved and parsed, otherwise,
* resolves to null.
*/
async fetchCoveragePercentages(changeInfo) {
const responseJson = await this.fetchCoverageJsonData(changeInfo,
'percentages');
return this.convertResponseJsonToCoveragePercentages(responseJson);
}
/**
* Fetches code coverage ranges from coverage service for a patchset.
*
* @param {object} changeInfo Has host, project, changeNum and patchNum.
*/
updateCoverageDataIfNecessary(changeInfo) {
if (isNaN(changeInfo.changeNum) || isNaN(changeInfo.patchNum) ||
changeInfo.changeNum <= 0 || changeInfo.patchNum <= 0) {
return;
}
if (JSON.stringify(changeInfo) !==
JSON.stringify(this.coverageData.changeInfo)) {
this.coverageData.changeInfo = changeInfo;
this.coverageData.rangesPromise = this.fetchCoverageRanges(changeInfo);
this.coverageData.rangesPromise.catch((error) => {
console.warn(error);
});
this.coverageData.percentagesPromise = this.fetchCoveragePercentages(
changeInfo);
this.coverageData.percentagesPromise.catch((error) => {
console.warn(error);
})
}
}
/**
* Provides code coverage ranges for a file of a patchset.
* @param {string} changeNum The change number of the patchset.
* @param {string} path The relative path to the file.
* @param {string} basePatchNum The patchset number of the base patchset.
* @param {string} patchNum The patchset number of the patchset.
* @return {object} Returns a list of coverage ranges. On error, it logs the
* error and returns null/undefined.
*/
async provideCoverageRanges(changeNum, path, basePatchNum, patchNum) {
const changeInfo = {
host: this.getNormalizedHost(window.location.host),
project: this.parseProjectFromPathName(window.location.pathname),
changeNum: parseInt(changeNum),
patchNum: parseInt(patchNum),
};
this.updateCoverageDataIfNecessary(changeInfo);
try {
const coverageRanges = await this.coverageData.rangesPromise;
return coverageRanges[path] || [];
} catch(error) {
console.log(error);
return null;
}
}
/**
* Prefetch coverage ranges.
*
* This method is supposed to be triggered by the 'showchange' event.
*
* @param {ChangeInfo} change Info of the current change.
* @param {RevisionInfo} revision Info of the current revision.
*/
prefetchCoverageRanges(change, revision) {
const changeInfo = {
host: this.getNormalizedHost(window.location.host),
project: change.project,
changeNum: parseInt(change._number),
patchNum: parseInt(revision._number),
};
this.updateCoverageDataIfNecessary(changeInfo);
}
/**
* Provides code coverage percentage for a file of a patchset.
* @param {string} changeNum The change number of the patchset.
* @param {string} path The relative path to the file.
* @param {string} patchNum The patchset number of the patchset.
* @param {string} type Type of percentage: "absolute" or "incremental".
* @return {object} Returns an object representing the absolute and
* incremental coverages. On error, it logs the error and returns
* null/undefined.
*/
async provideCoveragePercentages(changeNum, path, patchNum) {
const changeInfo = {
host: this.getNormalizedHost(window.location.host),
project: this.parseProjectFromPathName(window.location.pathname),
changeNum: parseInt(changeNum),
patchNum: parseInt(patchNum),
};
this.updateCoverageDataIfNecessary(changeInfo);
try {
const coveragePercentages = await this.coverageData.percentagesPromise;
return coveragePercentages[path];
} catch(error) {
console.log(error);
return null;
}
}
/**
* Returns whether to show percentage columns for the current change.
* @return {promise<boolean>} Resolves to true if to show the percentage
* columns, otherwise, false.
*/
async showPercentageColumns() {
// This method is expected to be called when percentage columns are
// attached, which means that the current page is at change view and that
// the current project can be parsed from the current URL.
const project = this.parseProjectFromPathName(window.location.pathname);
if (project !== this.coverageConfig.project) {
this.coverageConfig.project = project;
this.coverageConfig.configPromise = this.plugin.restApi().get(
`/projects/${encodeURIComponent(project)}/` +
`${encodeURIComponent(this.plugin.getPluginName())}~config`);
}
try {
const config = await this.coverageConfig.configPromise;
return config && config.enabled;
} catch(error) {
console.log(error);
return false;
}
}
}