Skip to content

Commit

Permalink
Native test coverage implementation
Browse files Browse the repository at this point in the history
Test coverage implementation for the CTest test controller. It relies
on lcov coverage info files being specefied by the user in the
settings.json of the project. Optionally, the user can specify CMake
(utility) targets that should be built before and/or after the tests
are/have been executed. These targets could reasonably zero the
coverage counters (pre) and filter the coverage info files (post).
  • Loading branch information
TSonono committed Nov 4, 2024
1 parent 1f04a3e commit 26e0d22
Show file tree
Hide file tree
Showing 7 changed files with 196 additions and 4 deletions.
3 changes: 3 additions & 0 deletions docs/cmake-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ Options that support substitution, in the table below, allow variable references
| `cmake.saveBeforeBuild` | If `true` (the default), saves open text documents when build or configure is invoked before running CMake. | `true` | no |
| `cmake.sourceDirectory` | A directory or a list of directories where the root `CMakeLists.txt`s are stored. | `${workspaceFolder}` | yes |
| `cmake.testEnvironment` | An object containing `key:value` pairs of environment variables, which will be available when debugging, running and testing with CTest. | `null` (no environment variables) | yes |
| `cmake.preRunCoverageTarget` | Target to build before running tests with coverage using the test explorer | null | no |
| `cmake.postRunCoverageTarget` | Target to build after running tests with coverage using the test explorer | null | no |
| `cmake.coverageInfoFiles` | LCOV coverage info files to be processed after running tests with coverage using the test explorer | [] | yes |

## Variable substitution

Expand Down
21 changes: 21 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3592,6 +3592,27 @@
"default": true,
"description": "%cmake-tools.configuration.cmake.enableAutomaticKitScan.description%",
"scope": "resource"
},
"cmake.preRunCoverageTarget": {
"type": "string",
"default": null,
"description": "%cmake-tools.configuration.cmake.preRunCoverageTarget.description%",
"scope": "resource"
},
"cmake.postRunCoverageTarget": {
"type": "string",
"default": null,
"description": "%cmake-tools.configuration.cmake.postRunCoverageTarget.description%",
"scope": "resource"
},
"cmake.coverageInfoFiles": {
"type": "array",
"items": {
"type": "string"
},
"default": [],
"description": "%cmake-tools.configuration.cmake.coverageInfoFiles.description%",
"scope": "resource"
}
}
},
Expand Down
3 changes: 3 additions & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,9 @@
"cmake-tools.configuration.cmake.automaticReconfigure.description": "Automatically configure CMake project directories when the kit or the configuration preset is changed.",
"cmake-tools.configuration.cmake.pinnedCommands.description":"List of CMake commands to pin.",
"cmake-tools.configuration.cmake.enableAutomaticKitScan.description": "Enable automatic scanning for kits when a kit isn't selected. This will only take affect when CMake Presets aren't being used.",
"cmake-tools.configuration.cmake.preRunCoverageTarget.description": "Target to build before running tests with coverage using the test explorer",
"cmake-tools.configuration.cmake.postRunCoverageTarget.description": "Target to build after running tests with coverage using the test explorer",
"cmake-tools.configuration.cmake.coverageInfoFiles.description": "LCOV coverage info files to be processed after running tests with coverage using the test explorer.",
"cmake-tools.debugger.pipeName.description": "Name of the pipe (on Windows) or domain socket (on Unix) to use for debugger communication.",
"cmake-tools.debugger.clean.description": "Clean prior to configuring.",
"cmake-tools.debugger.configureAll.description": "Configure for all projects.",
Expand Down
20 changes: 19 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,9 @@ export interface ExtensionConfigurationSettings {
automaticReconfigure: boolean;
pinnedCommands: string[];
enableAutomaticKitScan: boolean;
preRunCoverageTarget: string | null;
postRunCoverageTarget: string | null;
coverageInfoFiles: string[];
}

