| /** @license |
| * 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. |
| */ |
| |
| import {PluginApi} from '@gerritcodereview/typescript-api/plugin'; |
| import { |
| CoverageRange, |
| CoverageType, |
| Side, |
| } from '@gerritcodereview/typescript-api/diff'; |
| import { |
| Category, |
| CheckResult, |
| FetchResponse, |
| ResponseCode, |
| RunStatus, |
| } from '@gerritcodereview/typescript-api/checks'; |
| import { |
| ChangeInfo, |
| RevisionInfo, |
| } from '@gerritcodereview/typescript-api/rest-api'; |
| |
| // 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 GERRIT_TO_COVERAGE_HOST: {[host: string]: string} = { |
| 'chromium-review.git.corp.google.com': CHROMIUM_COVERAGE_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-']; |
| |
| // Bar for low coverage warning, |
| const OVERALL_LOW_COVERAGE_WARNING_BAR = 70; |
| |
| const LOW_COVERAGE_REASON_PREFIXES = [ |
| 'TRIVIAL_CHANGE', 'TESTS_ARE_DISABLED', 'TESTS_IN_SEPARATE_CL', |
| 'HARD_TO_TEST', 'COVERAGE_UNDERREPORTED', 'LARGE_SCALE_REFACTOR', |
| 'EXPERIMENTAL_CODE', 'OTHER' |
| ] |
| |
| declare interface CoverageConfig { |
| project: string; // Used to validate/invalidate the cache. |
| // Used to indicate an async fetch of per-project configuration |
| configPromise: Promise<{enabled: boolean}> | null; |
| } |
| |
| declare interface CoverageChangeInfo { |
| host: string; |
| project: string; |
| changeNum: number; |
| patchNum: number | undefined; |
| } |
| /** |
| * Description of Code coverage for a file. |
| * |
| * @param absolute Coverage percentage of all lines. |
| * @param incremental Coverage percentages of added lines. |
| * @param absolute_unit_tests Coverage percentage of all lines of unittests. |
| * @param incremental_unit_tests Coverage percentages of added unitetest lines. |
| */ |
| export declare interface PercentageData { |
| absolute?: number; |
| incremental?: number; |
| absolute_unit_tests?: number; |
| incremental_unit_tests?: number; |
| } |
| |
| declare interface CoverageData { |
| changeInfo: CoverageChangeInfo; |
| rangesPromise: Promise<{[path: string]: CoverageRange[]} | null>; |
| percentagesPromise: Promise<{[path: string]: PercentageData} | null>; |
| } |
| |
| declare interface LinesCoverage { |
| line: number; |
| count: number; |
| } |
| |
| declare interface PctCoverage { |
| covered: number; |
| total: number; |
| } |
| |
| declare interface CoverageFilesResponse { |
| path: string; |
| lines?: LinesCoverage[]; |
| absolute_coverage?: PctCoverage; |
| incremental_coverage?: PctCoverage; |
| absolute_unit_tests_coverage?: PctCoverage; |
| incremental_unit_tests_coverage?: PctCoverage; |
| } |
| |
| /** |
| * JSON data of coverage. |
| * |
| * @param data Coverage, keyed by file |
| */ |
| export declare interface CoverageResponse { |
| data: {files: CoverageFilesResponse[]}; |
| } |
| |
| /** |
| * Provides APIs to fetch and cache coverage data. |
| */ |
| export class CoverageClient { |
| plugin: PluginApi; |
| |
| coverageConfig: CoverageConfig | null; |
| |
| coverageData: CoverageData | null; |
| |
| constructor(plugin: PluginApi) { |
| this.provideCoverageRanges = this.provideCoverageRanges.bind(this); |
| this.prefetchCoverageRanges = this.prefetchCoverageRanges.bind(this); |
| this.provideCoveragePercentages = |
| this.provideCoveragePercentages.bind(this); |
| |
| this.plugin = plugin; |
| |
| this.coverageConfig = null; |
| this.coverageData = null; |
| } |
| |
| /** |
| * Gets the normalized host name. |
| * |
| * @param host The host name of the window location. |
| */ |
| getNormalizedHost(host: string): string { |
| 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 pathName The path name of the window location. |
| * @return Returns current project such as chromium/src if the url |
| * is valid, otherwise, returns null. |
| */ |
| parseProjectFromPathName(pathName: string): string { |
| 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. |
| * The value of 'incremental_coverage' is null if there are no added lines. |
| */ |
| async fetchCoverageJsonData( |
| changeInfo: CoverageChangeInfo, |
| kind: string |
| ): Promise<CoverageResponse> { |
| if (kind !== 'lines' && kind !== 'percentages') { |
| throw new Error('Kind is expected to be either "lines" or "percentages"'); |
| } |
| if (changeInfo.patchNum === undefined) { |
| throw new Error('Patch number is undefined, cannot get coverage data'); |
| } |
| const params = new URLSearchParams({ |
| // Only googlesource hosts are supported. |
| host: changeInfo.host.replace('git.corp.google', 'googlesource'), |
| project: changeInfo.project, |
| change: changeInfo.changeNum.toString(), |
| patchset: changeInfo.patchNum.toString(), |
| type: kind, |
| format: 'json', |
| concise: '1', |
| }); |
| |
| let coverageHost = GERRIT_TO_COVERAGE_HOST[changeInfo.host]; |
| // If the host is not found, use CHROMIUM_COVERAGE_HOST by default. |
| if (!coverageHost) { |
| 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 responseJson The JSON response returned from coverage |
| * service. |
| * @return 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: CoverageResponse): { |
| [path: string]: CoverageRange[]; |
| } { |
| 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; |
| const coverageRanges: {[path: string]: CoverageRange[]} = {}; |
| |
| for (let 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] = []; |
| const responseLines = responseFile.lines; |
| responseLines.sort((a, b) => (a.line > b.line ? 1 : -1)); |
| let startLine = -1; |
| let endLine = -1; |
| let isCovered = null; |
| for (let 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: Side.RIGHT, |
| type: isCovered ? CoverageType.COVERED : CoverageType.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: Side.RIGHT, |
| type: isCovered ? CoverageType.COVERED : CoverageType.NOT_COVERED, |
| code_range: { |
| start_line: startLine, |
| end_line: endLine, |
| }, |
| }); |
| } |
| } |
| |
| return coverageRanges; |
| } |
| |
| /** |
| * Fetches code coverage ranges from coverage service for a patchset. |
| * |
| * @param changeInfo Has host, project, changeNum and patchNum. |
| * @return 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: CoverageChangeInfo |
| ): Promise<{[path: string]: CoverageRange[]}> { |
| const responseJson = await this.fetchCoverageJsonData(changeInfo, 'lines'); |
| return this.convertResponseJsonToCoverageRanges(responseJson); |
| } |
| |
| /** |
| * Converts the JSON response to coverage percentages. |
| * |
| * @param responseJson The JSON response returned from coverage |
| * service. |
| * @return 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: CoverageResponse): { |
| [path: string]: PercentageData; |
| } { |
| 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: {[path: string]: PercentageData} = {}; |
| for (const responseFile of responseData.files) { |
| // TODO(crbug/1424854): Also check for absolute coverage |
| if (!responseFile.path) { |
| throw new Error( |
| 'Invalid coverage percentages response format. ' + |
| 'Expecting "path" property' |
| ); |
| } |
| |
| const fileCov: PercentageData = {}; |
| |
| if (responseFile.absolute_coverage) { |
| fileCov.absolute = Math.round( |
| (responseFile.absolute_coverage.covered * 100) / |
| responseFile.absolute_coverage.total |
| ); |
| } |
| if (responseFile.incremental_coverage) { |
| fileCov.incremental = Math.round( |
| (responseFile.incremental_coverage.covered * 100) / |
| responseFile.incremental_coverage.total |
| ); |
| } |
| if (responseFile.absolute_unit_tests_coverage) { |
| fileCov.absolute_unit_tests = Math.round( |
| (responseFile.absolute_unit_tests_coverage.covered * 100) / |
| responseFile.absolute_unit_tests_coverage.total |
| ); |
| } |
| if (responseFile.incremental_unit_tests_coverage) { |
| fileCov.incremental_unit_tests = Math.round( |
| (responseFile.incremental_unit_tests_coverage.covered * 100) / |
| responseFile.incremental_unit_tests_coverage.total |
| ); |
| } |
| |
| coveragePercentages[responseFile.path] = fileCov; |
| } |
| |
| return coveragePercentages; |
| } |
| |
| /** |
| * Fetches code coverage percentages from coverage service for a patchset. |
| * |
| * @param changeInfo Has host, project, changeNum and patchNum. |
| * @return Resolves to a map of files to coverage percentages if |
| * coverage data is successfully retrieved and parsed, otherwise, |
| * resolves to null. |
| */ |
| async fetchCoveragePercentages( |
| changeInfo: CoverageChangeInfo |
| ): Promise<{[path: string]: PercentageData}> { |
| const responseJson = await this.fetchCoverageJsonData( |
| changeInfo, |
| 'percentages' |
| ); |
| return this.convertResponseJsonToCoveragePercentages(responseJson); |
| } |
| |
| /** |
| * Fetches code coverage ranges from coverage service for a patchset. |
| * |
| * @param changeInfo Has host, project, changeNum and patchNum. |
| */ |
| updateCoverageDataIfNecessary(changeInfo: CoverageChangeInfo) { |
| if ( |
| changeInfo.patchNum === undefined || |
| isNaN(changeInfo.changeNum) || |
| isNaN(changeInfo.patchNum) || |
| changeInfo.changeNum <= 0 || |
| changeInfo.patchNum <= 0 |
| ) { |
| return; |
| } |
| |
| if ( |
| !this.coverageData || |
| JSON.stringify(changeInfo) !== |
| JSON.stringify(this.coverageData.changeInfo) |
| ) { |
| this.coverageData = { |
| changeInfo, |
| rangesPromise: this.fetchCoverageRanges(changeInfo), |
| percentagesPromise: this.fetchCoveragePercentages(changeInfo), |
| }; |
| this.coverageData.rangesPromise.catch(error => { |
| console.warn(error); |
| }); |
| this.coverageData.percentagesPromise.catch(error => { |
| console.warn(error); |
| }); |
| } |
| } |
| |
| /** |
| * Provides code coverage ranges for a file of a patchset. |
| * |
| * @param changeNum The change number of the patchset. |
| * @param path The relative path to the file. |
| * @param basePatchNum The patchset number of the base patchset. |
| * @param patchNum The patchset number of the patchset. |
| * @return Returns a list of coverage ranges. On error, it logs the |
| * error and returns null/undefined. |
| */ |
| async provideCoverageRanges( |
| changeNum: number, |
| path: string, |
| basePatchNum: number | undefined, |
| patchNum: number | undefined |
| ): Promise<CoverageRange[] | undefined> { |
| const changeInfo: CoverageChangeInfo = { |
| host: this.getNormalizedHost(window.location.host), |
| project: this.parseProjectFromPathName(window.location.pathname), |
| changeNum, |
| patchNum, |
| }; |
| this.updateCoverageDataIfNecessary(changeInfo); |
| try { |
| if (this.coverageData) { |
| const coverageRanges = await this.coverageData.rangesPromise; |
| if (coverageRanges) { |
| return coverageRanges[path] || []; |
| } |
| return []; |
| } else { |
| return undefined; |
| } |
| } catch (error: unknown) { |
| console.info(error); |
| return undefined; |
| } |
| } |
| |
| /** |
| * Prefetch coverage ranges. |
| * |
| * This method is supposed to be triggered by the 'showchange' event. |
| * |
| * @param change Info of the current change. |
| * @param revision Info of the current revision. |
| */ |
| prefetchCoverageRanges(change: ChangeInfo, revision: RevisionInfo) { |
| let patchNum = NaN; |
| if (typeof revision._number === 'number') { |
| patchNum = revision._number; |
| } |
| const changeInfo: CoverageChangeInfo = { |
| host: this.getNormalizedHost(window.location.host), |
| project: change.project, |
| changeNum: change._number, |
| patchNum, |
| }; |
| this.updateCoverageDataIfNecessary(changeInfo); |
| } |
| |
| /** |
| * Provides code coverage percentage for a file of a patchset. |
| * |
| * @param changeNum The change number of the patchset. |
| * @param path The relative path to the file. |
| * @param patchNum The patchset number of the patchset. |
| * @param type Type of percentage: "absolute" or "incremental". |
| * @return Returns an object representing the absolute and |
| * incremental coverages. On error, it logs the error and returns |
| * null/undefined. |
| */ |
| async provideCoveragePercentages( |
| changeNum: string, |
| path: string, |
| patchNum: string |
| ): Promise<PercentageData | null> { |
| const changeInfo: CoverageChangeInfo = { |
| host: this.getNormalizedHost(window.location.host), |
| project: this.parseProjectFromPathName(window.location.pathname), |
| changeNum: Number(changeNum), |
| patchNum: Number(patchNum), |
| }; |
| this.updateCoverageDataIfNecessary(changeInfo); |
| try { |
| if (this.coverageData) { |
| const coveragePercentages = await this.coverageData.percentagesPromise; |
| if (coveragePercentages) { |
| return coveragePercentages[path]; |
| } else { |
| return null; |
| } |
| } else { |
| return null; |
| } |
| } catch (error: unknown) { |
| console.info(error); |
| return null; |
| } |
| } |
| |
| /** |
| * Extracts low coverage reason from commit message. |
| */ |
| getLowCoverageReason(commitMessage?: string): string|undefined { |
| if(!commitMessage){ |
| return undefined; |
| } |
| const re = /Low-Coverage-Reason:(.*)/g; |
| const matches = [...commitMessage.matchAll(re)]; |
| if (matches.length === 0 || matches[0].length === 0) { |
| return undefined; |
| } |
| const m = matches[0]; |
| const reason = m[m.length - 1].toString().trim(); |
| if (!reason) { |
| return undefined; |
| } |
| return reason; |
| } |
| |
| /** |
| * Surfaces a warning if there are files with low coverage in the patchset. |
| * |
| * @param changeNum The change number of the patchset. |
| * @param patchNum The patchset number of the patchset. |
| * @return Returns an object representing the warnings. |
| * On error, it logs the error and returns null/undefined. |
| */ |
| async mayBeShowLowCoverageAlert( |
| changeNum: number, |
| patchNum: number, |
| commitMessage?: string |
| ): Promise<FetchResponse> { |
| const changeInfo: CoverageChangeInfo = { |
| host: this.getNormalizedHost(window.location.host), |
| project: this.parseProjectFromPathName(window.location.pathname), |
| changeNum, |
| patchNum, |
| }; |
| this.updateCoverageDataIfNecessary(changeInfo); |
| try { |
| if (!this.coverageData) { |
| return { |
| // This should never happen |
| responseCode: ResponseCode.OK, |
| runs: [], |
| }; |
| } |
| const coveragePercentages = |
| (await this.coverageData.percentagesPromise) || {}; |
| const reason = this.getLowCoverageReason(commitMessage); |
| const low_coverage_alerts: CheckResult[] = []; |
| for (const file of Object.keys(coveragePercentages)) { |
| const incremental = coveragePercentages[file].incremental; |
| if (incremental !== undefined && incremental < OVERALL_LOW_COVERAGE_WARNING_BAR) { |
| const msg = `Incremental coverage(All tests) for ${file} `.concat( |
| `is ${incremental} which is below the bar. `) |
| const alert = { |
| category: Category.WARNING, |
| summary: `Incremental coverage(All Tests) < ${OVERALL_LOW_COVERAGE_WARNING_BAR}%`, |
| message: msg.concat( |
| 'Please add tests for uncovered lines or add', |
| ' Low-Coverage-Reason:<reason> in change description' |
| ), |
| } |
| // Change alert from WARNING to INFO if a reason is provided |
| if(reason){ |
| alert.category = Category.INFO |
| alert.message = msg.concat('Check skipped because Low-Coverage-Reason provided') |
| } |
| low_coverage_alerts.push(alert); |
| } |
| } |
| const low_coverage_reason_not_formatted_alerts: CheckResult[] = []; |
| console.log(reason) |
| if(reason){ |
| console.log("in reason if block") |
| var isFormatted = false; |
| if (LOW_COVERAGE_REASON_PREFIXES.some(v => reason.startsWith(v))) { |
| isFormatted = true; |
| } |
| console.log(isFormatted); |
| if(!isFormatted) |
| { |
| const msg = `Reason specified for low coverage for this CL is - ${reason} - `.concat( |
| `which is missing the category string. `).concat( |
| `Reason string should begin with one of the following `).concat( |
| `categories - ${LOW_COVERAGE_REASON_PREFIXES.join(', ')}. `).concat( |
| `For e.g. "Low-Coverage-Reason: TRIVIAL_CHANGE This change only `).concat( |
| `adds log statements". See https://bit.ly/46jhjS9 for more info. `).concat( |
| `If you think none of the reason `).concat( |
| `categories are sufficent, please open a bug with Infra>Test>CodeCoverage` |
| ) |
| const alert = { |
| category: Category.WARNING, |
| summary: 'Low-Coverage-Reason footer is not properly formatted', |
| message: msg |
| } |
| low_coverage_reason_not_formatted_alerts.push(alert) |
| } |
| |
| } |
| const responseRuns = [] |
| if (low_coverage_alerts.length > 0) { |
| responseRuns.push( { |
| checkName: 'Code Coverage Check', |
| status: RunStatus.COMPLETED, |
| results: low_coverage_alerts, |
| }) |
| } |
| if (low_coverage_reason_not_formatted_alerts.length > 0) { |
| responseRuns.push({ |
| checkName: 'Low-Coverage-Reason Format Check', |
| status: RunStatus.COMPLETED, |
| results: low_coverage_reason_not_formatted_alerts, |
| }) |
| } |
| return { |
| responseCode: ResponseCode.OK, |
| runs: responseRuns, |
| }; |
| } catch (error: unknown) { |
| console.info(error); |
| return { |
| // TODO: Make sure that a repo not being supported is not treated as an |
| // error. And then report realy errors as WARNING. |
| responseCode: ResponseCode.OK, |
| runs: [], |
| }; |
| } |
| } |
| |
| /** |
| * Returns whether to show percentage columns for the current change. |
| * |
| * @return Resolves to true if to show the percentage |
| * columns, otherwise, false. |
| */ |
| async showPercentageColumns(): Promise<boolean> { |
| // 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 (!this.coverageConfig || project !== this.coverageConfig.project) { |
| this.coverageConfig = { |
| project, |
| configPromise: this.plugin |
| .restApi() |
| .get( |
| `/projects/${encodeURIComponent(project)}/` + |
| `${encodeURIComponent(this.plugin.getPluginName())}~config` |
| ), |
| }; |
| } |
| try { |
| const config = await this.coverageConfig.configPromise; |
| return config !== null && config.enabled; |
| } catch (error: unknown) { |
| console.info(error); |
| return false; |
| } |
| } |
| } |