From b7af57a9798abc08aac24f22b906455822105ecc Mon Sep 17 00:00:00 2001 From: Shane McLaughlin Date: Tue, 19 Sep 2023 12:33:25 -0500 Subject: [PATCH] feat: color nuances for test coverage % (#756) --- src/commands/project/reset/tracking.ts | 4 +- src/formatters/deleteResultFormatter.ts | 5 +- src/formatters/deployResultFormatter.ts | 29 +-- src/formatters/testResultsFormatter.ts | 108 ++++---- src/utils/coverage.ts | 63 +++-- test/utils/coverage.test.ts | 328 +++++++++++++++++++++++- 6 files changed, 432 insertions(+), 105 deletions(-) diff --git a/src/commands/project/reset/tracking.ts b/src/commands/project/reset/tracking.ts index 079ff987..b78b4ece 100644 --- a/src/commands/project/reset/tracking.ts +++ b/src/commands/project/reset/tracking.ts @@ -6,7 +6,6 @@ */ import { Messages } from '@salesforce/core'; -import * as chalk from 'chalk'; import { SourceTracking } from '@salesforce/source-tracking'; import { Flags, @@ -14,6 +13,7 @@ import { orgApiVersionFlagWithDeprecations, requiredOrgFlagWithDeprecations, SfCommand, + StandardColors, } from '@salesforce/sf-plugins-core'; Messages.importMessagesDirectory(__dirname); @@ -53,7 +53,7 @@ export class ResetTracking extends SfCommand { public async run(): Promise { const { flags } = await this.parse(ResetTracking); - if (flags['no-prompt'] || (await this.confirm(chalk.dim(messages.getMessage('promptMessage'))))) { + if (flags['no-prompt'] || (await this.confirm(StandardColors.info(messages.getMessage('promptMessage'))))) { const sourceTracking = await SourceTracking.create({ project: this.project, org: flags['target-org'], diff --git a/src/formatters/deleteResultFormatter.ts b/src/formatters/deleteResultFormatter.ts index 0e2cc05f..5b16f806 100644 --- a/src/formatters/deleteResultFormatter.ts +++ b/src/formatters/deleteResultFormatter.ts @@ -5,10 +5,9 @@ * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ import { ux } from '@oclif/core'; -import * as chalk from 'chalk'; import { DeployResult, FileResponse, RequestStatus } from '@salesforce/source-deploy-retrieve'; import { ensureArray } from '@salesforce/kit'; -import { bold } from 'chalk'; +import { bold, blue } from 'chalk'; import { StandardColors } from '@salesforce/sf-plugins-core'; import { DeleteSourceJson, Formatter, TestLevel } from '../utils/types'; import { sortFileResponses, asRelativePaths } from '../utils/output'; @@ -76,7 +75,7 @@ export class DeleteResultFormatter extends TestResultsFormatter implements Forma } ux.log(''); - ux.styledHeader(chalk.blue('Deleted Source')); + ux.styledHeader(blue('Deleted Source')); ux.table( successes.map((entry) => ({ fullName: entry.fullName, diff --git a/src/formatters/deployResultFormatter.ts b/src/formatters/deployResultFormatter.ts index 7abe6a3a..8b3d3a91 100644 --- a/src/formatters/deployResultFormatter.ts +++ b/src/formatters/deployResultFormatter.ts @@ -22,6 +22,7 @@ import { DeployResultJson, isSdrFailure, isSdrSuccess, TestLevel, Verbosity, For import { generateCoveredLines, getCoverageFormattersOptions, + getCoverageNumbers, mapTestResults, transformCoverageToApexCoverage, } from '../utils/coverage'; @@ -63,20 +64,17 @@ export class DeployResultFormatter extends TestResultsFormatter implements Forma (!this.result.response?.numberTestsTotal && !this.flags['test-level']) || this.flags['test-level'] === 'NoTestRun' ) { - let testsWarn = ''; - - if (this.coverageOptions.reportFormats?.length) { - testsWarn += `\`--coverage-formatters\` was specified but no tests ran.${EOL}`; - } - if (this.junit) { - testsWarn += `\`--junit\` was specified but no tests ran.${EOL}`; - } + const testsWarn = ( + this.coverageOptions.reportFormats?.length ? ['`--coverage-formatters` was specified but no tests ran.'] : [] + ) + .concat(this.junit ? ['`--junit` was specified but no tests ran.'] : []) + .concat([ + 'You can ensure tests run by specifying `--test-level` and setting it to `RunSpecifiedTests`, `RunLocalTests` or `RunAllTestsInOrg`.', + ]); // only emit warning if --coverage-formatters or --junit flags were passed - if (testsWarn.length > 0) { - testsWarn += - 'You can ensure tests run by specifying `--test-level` and setting it to `RunSpecifiedTests`, `RunLocalTests` or `RunAllTestsInOrg`.'; - await Lifecycle.getInstance().emitWarning(testsWarn); + if (testsWarn.length > 1) { + await Lifecycle.getInstance().emitWarning(testsWarn.join(EOL)); } } @@ -185,10 +183,9 @@ export class DeployResultFormatter extends TestResultsFormatter implements Forma ...mapTestResults(ensureArray(runTestResult.failures)), ], codecoverage: ensureArray(runTestResult?.codeCoverage).map((cov): CodeCoverageResult => { - const numLinesUncovered = parseInt(cov.numLocationsNotCovered, 10); const [uncoveredLines, coveredLines] = generateCoveredLines(cov); - const numLocationsNum = parseInt(cov.numLocations, 10); - const numLocationsNotCovered: number = parseInt(cov.numLocationsNotCovered, 10); + const [numLocationsNum, numLinesUncovered] = getCoverageNumbers(cov); + return { // TODO: fix this type in SDR? type: cov.type as 'ApexClass' | 'ApexTrigger', @@ -200,7 +197,7 @@ export class DeployResultFormatter extends TestResultsFormatter implements Forma uncoveredLines, percentage: numLocationsNum > 0 - ? (((numLocationsNum - numLocationsNotCovered) / numLocationsNum) * 100).toFixed() + '%' + ? (((numLocationsNum - numLinesUncovered) / numLocationsNum) * 100).toFixed() + '%' : '', }; }), diff --git a/src/formatters/testResultsFormatter.ts b/src/formatters/testResultsFormatter.ts index 5282ce21..70fc0c94 100644 --- a/src/formatters/testResultsFormatter.ts +++ b/src/formatters/testResultsFormatter.ts @@ -7,7 +7,15 @@ import * as os from 'os'; import { ux } from '@oclif/core'; import { dim, underline } from 'chalk'; -import { CodeCoverageWarnings, DeployResult, Failures, Successes } from '@salesforce/source-deploy-retrieve'; +import { + CodeCoverage, + CodeCoverageWarnings, + DeployResult, + Failures, + MetadataApiDeployStatus, + RunTestResult, + Successes, +} from '@salesforce/source-deploy-retrieve'; import { ensureArray } from '@salesforce/kit'; import { TestLevel, Verbosity } from '../utils/types'; import { tableHeader, error, success, check } from '../utils/output'; @@ -34,11 +42,11 @@ export class TestResultsFormatter { return; } - this.displayVerboseTestFailures(); + displayVerboseTestFailures(this.result.response); if (this.verbosity === 'verbose') { - this.displayVerboseTestSuccesses(); - this.displayVerboseTestCoverage(); + displayVerboseTestSuccesses(this.result.response.details.runTestResult?.successes); + displayVerboseTestCoverage(this.result.response.details.runTestResult?.codeCoverage); } ux.log(); @@ -59,60 +67,58 @@ export class TestResultsFormatter { if (this.flags.verbose) return 'verbose'; return 'normal'; } +} - private displayVerboseTestCoverage(): void { - const codeCoverage = ensureArray(this.result.response.details.runTestResult?.codeCoverage); - if (codeCoverage.length) { - const coverage = codeCoverage.sort((a, b) => (a.name.toUpperCase() > b.name.toUpperCase() ? 1 : -1)); - ux.log(); - ux.log(tableHeader('Apex Code Coverage')); - - ux.table(coverage.map(coverageOutput), { - name: { header: 'Name' }, - numLocations: { header: '% Covered' }, - lineNotCovered: { header: 'Uncovered Lines' }, - }); +const displayVerboseTestSuccesses = (resultSuccesses: RunTestResult['successes']): void => { + const successes = ensureArray(resultSuccesses).sort(testResultSort); + if (successes.length > 0) { + ux.log(); + ux.log(success(`Test Success [${successes.length}]`)); + for (const test of successes) { + const testName = underline(`${test.name}.${test.methodName}`); + ux.log(`${check} ${testName}`); } } +}; - private displayVerboseTestSuccesses(): void { - const successes = ensureArray(this.result.response.details.runTestResult?.successes); - if (successes.length > 0) { - const testSuccesses = sortTestResults(successes); - ux.log(); - ux.log(success(`Test Success [${successes.length}]`)); - for (const test of testSuccesses) { - const testName = underline(`${test.name}.${test.methodName}`); - ux.log(`${check} ${testName}`); - } +/** display the Test failures if there are any testErrors in the mdapi deploy response */ +const displayVerboseTestFailures = (response: MetadataApiDeployStatus): void => { + if (!response.numberTestErrors) return; + const failures = ensureArray(response.details.runTestResult?.failures).sort(testResultSort); + const failureCount = response.details.runTestResult?.numFailures; + ux.log(); + ux.log(error(`Test Failures [${failureCount}]`)); + for (const test of failures) { + const testName = underline(`${test.name}.${test.methodName}`); + ux.log(`• ${testName}`); + ux.log(` ${dim('message')}: ${test.message}`); + if (test.stackTrace) { + const stackTrace = test.stackTrace.replace(/\n/g, `${os.EOL} `); + ux.log(` ${dim('stacktrace')}: ${os.EOL} ${stackTrace}`); } + ux.log(); } +}; - private displayVerboseTestFailures(): void { - if (!this.result.response.numberTestErrors) return; - const failures = ensureArray(this.result.response.details.runTestResult?.failures); - const failureCount = this.result.response.details.runTestResult?.numFailures; - const testFailures = sortTestResults(failures); +/** + * Display the table if there is at least one coverage item in the result + */ +const displayVerboseTestCoverage = (coverage?: CodeCoverage | CodeCoverage[]): void => { + const codeCoverage = ensureArray(coverage); + if (codeCoverage.length) { ux.log(); - ux.log(error(`Test Failures [${failureCount}]`)); - for (const test of testFailures) { - const testName = underline(`${test.name}.${test.methodName}`); - ux.log(`• ${testName}`); - ux.log(` ${dim('message')}: ${test.message}`); - if (test.stackTrace) { - const stackTrace = test.stackTrace.replace(/\n/g, `${os.EOL} `); - ux.log(` ${dim('stacktrace')}: ${os.EOL} ${stackTrace}`); - } - ux.log(); - } + ux.log(tableHeader('Apex Code Coverage')); + + ux.table(codeCoverage.sort(coverageSort).map(coverageOutput), { + name: { header: 'Name' }, + coveragePercent: { header: '% Covered' }, + linesNotCovered: { header: 'Uncovered Lines' }, + }); } -} +}; -function sortTestResults(results: T[]): T[] { - return results.sort((a, b) => { - if (a.methodName === b.methodName) { - return a.name.localeCompare(b.name); - } - return a.methodName.localeCompare(b.methodName); - }); -} +const testResultSort = (a: T, b: T): number => + a.methodName === b.methodName ? a.name.localeCompare(b.name) : a.methodName.localeCompare(b.methodName); + +const coverageSort = (a: CodeCoverage, b: CodeCoverage): number => + a.name.toUpperCase() > b.name.toUpperCase() ? 1 : -1; diff --git a/src/utils/coverage.ts b/src/utils/coverage.ts index 8a10a5c8..483b2f2a 100644 --- a/src/utils/coverage.ts +++ b/src/utils/coverage.ts @@ -18,6 +18,7 @@ import { import { Successes, Failures, CodeCoverage } from '@salesforce/source-deploy-retrieve'; import { ensureArray } from '@salesforce/kit'; import { StandardColors } from '@salesforce/sf-plugins-core'; +import { Chalk } from 'chalk'; export const mapTestResults = (testResults: T[]): ApexTestResultData[] => testResults.map((testResult) => ({ @@ -37,11 +38,10 @@ export const mapTestResults = (testResults: T[]) })); export const generateCoveredLines = (cov: CodeCoverage): [number[], number[]] => { - const numCovered = parseInt(cov.numLocations, 10); - const numUncovered = parseInt(cov.numLocationsNotCovered, 10); + const [lineCount, uncoveredLineCount] = getCoverageNumbers(cov); const uncoveredLines = ensureArray(cov.locationsNotCovered).map((location) => parseInt(location.line, 10)); const minLineNumber = uncoveredLines.length ? Math.min(...uncoveredLines) : 1; - const lines = [...Array(numCovered + numUncovered).keys()].map((i) => i + minLineNumber); + const lines = [...Array(lineCount + uncoveredLineCount).keys()].map((i) => i + minLineNumber); const coveredLines = lines.filter((line) => !uncoveredLines.includes(line)); return [uncoveredLines, coveredLines]; }; @@ -72,53 +72,52 @@ export const getCoverageFormattersOptions = (formatters: string[] = []): Coverag }; export const transformCoverageToApexCoverage = (mdCoverage: CodeCoverage[]): ApexCodeCoverageAggregate => { - const apexCoverage = mdCoverage.map((cov) => { - const numCovered = parseInt(cov.numLocations, 10); - const numUncovered = parseInt(cov.numLocationsNotCovered, 10); + const apexCoverage = mdCoverage.map((cov): ApexCodeCoverageAggregateRecord => { + const [NumLinesCovered, NumLinesUncovered] = getCoverageNumbers(cov); const [uncoveredLines, coveredLines] = generateCoveredLines(cov); - const ac: ApexCodeCoverageAggregateRecord = { + return { ApexClassOrTrigger: { Id: cov.id, Name: cov.name, }, - NumLinesCovered: numCovered, - NumLinesUncovered: numUncovered, + NumLinesCovered, + NumLinesUncovered, Coverage: { coveredLines, uncoveredLines, }, }; - return ac; }); return { done: true, totalSize: apexCoverage.length, records: apexCoverage }; }; export const coverageOutput = ( cov: CodeCoverage -): Pick & { lineNotCovered: string } => { - const numLocationsNum = parseInt(cov.numLocations, 10); - const numLocationsNotCovered: number = parseInt(cov.numLocationsNotCovered, 10); - const color = numLocationsNotCovered > 0 ? StandardColors.error : StandardColors.success; +): Pick & { coveragePercent: string; linesNotCovered: string } => ({ + name: cov.name, + coveragePercent: formatPercent(getCoveragePct(cov)), + linesNotCovered: cov.locationsNotCovered + ? ensureArray(cov.locationsNotCovered) + .map((location) => location.line) + .join(',') + : '', +}); - let pctCovered = 100; - const coverageDecimal: number = parseFloat(((numLocationsNum - numLocationsNotCovered) / numLocationsNum).toFixed(2)); - if (numLocationsNum > 0) { - pctCovered = coverageDecimal * 100; - } - // cov.numLocations = color(`${pctCovered}%`); - const base = { - name: cov.name, - numLocations: color(`${pctCovered}%`), - }; +const color = (percent: number): Chalk => + percent >= 90 ? StandardColors.success : percent >= 75 ? StandardColors.warning : StandardColors.error; - if (!cov.locationsNotCovered) { - return { ...base, lineNotCovered: '' }; - } - const locations = ensureArray(cov.locationsNotCovered); +const formatPercent = (percent: number): string => color(percent)(`${percent}%`); - return { - ...base, - lineNotCovered: locations.map((location) => location.line).join(','), - }; +export const getCoveragePct = (cov: CodeCoverage): number => { + const [lineCount, uncoveredLineCount] = getCoverageNumbers(cov); + const coverageDecimal = parseFloat(((lineCount - uncoveredLineCount) / lineCount).toFixed(2)); + + return lineCount > 0 ? coverageDecimal * 100 : 100; }; + +/** returns the number of total line for which coverage should apply, and the total uncovered line */ +export const getCoverageNumbers = (cov: CodeCoverage): [lineCount: number, uncoveredLineCount: number] => [ + parseInt(cov.numLocations, 10), + parseInt(cov.numLocationsNotCovered, 10), +]; diff --git a/test/utils/coverage.test.ts b/test/utils/coverage.test.ts index f2f42480..7a761c26 100644 --- a/test/utils/coverage.test.ts +++ b/test/utils/coverage.test.ts @@ -7,7 +7,285 @@ import { expect } from 'chai'; import { ApexTestResultOutcome } from '@salesforce/apex-node'; -import { mapTestResults } from '../../src/utils/coverage'; +import { StandardColors } from '@salesforce/sf-plugins-core'; +import { coverageOutput, getCoveragePct, mapTestResults } from '../../src/utils/coverage'; + +// methods are mutating the object instead of returning new ones +function getSampleTestResult() { + return { + codeCoverage: [ + { + id: '01p19000002uDLAAA2', + locationsNotCovered: { + column: '0', + line: '12', + numExecutions: '0', + time: '-1.0', + }, + name: 'PagedResult', + namespace: { + $: { + 'xsi:nil': 'true', + }, + }, + numLocations: '4', + numLocationsNotCovered: '1', + type: 'Class', + }, + { + id: '01p19000002uDLBAA2', + locationsNotCovered: [ + { + column: '0', + line: '26', + numExecutions: '0', + time: '-1.0', + }, + { + column: '0', + line: '31', + numExecutions: '0', + time: '-1.0', + }, + { + column: '0', + line: '78', + numExecutions: '0', + time: '-1.0', + }, + ], + name: 'PropertyController', + namespace: { + $: { + 'xsi:nil': 'true', + }, + }, + numLocations: '44', + numLocationsNotCovered: '3', + type: 'Class', + }, + { + id: '01p19000002uDLCAA2', + name: 'SampleDataController', + namespace: { + $: { + 'xsi:nil': 'true', + }, + }, + numLocations: '34', + numLocationsNotCovered: '0', + type: 'Class', + }, + { + id: '01p19000002uDL8AAM', + name: 'GeocodingService', + namespace: { + $: { + 'xsi:nil': 'true', + }, + }, + numLocations: '36', + numLocationsNotCovered: '0', + type: 'Class', + }, + { + id: '01p19000002uDLAAAN', + locationsNotCovered: { + column: '0', + line: '12', + numExecutions: '0', + time: '-1.0', + }, + name: 'A', + namespace: { + $: { + 'xsi:nil': 'true', + }, + }, + numLocations: '100', + numLocationsNotCovered: '100', + type: 'Class', + }, + { + id: '01p19000002uDLAAAN', + locationsNotCovered: { + column: '0', + line: '12', + numExecutions: '0', + time: '-1.0', + }, + name: 'B', + namespace: { + $: { + 'xsi:nil': 'true', + }, + }, + numLocations: '100', + numLocationsNotCovered: '26', + type: 'Class', + }, + { + id: '01p19000002uDLAABN', + locationsNotCovered: { + column: '0', + line: '12', + numExecutions: '0', + time: '-1.0', + }, + name: 'C', + namespace: { + $: { + 'xsi:nil': 'true', + }, + }, + numLocations: '100', + numLocationsNotCovered: '25', + type: 'Class', + }, + { + id: '01p19000002uDLAABN', + locationsNotCovered: { + column: '0', + line: '12', + numExecutions: '0', + time: '-1.0', + }, + name: 'D', + namespace: { + $: { + 'xsi:nil': 'true', + }, + }, + numLocations: '100', + numLocationsNotCovered: '11', + type: 'Class', + }, + { + id: '01p19000002uDLAABN', + locationsNotCovered: { + column: '0', + line: '12', + numExecutions: '0', + time: '-1.0', + }, + name: 'E', + namespace: { + $: { + 'xsi:nil': 'true', + }, + }, + numLocations: '100', + numLocationsNotCovered: '10', + type: 'Class', + }, + { + id: '01p19000002uDLAACN', + locationsNotCovered: { + column: '0', + line: '12', + numExecutions: '0', + time: '-1.0', + }, + name: 'F', + namespace: { + $: { + 'xsi:nil': 'true', + }, + }, + numLocations: '100', + numLocationsNotCovered: '0', + type: 'Class', + }, + ], + failures: { + id: '01p19000002uDLDAA2', + message: 'System.QueryException: Insufficient permissions: secure query included inaccessible field', + methodName: 'testGetPagedPropertyList', + name: 'TestPropertyController', + namespace: { + $: { + 'xsi:nil': 'true', + }, + }, + packageName: 'TestPropertyController', + stackTrace: + 'Class.PropertyController.getPagedPropertyList: line 52, column 1\nClass.TestPropertyController.testGetPagedPropertyList: line 22, column 1', + time: '604.0', + type: 'Class', + }, + numFailures: '1', + numTestsRun: '7', + successes: [ + { + id: '01p19000002uDL9AAM', + methodName: 'blankAddress', + name: 'GeocodingServiceTest', + namespace: { + $: { + 'xsi:nil': 'true', + }, + }, + time: '26.0', + }, + { + id: '01p19000002uDL9AAM', + methodName: 'errorResponse', + name: 'GeocodingServiceTest', + namespace: { + $: { + 'xsi:nil': 'true', + }, + }, + time: '77.0', + }, + { + id: '01p19000002uDL9AAM', + methodName: 'successResponse', + name: 'GeocodingServiceTest', + namespace: { + $: { + 'xsi:nil': 'true', + }, + }, + time: '63.0', + }, + { + id: '01p19000002uDLDAA2', + methodName: 'testGetPicturesNoResults', + name: 'TestPropertyController', + namespace: { + $: { + 'xsi:nil': 'true', + }, + }, + time: '691.0', + }, + { + id: '01p19000002uDLDAA2', + methodName: 'testGetPicturesWithResults', + name: 'TestPropertyController', + namespace: { + $: { + 'xsi:nil': 'true', + }, + }, + time: '1873.0', + }, + { + id: '01p19000002uDLEAA2', + methodName: 'importSampleData', + name: 'TestSampleDataController', + namespace: { + $: { + 'xsi:nil': 'true', + }, + }, + time: '1535.0', + }, + ], + totalTime: '4952.0', + }; +} describe('coverage utils', () => { describe('mapTestResultsTests', () => { @@ -54,4 +332,52 @@ describe('coverage utils', () => { expect(result.stackTrace).to.be.eq('SomeStackTrace'); }); }); + + describe('coverageOutput', () => { + it('one uncovered line, warning color', () => { + const cov = getSampleTestResult().codeCoverage[0]; + expect(coverageOutput(cov)).to.deep.equal({ + name: cov.name, + coveragePercent: StandardColors.warning('75%'), + linesNotCovered: '12', + }); + }); + it('3 uncovered lines, in the success color', () => { + const cov = getSampleTestResult().codeCoverage[1]; + expect(coverageOutput(cov)).to.deep.equal({ + name: cov.name, + coveragePercent: StandardColors.success('93%'), + linesNotCovered: '26,31,78', + }); + }); + it('all covered', () => { + const cov = getSampleTestResult().codeCoverage[2]; + expect(coverageOutput(cov)).to.deep.equal({ + name: cov.name, + coveragePercent: StandardColors.success('100%'), + linesNotCovered: '', + }); + }); + it('none covered', () => { + const cov = getSampleTestResult().codeCoverage[4]; + expect(coverageOutput(cov)).to.deep.equal({ + name: cov.name, + coveragePercent: StandardColors.error('0%'), + // only 1 shows as uncovered, BUT the numLocations says they all are` + linesNotCovered: '12', + }); + }); + }); + + describe('coverage percent (the number)', () => { + it('1 uncovered of 4', () => { + expect(getCoveragePct(getSampleTestResult().codeCoverage[0])).equal(75); + }); + it('rounds 3 uncovered out of 44 to the nearest integer', () => { + expect(getCoveragePct(getSampleTestResult().codeCoverage[1])).equal(93); + }); + it('all covered', () => { + expect(getCoveragePct(getSampleTestResult().codeCoverage[2])).equal(100); + }); + }); });