type EmittersOf<T> = {
Expand Down Expand Up @@ -566,6 +569,18 @@ export class ConfigurationReader implements vscode.Disposable {
return this.configData.enableAutomaticKitScan;
}

get preRunCoverageTarget(): string | null {
return this.configData.preRunCoverageTarget;
}

get postRunCoverageTarget(): string | null {
return this.configData.postRunCoverageTarget;
}

get coverageInfoFiles(): string[] {
return this.configData.coverageInfoFiles;
}

private readonly emitters: EmittersOf<ExtensionConfigurationSettings> = {
autoSelectActiveFolder: new vscode.EventEmitter<boolean>(),
defaultActiveFolder: new vscode.EventEmitter<string | null>(),
Expand Down Expand Up @@ -631,7 +646,10 @@ export class ConfigurationReader implements vscode.Disposable {
launchBehavior: new vscode.EventEmitter<string>(),
automaticReconfigure: new vscode.EventEmitter<boolean>(),
pinnedCommands: new vscode.EventEmitter<string[]>(),
enableAutomaticKitScan: new vscode.EventEmitter<boolean>()
enableAutomaticKitScan: new vscode.EventEmitter<boolean>(),
preRunCoverageTarget: new vscode.EventEmitter<string | null>(),
postRunCoverageTarget: new vscode.EventEmitter<string | null>(),
coverageInfoFiles: new vscode.EventEmitter<string[]>()
};

/**
Expand Down
64 changes: 64 additions & 0 deletions src/coverage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import * as vscode from 'vscode';
import { lcovParser } from "@friedemannsommer/lcov-parser";
import * as nls from 'vscode-nls';
import * as logging from '@cmt/logging';

nls.config({ messageFormat: nls.MessageFormat.bundle, bundleFormat: nls.BundleFormat.standalone })();
const localize: nls.LocalizeFunc = nls.loadMessageBundle();

const log = logging.createLogger('ctest');

export async function handleCoverageInfoFiles(run: vscode.TestRun, coverageInfoFiles: string[], coverageData: WeakMap<vscode.FileCoverage, vscode.FileCoverageDetail[]>) {
for (const coverageInfoFile of coverageInfoFiles) {
let contents: Uint8Array;
try {
contents = await vscode.workspace.fs.readFile(vscode.Uri.file(coverageInfoFile));
} catch (e) {
log.warning(localize('test.openCoverageInfoFile', 'Could not open coverage info file: {0}. Skipping...', coverageInfoFile));
return;
}
const sections = await lcovParser({ from: contents });
for (const section of sections) {
const coverage = new vscode.FileCoverage(vscode.Uri.file(section.path),
new vscode.TestCoverageCount(
section.lines.hit,
section.lines.instrumented
), new vscode.TestCoverageCount(
section.branches.hit,
section.branches.instrumented
), new vscode.TestCoverageCount(
section.functions.hit,
section.functions.instrumented
));

const lineBranches = new Map<number, vscode.BranchCoverage[]>();
for (const branch of section.branches.details) {
const branchCoverage = new vscode.BranchCoverage(branch.hit,
new vscode.Position(branch.line - 1, 0), branch.branch);

const curr = lineBranches.get(branch.line);
if (curr === undefined) {
lineBranches.set(branch.line, [branchCoverage]);
} else {
curr.push(branchCoverage);
lineBranches.set(branch.line, curr);
}
}

const declarations: vscode.DeclarationCoverage[] = [];
for (const declaration of section.functions.details) {
declarations.push(new vscode.DeclarationCoverage(declaration.name, declaration.hit,
new vscode.Position(declaration.line - 1, 0)));
}

const statements: vscode.StatementCoverage[] = [];
for (const line of section.lines.details) {
statements.push(new vscode.StatementCoverage(line.hit,
new vscode.Position(line.line - 1, 0),
lineBranches.get(line.line) ?? []));
}
coverageData.set(coverage, [...statements, ...declarations]);
run.addCoverage(coverage);
}
}
}
84 changes: 82 additions & 2 deletions src/ctest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { expandString } from '@cmt/expand';
import * as proc from '@cmt/proc';
import { ProjectController } from '@cmt/projectController';
import { extensionManager } from '@cmt/extension';
import { CMakeProject } from '@cmt/cmakeProject';
import { handleCoverageInfoFiles } from '@cmt/coverage';

nls.config({ messageFormat: nls.MessageFormat.bundle, bundleFormat: nls.BundleFormat.standalone })();
const localize: nls.LocalizeFunc = nls.loadMessageBundle();
Expand Down Expand Up @@ -116,6 +118,14 @@ interface CTestInfo {
version: { major: number; minor: number };
}

interface ProjectCoverageConfig {
project: CMakeProject;
driver: CMakeDriver;
preRunCoverageTarget: string | null;
postRunCoverageTarget: string | null;
coverageInfoFiles: string[];
}

function parseXmlString<T>(xml: string): Promise<T> {
return new Promise((resolve, reject) => {
xml2js.parseString(xml, (err, result) => {
Expand Down Expand Up @@ -227,6 +237,8 @@ export class CTestDriver implements vscode.Disposable {
private readonly testingEnabledEmitter = new vscode.EventEmitter<boolean>();
readonly onTestingEnabledChanged = this.testingEnabledEmitter.event;

private coverageData = new WeakMap<vscode.FileCoverage, vscode.FileCoverageDetail[]>();

dispose() {
this.testingEnabledEmitter.dispose();
this.testsChangedEmitter.dispose();
Expand Down Expand Up @@ -874,7 +886,64 @@ export class CTestDriver implements vscode.Disposable {
return true;
}

private async runTestHandler(request: vscode.TestRunRequest, cancellation: vscode.CancellationToken) {
private async handleCoverageOnProjects(run: vscode.TestRun, projectCoverageConfigs: ProjectCoverageConfig[]) {
for (const projectCoverageConfig of projectCoverageConfigs) {
if (projectCoverageConfig.coverageInfoFiles.length === 0) {
log.warning(localize('test.noCoverageInfoFiles', 'No coverage info files for CMake project {0}. No coverage data will be analyzed for this project.', projectCoverageConfig.project.sourceDir));
continue;
}
const expandedCoverageInfoFiles = await Promise.all(projectCoverageConfig.coverageInfoFiles.map(async (coverageInfoFile: string) => expandString(coverageInfoFile, projectCoverageConfig.driver.expansionOptions)));
await handleCoverageInfoFiles(run, expandedCoverageInfoFiles, this.coverageData);
}
}

private async coverageCTestHelper(tests: vscode.TestItem[], run: vscode.TestRun, cancellation: vscode.CancellationToken): Promise<number> {
const projectRoots = new Set<string>();
for (const test of tests) {
projectRoots.add(this.getTestRootFolder(test));
}

const projectCoverageConfigs: ProjectCoverageConfig[] = [];
for (const folder of projectRoots) {
const project = await this.projectController?.getProjectForFolder(folder);
if (project) {
const driver = await project.getCMakeDriverInstance();
if (driver) {
projectCoverageConfigs.push({
project: project, driver: driver, preRunCoverageTarget: driver.config.preRunCoverageTarget, postRunCoverageTarget: driver.config.postRunCoverageTarget, coverageInfoFiles: driver.config.coverageInfoFiles
});
}
}
}

for (const projectCoverageConfig of projectCoverageConfigs) {
if (projectCoverageConfig.preRunCoverageTarget) {
log.info(localize('test.buildingPreRunCoverageTarget', 'Building the preRunCoverageTarget for project {0} before running tests with coverage.', projectCoverageConfig.project.sourceDir));
const rc = await projectCoverageConfig.project.build([projectCoverageConfig.preRunCoverageTarget]);
if (rc !== 0) {
log.error(localize('test.preRunCoverageTargetFailure', 'Building the preRunCoverageTarget \'{0}\' on project in {1} failed. Skipping running tests.', projectCoverageConfig.preRunCoverageTarget, projectCoverageConfig.project.sourceDir));
run.end();
return 0;
}
}
}
const runResult = await this.runCTestHelper(tests, run, cancellation, undefined, undefined, undefined, false, undefined, RunCTestHelperEntryPoint.TestExplorer);
for (const projectCoverageConfig of projectCoverageConfigs) {
if (projectCoverageConfig.postRunCoverageTarget) {
log.info(localize('test.buildingPostRunCoverageTarget', 'Building the postRunCoverageTarget \'{0}\' for project {1} after the tests have run with coverage.', projectCoverageConfig.postRunCoverageTarget, projectCoverageConfig.project.sourceDir));
const rc = await projectCoverageConfig.project.build([projectCoverageConfig.postRunCoverageTarget]);
if (rc !== 0) {
log.error(localize('test.postRunCoverageTargetFailure', 'Building target postRunCoverageTarget on project in {0} failed. Skipping handling of coverage data.', projectCoverageConfig.project.sourceDir));
return 0;
}
}
}

await this.handleCoverageOnProjects(run, projectCoverageConfigs);
return runResult;
}

private async runTestHandler(request: vscode.TestRunRequest, cancellation: vscode.CancellationToken, isCoverageRun = false) {
// NOTE: We expect the testExplorer to be undefined when the cmake.ctest.testExplorerIntegrationEnabled is disabled.
if (!testExplorer) {
return;
Expand All @@ -891,7 +960,12 @@ export class CTestDriver implements vscode.Disposable {
this.ctestsEnqueued(tests, run);
const buildSucceeded = await this.buildTests(tests, run);
if (buildSucceeded) {
await this.runCTestHelper(tests, run, cancellation, undefined, undefined, undefined, false, undefined, RunCTestHelperEntryPoint.TestExplorer);
if (isCoverageRun) {
await this.coverageCTestHelper(tests, run, cancellation);

} else {
await this.runCTestHelper(tests, run, cancellation, undefined, undefined, undefined, false, undefined, RunCTestHelperEntryPoint.TestExplorer);
}
} else {
log.info(localize('test.skip.run.build.failure', "Not running tests due to build failure."));
}
Expand Down Expand Up @@ -1200,6 +1274,12 @@ export class CTestDriver implements vscode.Disposable {
(request: vscode.TestRunRequest, cancellation: vscode.CancellationToken) => this.runTestHandler(request, cancellation),
true
);
testExplorer.createRunProfile(
'Run Tests with Coverage',
vscode.TestRunProfileKind.Coverage,
(request: vscode.TestRunRequest, cancellation: vscode.CancellationToken) => this.runTestHandler(request, cancellation, true),
true
).loadDetailedCoverage = async (_, fileCoverage) => this.coverageData.get(fileCoverage) ?? [];
testExplorer.createRunProfile(
'Debug Tests',
vscode.TestRunProfileKind.Debug,
Expand Down
5 changes: 4 additions & 1 deletion test/unit-tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ function createConfig(conf: Partial<ExtensionConfigurationSettings>): Configurat
launchBehavior: 'reuseTerminal',
ignoreCMakeListsMissing: false,
automaticReconfigure: false,
enableAutomaticKitScan: true
enableAutomaticKitScan: true,
preRunCoverageTarget: null,
postRunCoverageTarget: null,
coverageInfoFiles: []
});
ret.updatePartial(conf);
return ret;
Expand Down

0 comments on commit 26e0d22

Please sign in to comment.