| // 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; |
| } |
| } |
| } |