From be06b6499011c9372f02611cbbebc4f86ae99be3 Mon Sep 17 00:00:00 2001 From: lcampos Date: Fri, 15 Mar 2019 19:11:17 -0700 Subject: [PATCH] Visual display of apex code coverage (#1145) @W-5951754@ --- .../src/codecoverage/colorizer.ts | 191 ++++++++++++++++++ .../src/codecoverage/decorations.ts | 27 +++ .../src/codecoverage/index.ts | 9 + .../src/codecoverage/statusBarToggle.ts | 48 +++++ .../salesforcedx-vscode-apex/src/index.ts | 10 + .../src/messages/i18n.ts | 11 +- .../codecoverage/colorizer.test.ts | 104 ++++++++++ .../apex/test-result-707xx00000000BG.json | 105 ++++++++++ .../tools/testresults/apex/test-run-id.txt | 1 + 9 files changed, 505 insertions(+), 1 deletion(-) create mode 100644 packages/salesforcedx-vscode-apex/src/codecoverage/colorizer.ts create mode 100644 packages/salesforcedx-vscode-apex/src/codecoverage/decorations.ts create mode 100644 packages/salesforcedx-vscode-apex/src/codecoverage/index.ts create mode 100644 packages/salesforcedx-vscode-apex/src/codecoverage/statusBarToggle.ts create mode 100644 packages/salesforcedx-vscode-apex/test/vscode-integration/codecoverage/colorizer.test.ts create mode 100644 packages/system-tests/assets/sfdx-simple/.sfdx/tools/testresults/apex/test-result-707xx00000000BG.json create mode 100644 packages/system-tests/assets/sfdx-simple/.sfdx/tools/testresults/apex/test-run-id.txt diff --git a/packages/salesforcedx-vscode-apex/src/codecoverage/colorizer.ts b/packages/salesforcedx-vscode-apex/src/codecoverage/colorizer.ts new file mode 100644 index 0000000000..0d0380b11e --- /dev/null +++ b/packages/salesforcedx-vscode-apex/src/codecoverage/colorizer.ts @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2019, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { + Range, + TextDocument, + TextEditor, + TextLine, + window, + workspace +} from 'vscode'; +import { nls } from '../messages'; +import { + coveredLinesDecorationType, + uncoveredLinesDecorationType +} from './decorations'; +import { StatusBarToggle } from './statusBarToggle'; + +const apexDirPath = path.join( + workspace!.workspaceFolders![0].uri.fsPath, + '.sfdx', + 'tools', + 'testresults', + 'apex' +); + +export function getLineRange( + document: TextDocument, + lineNumber: number +): Range { + let adjustedLineNumber: number; + let firstLine: TextLine; + try { + adjustedLineNumber = lineNumber - 1; + firstLine = document.lineAt(adjustedLineNumber); + } catch (e) { + throw new Error(nls.localize('colorizer_out_of_sync_code_coverage_data')); + } + + return new Range( + adjustedLineNumber, + firstLine.range.start.character, + adjustedLineNumber, + firstLine.range.end.character + ); +} + +export type CoverageTestResult = { + coverage: { + coverage: CoverageItem[]; + }; +}; + +export type CoverageItem = { + id: string; + name: string; + totalLines: number; + lines: { [key: string]: number }; +}; + +function getTestRunId() { + const testRunIdFile = path.join(apexDirPath, 'test-run-id.txt'); + if (!fs.existsSync(testRunIdFile)) { + throw new Error(nls.localize('colorizer_no_code_coverage_files')); + } + return fs.readFileSync(testRunIdFile, 'utf8'); +} + +function getCoverageData() { + const testRunId = getTestRunId(); + const testResultFilePath = path.join( + apexDirPath, + `test-result-${testRunId}.json` + ); + + if (!fs.existsSync(testResultFilePath)) { + throw new Error( + nls.localize('colorizer_no_code_coverage_on_test_results', testRunId) + ); + } + const testResultOutput = fs.readFileSync(testResultFilePath, 'utf8'); + const codeCoverage = JSON.parse(testResultOutput) as CoverageTestResult; + if (codeCoverage.coverage === undefined) { + throw new Error( + nls.localize('colorizer_no_code_coverage_on_test_results', testRunId) + ); + } + return codeCoverage.coverage ? codeCoverage.coverage.coverage : ''; +} + +function isApexMetadata(filePath: string): boolean { + return filePath.endsWith('.cls') || filePath.endsWith('.trigger'); +} + +function getApexMemberName(filePath: string): string { + if (isApexMetadata(filePath)) { + const filePathWithOutType = filePath.replace(/.cls|.trigger/g, ''); + const separator = process.platform === 'win32' ? '\\' : '/'; + const indexOfLastFolder = filePathWithOutType.lastIndexOf(separator); + return filePathWithOutType.substring(indexOfLastFolder + 1); + } + return ''; +} + +export class CodeCoverage { + private statusBar: StatusBarToggle; + public coveredLines: Range[]; + public uncoveredLines: Range[]; + + constructor(statusBar: StatusBarToggle) { + this.statusBar = statusBar; + this.coveredLines = Array(); + this.uncoveredLines = Array(); + + window.onDidChangeActiveTextEditor(this.onDidChangeActiveTextEditor, this); + this.onDidChangeActiveTextEditor(window.activeTextEditor); + } + + public onDidChangeActiveTextEditor(editor?: TextEditor) { + if (editor && this.statusBar.isHighlightingEnabled) { + this.colorizer(editor); + } + } + + public toggleCoverage() { + if (this.statusBar.isHighlightingEnabled) { + this.statusBar.toggle(false); + this.coveredLines = []; + this.uncoveredLines = []; + + const editor = window.activeTextEditor; + if (editor) { + editor.setDecorations(coveredLinesDecorationType, this.coveredLines); + editor.setDecorations( + uncoveredLinesDecorationType, + this.uncoveredLines + ); + } + } else { + this.colorizer(window.activeTextEditor); + this.statusBar.toggle(true); + } + } + + public colorizer(editor?: TextEditor) { + try { + if (editor && isApexMetadata(editor.document.uri.fsPath)) { + const codeCovArray = getCoverageData() as CoverageItem[]; + const codeCovItem = codeCovArray.find( + covItem => + covItem.name === getApexMemberName(editor.document.uri.fsPath) + ); + + if (!codeCovItem) { + throw new Error( + nls.localize('colorizer_no_code_coverage_current_file') + ); + } + + for (const key in codeCovItem.lines) { + if (codeCovItem.lines.hasOwnProperty(key)) { + if (codeCovItem.lines[key] === 1) { + this.coveredLines.push( + getLineRange(editor.document, Number(key)) + ); + } else { + this.uncoveredLines.push( + getLineRange(editor.document, Number(key)) + ); + } + } + } + + editor.setDecorations(coveredLinesDecorationType, this.coveredLines); + editor.setDecorations( + uncoveredLinesDecorationType, + this.uncoveredLines + ); + } + } catch (e) { + // telemetry + window.showWarningMessage(e.message); + } + } +} diff --git a/packages/salesforcedx-vscode-apex/src/codecoverage/decorations.ts b/packages/salesforcedx-vscode-apex/src/codecoverage/decorations.ts new file mode 100644 index 0000000000..1d23ff2d97 --- /dev/null +++ b/packages/salesforcedx-vscode-apex/src/codecoverage/decorations.ts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2019, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { window } from 'vscode'; + +const lime = (opacity: number): string => `rgba(45, 121, 11, ${opacity})`; +const red = (opacity: number): string => `rgba(253, 72, 73, ${opacity})`; + +export const coveredLinesDecorationType = window.createTextEditorDecorationType( + { + backgroundColor: lime(0.5), + borderRadius: '.2em', + overviewRulerColor: lime(0.5) + } +); + +export const uncoveredLinesDecorationType = window.createTextEditorDecorationType( + { + backgroundColor: red(0.5), + borderRadius: '.2em', + overviewRulerColor: red(0.5) + } +); diff --git a/packages/salesforcedx-vscode-apex/src/codecoverage/index.ts b/packages/salesforcedx-vscode-apex/src/codecoverage/index.ts new file mode 100644 index 0000000000..df5576898b --- /dev/null +++ b/packages/salesforcedx-vscode-apex/src/codecoverage/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright (c) 2019, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +export { CodeCoverage } from './colorizer'; +export { StatusBarToggle } from './statusBarToggle'; diff --git a/packages/salesforcedx-vscode-apex/src/codecoverage/statusBarToggle.ts b/packages/salesforcedx-vscode-apex/src/codecoverage/statusBarToggle.ts new file mode 100644 index 0000000000..740f34ebaf --- /dev/null +++ b/packages/salesforcedx-vscode-apex/src/codecoverage/statusBarToggle.ts @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2019, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { Disposable, StatusBarItem, window } from 'vscode'; +import { nls } from '../messages'; + +export class StatusBarToggle implements Disposable { + private static readonly toggleCodeCovCommand = + 'sfdx.force.apex.toggle.colorizer'; + private static readonly showIcon = '$(tasklist)'; + private static readonly hideIcon = '$(three-bars)'; + private static readonly toolTip = nls.localize( + 'colorizer_statusbar_hover_text' + ); + private isEnabled: boolean; + private statusBarItem: StatusBarItem; + + constructor() { + this.statusBarItem = window.createStatusBarItem(); + this.statusBarItem.command = StatusBarToggle.toggleCodeCovCommand; + this.statusBarItem.text = StatusBarToggle.showIcon; + this.statusBarItem.tooltip = StatusBarToggle.toolTip; + this.statusBarItem.show(); + this.isEnabled = false; + } + + public get isHighlightingEnabled(): boolean { + return this.isEnabled; + } + + public toggle(active: boolean) { + if (active) { + this.statusBarItem.text = StatusBarToggle.hideIcon; + this.isEnabled = true; + } else { + this.statusBarItem.text = StatusBarToggle.showIcon; + this.isEnabled = false; + } + } + + public dispose() { + this.statusBarItem.dispose(); + } +} diff --git a/packages/salesforcedx-vscode-apex/src/index.ts b/packages/salesforcedx-vscode-apex/src/index.ts index 4e6e1ff1db..07f0567a97 100644 --- a/packages/salesforcedx-vscode-apex/src/index.ts +++ b/packages/salesforcedx-vscode-apex/src/index.ts @@ -10,6 +10,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; import { LanguageClient } from 'vscode-languageclient/lib/main'; +import { CodeCoverage, StatusBarToggle } from './codecoverage'; import { forceApexTestClassRunCodeAction, forceApexTestClassRunCodeActionDelegate, @@ -109,6 +110,14 @@ export async function activate(context: vscode.ExtensionContext) { function registerCommands( extensionContext: vscode.ExtensionContext ): vscode.Disposable { + // Colorize code coverage + const statusBarToggle = new StatusBarToggle(); + const colorizer = new CodeCoverage(statusBarToggle); + const forceApexToggleColorizerCmd = vscode.commands.registerCommand( + 'sfdx.force.apex.toggle.colorizer', + () => colorizer.toggleCoverage() + ); + // Customer-facing commands const forceApexTestClassRunDelegateCmd = vscode.commands.registerCommand( 'sfdx.force.apex.test.class.run.delegate', @@ -135,6 +144,7 @@ function registerCommands( forceApexTestMethodRunCodeAction ); return vscode.Disposable.from( + forceApexToggleColorizerCmd, forceApexTestLastClassRunCmd, forceApexTestClassRunCmd, forceApexTestClassRunDelegateCmd, diff --git a/packages/salesforcedx-vscode-apex/src/messages/i18n.ts b/packages/salesforcedx-vscode-apex/src/messages/i18n.ts index ad307d437f..fb3c041013 100644 --- a/packages/salesforcedx-vscode-apex/src/messages/i18n.ts +++ b/packages/salesforcedx-vscode-apex/src/messages/i18n.ts @@ -40,5 +40,14 @@ export const messages = { client_name: 'Apex Language Server', cannot_determine_workspace: - 'Unable to determine workspace folders for workspace' + 'Unable to determine workspace folders for workspace', + colorizer_no_code_coverage_on_project: + 'No test run information was found for this project. Set "salesforcedx-vscode-core.retrieve-test-code-coverage": true in your user or workspace settings, then run Apex tests from the Apex Tests sidebar or using the Run Tests or Run All Tests code lens within a test class file.', + colorizer_no_code_coverage_on_test_results: + 'No code coverage information was found for test run %s. Set "salesforcedx-vscode-core.retrieve-test-code-coverage": true in your user or workspace settings, then run Apex tests from the Apex Tests sidebar or using the Run Tests or Run All Tests code lens within a test class file.', + colorizer_out_of_sync_code_coverage_data: + 'It looks like this file has been updated, please re-run your Apex test to update code coverage numbers', + colorizer_no_code_coverage_current_file: + 'No code coverage information was found for this file. Set "salesforcedx-vscode-core.retrieve-test-code-coverage": true in your user or workspace settings. Then, run Apex tests that include methods in this file from the Apex Tests sidebar or using the Run Tests or Run All Tests code lens within the file.', + colorizer_statusbar_hover_text: 'Highlight Apex Code Coverage' }; diff --git a/packages/salesforcedx-vscode-apex/test/vscode-integration/codecoverage/colorizer.test.ts b/packages/salesforcedx-vscode-apex/test/vscode-integration/codecoverage/colorizer.test.ts new file mode 100644 index 0000000000..01bd038866 --- /dev/null +++ b/packages/salesforcedx-vscode-apex/test/vscode-integration/codecoverage/colorizer.test.ts @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2019, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { expect } from 'chai'; +import { Range, RelativePattern, Uri, window, workspace } from 'vscode'; +import { + CodeCoverage, + getLineRange +} from '../../../src/codecoverage/colorizer'; +import { StatusBarToggle } from '../../../src/codecoverage/statusBarToggle'; + +describe('Code coverage colorizer', () => { + let testCoverage: Uri[]; + + before(async () => { + testCoverage = await workspace.findFiles( + new RelativePattern( + workspace.workspaceFolders![0], + '**/DemoController.cls' + ), + new RelativePattern( + workspace.workspaceFolders![0], + '**/DemoControllerTest.cls' + ) + ); + }); + + it('Should report correct status on statusbaritem', async () => { + const statusBarToggle = new StatusBarToggle(); + const colorizer = new CodeCoverage(statusBarToggle); + + expect(statusBarToggle.isHighlightingEnabled).to.equal(false); + colorizer.toggleCoverage(); + expect(statusBarToggle.isHighlightingEnabled).to.equal(true); + colorizer.toggleCoverage(); + expect(statusBarToggle.isHighlightingEnabled).to.equal(false); + }); + + it('Should report correct covered and uncovered lines for apex with code coverage', async () => { + const apexDocument = await workspace.openTextDocument(testCoverage[0]); + await window.showTextDocument(apexDocument); + + const statusBarToggle = new StatusBarToggle(); + const colorizer = new CodeCoverage(statusBarToggle); + + expect(statusBarToggle.isHighlightingEnabled).to.equal(false); + // tslint:disable-next-line:no-unused-expression + expect(colorizer.coveredLines).to.be.empty; + // tslint:disable-next-line:no-unused-expression + expect(colorizer.uncoveredLines).to.be.empty; + + colorizer.toggleCoverage(); + expect(colorizer.coveredLines.length).to.equal(6); + expect(colorizer.uncoveredLines.length).to.equal(1); + const uncovered = Array(); + uncovered.push(getLineRange(apexDocument, 5)); + expect(uncovered).to.deep.equal(colorizer.uncoveredLines); + const covered = Array(); + covered.push(getLineRange(apexDocument, 10)); + covered.push(getLineRange(apexDocument, 12)); + covered.push(getLineRange(apexDocument, 14)); + covered.push(getLineRange(apexDocument, 16)); + covered.push(getLineRange(apexDocument, 19)); + covered.push(getLineRange(apexDocument, 27)); + expect(covered).to.deep.equal(colorizer.coveredLines); + expect(statusBarToggle.isHighlightingEnabled).to.equal(true); + + colorizer.toggleCoverage(); + expect(statusBarToggle.isHighlightingEnabled).to.equal(false); + // tslint:disable-next-line:no-unused-expression + expect(colorizer.coveredLines).to.be.empty; + // tslint:disable-next-line:no-unused-expression + expect(colorizer.uncoveredLines).to.be.empty; + }); + + it('Should report no lines for apex with out code coverage', async () => { + const apexTestDoc = await workspace.openTextDocument(testCoverage[1]); + await window.showTextDocument(apexTestDoc); + + const statusBarToggle = new StatusBarToggle(); + const colorizer = new CodeCoverage(statusBarToggle); + + expect(statusBarToggle.isHighlightingEnabled).to.equal(false); + // tslint:disable-next-line:no-unused-expression + expect(colorizer.coveredLines).to.be.empty; + // tslint:disable-next-line:no-unused-expression + expect(colorizer.uncoveredLines).to.be.empty; + + colorizer.toggleCoverage(); + expect(colorizer.coveredLines.length).to.equal(0); + expect(colorizer.uncoveredLines.length).to.equal(0); + + colorizer.toggleCoverage(); + expect(statusBarToggle.isHighlightingEnabled).to.equal(false); + // tslint:disable-next-line:no-unused-expression + expect(colorizer.coveredLines).to.be.empty; + // tslint:disable-next-line:no-unused-expression + expect(colorizer.uncoveredLines).to.be.empty; + }); +}); diff --git a/packages/system-tests/assets/sfdx-simple/.sfdx/tools/testresults/apex/test-result-707xx00000000BG.json b/packages/system-tests/assets/sfdx-simple/.sfdx/tools/testresults/apex/test-result-707xx00000000BG.json new file mode 100644 index 0000000000..058967cbe2 --- /dev/null +++ b/packages/system-tests/assets/sfdx-simple/.sfdx/tools/testresults/apex/test-result-707xx00000000BG.json @@ -0,0 +1,105 @@ +{ + "summary": { + "outcome": "Failed", + "testsRan": 1, + "passing": 0, + "failing": 1, + "skipped": 0, + "passRate": "0%", + "failRate": "100%", + "testStartTime": "Feb 21, 2019 9:56 AM", + "testExecutionTime": "4417 ms", + "testTotalTime": "4417 ms", + "commandTime": "9049 ms", + "hostname": "https://customization-ruby-9544-dev-ed.cs42.my.salesforce.com/", + "orgId": "00D56000000D60yEAC", + "username": "test-use@example.com", + "testRunId": "70xxx00000xxxBG", + "userId": "005xx000001X7NoAAK", + "testRunCoverage": "79%", + "orgWideCoverage": "53%" + }, + "tests": [ + { + "attributes": { + "type": "ApexTestResult", + "url": "/services/data/v45.0/tooling/sobjects/ApexTestResult/07Mxx000002KqzdEAC" + }, + "Id": "07Mxx000002KqzdEAC", + "QueueItemId": "709xx0000008MiOAAU", + "StackTrace": "Class.DemoControllerTest: line 5, column 1\nClass.DemoControllerTest: line 136, column 1", + "Message": "System.QueryException: List has no rows for assignment to SObject", + "AsyncApexJobId": "707xx00000T4kBGAAZ", + "MethodName": "testWithValidAndInvalidRules", + "Outcome": "Fail", + "ApexClass": { + "attributes": { + "type": "ApexClass", + "url": "/services/data/v45.0/tooling/sobjects/ApexClass/01pxx0000000000AAQ" + }, + "Id": "01pxx0000000000AAQ", + "Name": "DemoControllerTest", + "NamespacePrefix": null + }, + "RunTime": 4417, + "FullName": "DemoControllerTest.testWithValidAndInvalidRules" + } + ], + "coverage": { + "coverage": [ + { + "id": "01pxx0000000000AAQ", + "name": "DemoController", + "totalLines": 52, + "lines": { + "5": 0, + "10": 1, + "12": 1, + "14": 1, + "16": 1, + "19": 1, + "27": 1 + }, + "totalCovered": 6, + "coveredPercent": 78.84615384615384 + } + ], + "records": [ + { + "attributes": { + "type": "ApexCodeCoverage", + "url": "/services/data/v45.0/tooling/sobjects/ApexCodeCoverage/714xx0000000000AAL" + }, + "ApexTestClass": { + "attributes": { + "type": "ApexClass", + "url": "/services/data/v45.0/tooling/sobjects/ApexClass/01pxx000000000JAAQ" + }, + "Id": "01pxx000000000JAAQ", + "Name": "TestPermSetAssignment" + }, + "Coverage": { + "coveredLines": [10, 12, 14, 16, 19, 27], + "uncoveredLines": [5] + }, + "TestMethodName": "testWithValidAndInvalidRules", + "NumLinesCovered": 41, + "ApexClassOrTrigger": { + "attributes": { + "type": "Name", + "url": "/services/data/v45.0/tooling/sobjects/ApexClass/01pxx000000000JAAQ" + }, + "Id": "01pxx000000000JAAQ", + "Name": "DemoControllerTest" + }, + "NumLinesUncovered": 11 + } + ], + "summary": { + "totalLines": 6, + "coveredLines": 6, + "testRunCoverage": "79%", + "orgWideCoverage": "53%" + } + } +} diff --git a/packages/system-tests/assets/sfdx-simple/.sfdx/tools/testresults/apex/test-run-id.txt b/packages/system-tests/assets/sfdx-simple/.sfdx/tools/testresults/apex/test-run-id.txt new file mode 100644 index 0000000000..f7a5874911 --- /dev/null +++ b/packages/system-tests/assets/sfdx-simple/.sfdx/tools/testresults/apex/test-run-id.txt @@ -0,0 +1 @@ +707xx00000000BG \ No newline at end of file