From aa07df0aceb24dfdde7fb5793bbb2c8297463453 Mon Sep 17 00:00:00 2001 From: Jake Kim Date: Tue, 29 Sep 2026 02:38:28 +0000 Subject: [PATCH] Add native test coverage via Ceedling's gcov plugin The Testing view's Run with Coverage button now runs the suite through Ceedling's gcov: task and reports the result through VS Code's own coverage UI - the Test Coverage panel, per-file percentages, and inline line and branch hit counts. The data was already there. Ceedling's gcov plugin writes a gcovr Cobertura report with per-line hit counts, per-branch condition coverage and, from newer gcovr, per-method entries; nothing consumed it. This wires that report to the coverage API VS Code finalized in 1.88. - src/gcovrCoberturaReport.ts: pure, vscode-free normalization of the parsed report, unit-tested in plain Node like ceedlingXmlReport.ts. Every path comes out absolute. gcovr writes relative to inside its root but absolute outside it, and before gcovr 8.3 is itself relative to where gcovr ran (gcovr#1037). Uri.file() puts a relative path at the filesystem root, so a path left unresolved shows no coverage at all. - ceedlingEngine: a coverage variant of the run entry points, and reading the report against the project directory Ceedling runs in. A missing or unreadable report is "no coverage", not a failed run. - testController: a non-default Coverage run profile, since a coverage run rebuilds instrumented and is slower. Each project's previous report is deleted before the run, so a failed build can't pass the last run's coverage off as this one's. The fixtures are real reports from Ceedling 1.1.6 with gcovr 5.0 and 8.6, of a probe project with one source inside the Ceedling project and one above it. Requires raising the minimum VS Code from 1.71.0 to 1.88.0, the release that finalized the test coverage API. --- CHANGELOG.md | 14 +++ README.md | 14 ++- docs/BreakingChanges.md | 12 +++ package-lock.json | 4 +- package.json | 15 ++- src/ceedlingEngine.ts | 69 +++++++++++-- src/gcovrCoberturaReport.ts | 96 +++++++++++++++++++ src/testController.ts | 53 +++++++++- .../gcovr-5.0/cobertura-mixed-paths.xml | 37 +++++++ .../gcovr-8.6/cobertura-mixed-paths.xml | 57 +++++++++++ tests/unit/gcovrCoberturaReport.test.ts | 91 ++++++++++++++++++ 11 files changed, 442 insertions(+), 20 deletions(-) create mode 100644 src/gcovrCoberturaReport.ts create mode 100644 tests/fixtures/gcovr-5.0/cobertura-mixed-paths.xml create mode 100644 tests/fixtures/gcovr-8.6/cobertura-mixed-paths.xml create mode 100644 tests/unit/gcovrCoberturaReport.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aba8397..15a37ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ This format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) --- +# [Unreleased] + +## ✨ Added + +Native test coverage. The Testing view's **Run with Coverage** button runs the suite through Ceedling's `gcov:` task and reports the result through VS Code's own coverage UI: the Test Coverage panel, per-file percentages, and inline line and branch hit counts. Function-level entries follow when gcovr populates `` — gcovr 5.0 leaves it empty, 8.6 fills it in. + +New `ceedlingExplorer.coverageCommandArgs` setting, for projects whose coverage task is not the default `gcov:${TEST_ID}`. + +## ⚠️ Changed + +Minimum VS Code version is now 1.71.0 → **1.88.0**, the release that finalized the test coverage API this feature is built on. + +--- + # [2.0.1] — 2026-08-31 ## ⚠️ Changed diff --git a/README.md b/README.md index cc4254b..e2bcd27 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,13 @@ Ceedling and its complementary [ThrowTheSwitch] pieces and parts are and always * Shows a failed test’s message and failing line directly in the Test Explorer and the editor. * Runs a single test function in isolation, without running the rest of its file. * Can be configured to report compiler and linker problems inline in the editor and in the Problems panel. +* Reports code coverage through VS Code’s built-in coverage UI, from Ceedling’s gcov plugin. # Requirements ## VS Code -Requires VS Code 1.71.0 or later. +Requires VS Code 1.88.0 or later. ## Ceedling @@ -67,6 +68,16 @@ Run and debug tests from the Testing view, or from the gutter next to a test in The Testing view’s toolbar also has Clean and Clobber buttons. They run `ceedling clean` and `ceedling clobber` against the current project. +# Coverage + +The Testing view’s **Run with Coverage** button runs the same tests through Ceedling’s `gcov:` task and reports the result in VS Code’s own coverage UI: the Test Coverage panel, per-file percentages, and — with Toggle Inline Coverage — line and branch hit counts in the editor. + +It reads the report Ceedling’s [gcov plugin](https://throwtheswitch.github.io/Ceedling/plugins/gcov/) writes to `/artifacts/gcov/gcovr/GcovCoverageCobertura.xml`. Configure that plugin as its own documentation describes; this extension only requires that `gcovr` is among its `:utilities:` and `Cobertura` among its `:reports:`. + +Function rows in the Test Coverage panel depend on your gcovr version. gcovr 5.0 writes an empty `` for every file, leaving only line and branch coverage; 8.6 populates it, and the rows appear with no change here. + +Coverage is not the default run profile — a coverage run rebuilds instrumented, so it stays opt-in behind its own button. If your project’s coverage task is not `gcov:${TEST_ID}`, override `ceedlingExplorer.coverageCommandArgs`. + # Configuration ## Options @@ -80,6 +91,7 @@ This table of options should be read as `ceedlingExplorer.`. | `.prettyTestLabel` | Shortens the test label in the _Testing_ explorer by dropping its leading prefix (e.g. inactive `test_BlinkTaskShouldToggleLed` vs. active `BlinkTaskShouldToggleLed`).

Inactive:
prettyTestLabelInactive

Active:
prettyTestLabelActive| | `.prettyTestFileLabel` | Shortens the test file label in the _Testing_ explorer by dropping its path, leading prefix, and file type (e.g. inactive `test/LEDs/test_BlinkTask.c` vs. active `BlinkTask`).

Inactive:
prettyTestFileLabelInactive

Active:
prettyTestFileLabelActive | | `.testCommandArgs` | The command line arguments used to run Ceedling tests. The first argument has to literally contain the `${TEST_ID}` tag. The value `["test:${TEST_ID}"]` is used by default. For example, the arguments `"test:${TEST_ID}", "gcov:${TEST_ID}", "utils:gcov"` can be used to run tests and generate a gcov report. | +| `.coverageCommandArgs` | The command line arguments used by the Testing view's **Run with Coverage** button. The first argument has to literally contain the `${TEST_ID}` tag. The value `["gcov:${TEST_ID}"]` is used by default. See [Coverage](#coverage). | | `.problemMatching` | Configuration of compiler/linker problem matching. See the [Problem matching](#problem%20matching) section for details. | | `.testCaseMacroAliases` | An array of aliases for Unity’s parameterized test `TEST_CASE` macro. By default it is `["TEST_CASE"]`. | | `.testRangeMacroAliases` | An array of aliases for Unity’s parameterized test `TEST_RANGE` macro. By default it is `["TEST_RANGE"]`. | diff --git a/docs/BreakingChanges.md b/docs/BreakingChanges.md index 4f461e1..44b1b2e 100644 --- a/docs/BreakingChanges.md +++ b/docs/BreakingChanges.md @@ -4,6 +4,18 @@ Complemented by three other documents: [../CHANGELOG.md](../CHANGELOG.md), [Rele --- +# [Unreleased] + +## Minimum VS Code version is now 1.88.0 + +This extension now requires VS Code 1.88.0 or later, up from 1.71.0. 1.88.0 is the release that finalized VS Code's test coverage API, which the new **Run with Coverage** support is built on. + +**What changes for you:** +- On VS Code 1.88.0 or later, nothing. Coverage is additive and opt-in; every existing behavior and setting is unchanged. +- On anything older, VS Code will not offer this version. It keeps the last version you can run, which continues to work. + +--- + # [2.0.0] — 2026-08-31 ## Native Testing API replaces the Test Explorer UI extension dependency diff --git a/package-lock.json b/package-lock.json index 97589be..53141f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "devDependencies": { "@types/glob": "^7.1.3", "@types/mocha": "^8.2.0", - "@types/vscode": "^1.71.0", + "@types/vscode": "^1.88.0", "@vscode/test-electron": "^3.1.0", "@vscode/vsce": "^3.9.2", "esbuild": "^0.28.1", @@ -36,7 +36,7 @@ }, "engines": { "node": ">=22", - "vscode": "^1.71.0" + "vscode": "^1.88.0" } }, "node_modules/@azu/format-text": { diff --git a/package.json b/package.json index 5afb693..d171dae 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "devDependencies": { "@types/glob": "^7.1.3", "@types/mocha": "^8.2.0", - "@types/vscode": "^1.71.0", + "@types/vscode": "^1.88.0", "@vscode/test-electron": "^3.1.0", "@vscode/vsce": "^3.9.2", "esbuild": "^0.28.1", @@ -82,7 +82,7 @@ "typescript": "^6.0.3" }, "engines": { - "vscode": "^1.71.0", + "vscode": "^1.88.0", "node": ">=22" }, "activationEvents": [ @@ -152,6 +152,17 @@ ], "scope": "resource" }, + "ceedlingExplorer.coverageCommandArgs": { + "description": "The command line arguments used to run Ceedling tests with coverage, from the Testing view's Run with Coverage button. The first argument has to literally contain the ${TEST_ID} tag. Requires Ceedling's gcov plugin with its 'Cobertura' report enabled.", + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "gcov:${TEST_ID}" + ], + "scope": "resource" + }, "ceedlingExplorer.problemMatching": { "markdownDescription": "Configuration of mechanism that scans Ceedling output text for known error/warning/info strings and reports these inline in the editor and in the Problems panel. The mechanism tries to resemble the one used in VSCode Tasks problemMatchers.", "type": "object", diff --git a/src/ceedlingEngine.ts b/src/ceedlingEngine.ts index f08431e..cee6d3e 100644 --- a/src/ceedlingEngine.ts +++ b/src/ceedlingEngine.ts @@ -22,6 +22,7 @@ import { testCaseFilterMatchedExactly, } from './ceedlingOutputParsing'; import { getTestListFromXmlReport } from './ceedlingXmlReport'; +import { CoveredFile, getCoverageFromCoberturaReport } from './gcovrCoberturaReport'; import { Logger } from './logger'; import { FileDiagnostic, ProblemMatcher, ProblemMatchingPattern } from './problemMatcher'; @@ -202,13 +203,13 @@ export class CeedlingEngine { return this.getExistingProjectKeys().map((projectKey) => this.getYmlProjectPath(projectKey)); } - async runFile(projectKey: string, file: CeedlingFileNode, reporter: RunReporter, token: vscode.CancellationToken): Promise { - await this.runInternal(projectKey, file.id, this.getTestCommandArgs(file.id), getAllIds(file), reporter, token); + async runFile(projectKey: string, file: CeedlingFileNode, reporter: RunReporter, token: vscode.CancellationToken, coverage = false): Promise { + await this.runInternal(projectKey, file.id, this.getTestCommandArgs(file.id, undefined, coverage), getAllIds(file), reporter, token); } - async runProjectAll(projectKey: string, files: CeedlingFileNode[], reporter: RunReporter, token: vscode.CancellationToken): Promise { + async runProjectAll(projectKey: string, files: CeedlingFileNode[], reporter: RunReporter, token: vscode.CancellationToken, coverage = false): Promise { const allIds = files.flatMap((file) => getAllIds(file)); - await this.runInternal(projectKey, projectKey, this.getTestCommandArgs('all'), allIds, reporter, token); + await this.runInternal(projectKey, projectKey, this.getTestCommandArgs('all', undefined, coverage), allIds, reporter, token); } // Runs a single test function (and, if parametrized, all its cases together - Ceedling's @@ -216,7 +217,8 @@ export class CeedlingEngine { // its siblings) instead of every test in the file. Ceedling still recompiles the whole file // regardless - --test-case narrows what runs and gets reported, not what gets built. async runFunction( - projectKey: string, file: CeedlingFileNode, fn: CeedlingTestFunction, reporter: RunReporter, token: vscode.CancellationToken + projectKey: string, file: CeedlingFileNode, fn: CeedlingTestFunction, reporter: RunReporter, token: vscode.CancellationToken, + coverage = false ): Promise { const functionName = extractTestFunctionName(fn.id); const ids = [fn.id, ...fn.cases.map((c) => c.id)]; @@ -226,14 +228,14 @@ export class CeedlingEngine { // it stops being used, so a since-fixed error would linger under fn.id after a later // whole-file run replaced only file.id's own entry. const collided = await this.runInternal( - projectKey, file.id, this.getTestCommandArgs(file.id, functionName), ids, reporter, token, functionName + projectKey, file.id, this.getTestCommandArgs(file.id, functionName, coverage), ids, reporter, token, functionName ); if (collided) { this.logger.warn( `runFunction: --test-case=${functionName} also matched an unrelated test in '${file.id}' - ` + `Ceedling's filter is a substring match, not exact. Falling back to a full-file run.` ); - await this.runFile(projectKey, file, reporter, token); + await this.runFile(projectKey, file, reporter, token, coverage); } } @@ -510,16 +512,63 @@ export class CeedlingEngine { // `testCaseFilter`, when given, appends Ceedling's --test-case flag to narrow the run to one // function (and, if parametrized, all its cases - see runFunction). Ceedling auto-enables // :test_runner: :cmdline_args itself when this flag is present; nothing here needs to. - private getTestCommandArgs(testToExec: string, testCaseFilter?: string): Array { + // `coverage` swaps in the instrumented task set, which is a separate setting because the + // gcov task is not a drop-in for the test task: it needs Ceedling's gcov plugin enabled. + private getTestCommandArgs(testToExec: string, testCaseFilter?: string, coverage = false): Array { // Keep only the filename of the test 'test/test_foo.c' -> 'test_foo.c' const testSuiteFilename = testToExec.replace(/^.*[\\/]/, ""); - const defaultTestCommandArgs = ["test:${TEST_ID}"]; + const setting = coverage ? 'coverageCommandArgs' : 'testCommandArgs'; + const fallback = coverage ? ["gcov:${TEST_ID}"] : ["test:${TEST_ID}"]; const args = this.getConfiguration() - .get>('testCommandArgs', defaultTestCommandArgs) + .get>(setting, fallback) .map(x => x.replace("${TEST_ID}", testSuiteFilename)); return testCaseFilter === undefined ? args : [...args, `--test-case=${testCaseFilter}`]; } + // Where Ceedling's gcov plugin leaves its gcovr Cobertura report. Fixed by the plugin, not + // configurable: only the enclosing build directory varies per project. + private getCoverageReportPath(projectKey: string): string { + return path.resolve( + this.projectData[projectKey].absPath, this.buildDirectories[projectKey], + 'artifacts', 'gcov', 'gcovr', 'GcovCoverageCobertura.xml' + ); + } + + // Returns [] rather than throwing when the report is absent or unreadable: a coverage run + // whose report never appeared is a misconfiguration to report as "no coverage", not a reason + // to fail a test run whose results are otherwise perfectly good. + async readCoverageReport(projectKey: string): Promise { + const reportPath = this.getCoverageReportPath(projectKey); + if (!fs.existsSync(reportPath)) { + this.logger.warn( + `readCoverageReport: no report at '${reportPath}' - the build failed, or Ceedling's gcov ` + + `plugin is not enabled with the 'Cobertura' report.` + ); + return []; + } + try { + const parser = new xml2js.Parser({ explicitArray: false }); + const parsed = await parser.parseStringPromise(fs.readFileSync(reportPath, 'utf8')); + // absPath is where execCeedling runs Ceedling, and so where Ceedling runs gcovr. + const files = getCoverageFromCoberturaReport(parsed, this.projectData[projectKey].absPath); + this.logger.debug(`readCoverageReport: ${files.length} file(s) from '${reportPath}'`); + return files; + } catch (e) { + this.logger.error(`readCoverageReport: failed to read '${reportPath}': ${util.format(e)}`); + return []; + } + } + + // A coverage build that fails never reaches gcovr, which would leave the previous run's report + // to be shown as this one's - the same trap deleteXmlReport avoids for test results. + async deleteCoverageReport(projectKey: string): Promise { + try { + await fs.promises.rm(this.getCoverageReportPath(projectKey), { force: true }); + } catch (e) { + this.logger.error(`deleteCoverageReport: ${util.format(e)}`); + } + } + // Picks the diagnostic that best explains a compile failure. Patterns are evaluated // warning-then-error-then-catchall. The first entry in `diagnostics` is not reliably the // most severe one. An early warning can otherwise outrank the actual fatal error. diff --git a/src/gcovrCoberturaReport.ts b/src/gcovrCoberturaReport.ts new file mode 100644 index 0000000..792263e --- /dev/null +++ b/src/gcovrCoberturaReport.ts @@ -0,0 +1,96 @@ +// Pure, `vscode`-free normalization of a parsed gcovr Cobertura XML report (as produced by +// xml2js's `new Parser({ explicitArray: false })` against the output of Ceedling's gcov plugin +// with the `Cobertura` report enabled). Kept free of any `vscode`/`fs` dependency so it can be +// unit-tested directly in plain Node (see tests/unit/gcovrCoberturaReport.test.ts). + +import * as path from 'path'; + +export interface CoveredLine { + line: number; + hits: number; + /** One entry per branch outcome on this line, true when taken. Empty for a plain statement. */ + branches: boolean[]; +} + +export interface CoveredFunction { + name: string; + line: number; + hits: number; +} + +export interface CoveredFile { + /** Absolute. */ + path: string; + lines: CoveredLine[]; + functions: CoveredFunction[]; +} + +// With `explicitArray: false`, a container holding exactly one child parses to a single object +// rather than a one-element array, and an empty self-closing container parses to an empty string. +// Both are normalized to an array here. +function toArray(value: any): any[] { + if (value === undefined || value === null || value === '') return []; + return Array.isArray(value) ? value : [value]; +} + +// `condition-coverage` looks like "50% (1/2)". Only the covered/total pair is usable: gcovr does +// not say *which* outcomes were taken, so the branches are reported as N taken followed by +// (total - N) not taken. That is enough for a covered/total ratio, which is all the consumer needs. +function parseBranches(conditionCoverage: unknown): boolean[] { + if (typeof conditionCoverage !== 'string') return []; + const match = /\((\d+)\/(\d+)\)/.exec(conditionCoverage); + if (match === null) return []; + const covered = Number(match[1]); + const total = Number(match[2]); + if (!Number.isFinite(covered) || !Number.isFinite(total) || total <= 0) return []; + return Array.from({ length: total }, (_, i) => i < covered); +} + +// Returns one entry per in the report. gcovr emits a per source file; a file that +// was compiled but never executed still appears, with every line at zero hits. `baseDir` is the +// directory gcovr ran in. Throws if the report doesn't have the expected shape; callers are +// expected to catch and log. +export function getCoverageFromCoberturaReport(xmlReportData: any, baseDir: string): CoveredFile[] { + const coverage = xmlReportData['coverage']; + const sourceRoot = toArray(coverage['sources']?.['source'])[0] ?? ''; + const files: CoveredFile[] = []; + + for (const pkg of toArray(coverage['packages']?.['package'])) { + for (const cls of toArray(pkg['classes']?.['class'])) { + const filename = cls['$']?.['filename'] ?? ''; + if (filename === '') continue; + + const lines: CoveredLine[] = []; + for (const line of toArray(cls['lines']?.['line'])) { + const number = Number(line['$']?.['number']); + const hits = Number(line['$']?.['hits']); + if (!Number.isFinite(number) || !Number.isFinite(hits)) continue; + lines.push({ + line: number, + hits, + branches: line['$']?.['branch'] === 'true' + ? parseBranches(line['$']?.['condition-coverage']) + : [], + }); + } + + const functions: CoveredFunction[] = []; + for (const method of toArray(cls['methods']?.['method'])) { + const name = method['$']?.['name'] ?? ''; + // gcovr does not give a method a hit count of its own. Its first line carries it. + const methodLines = toArray(method['lines']?.['line']); + const first = methodLines[0]; + const line = Number(first?.['$']?.['number']); + const hits = Number(first?.['$']?.['hits']); + if (name === '' || !Number.isFinite(line)) continue; + functions.push({ name, line, hits: Number.isFinite(hits) ? hits : 0 }); + } + + // is relative to inside gcovr's root and absolute outside it; + // is relative to baseDir before gcovr 8.3 (gcovr#1037). path.join would mangle the absolutes. + files.push({ path: path.resolve(baseDir, String(sourceRoot), String(filename)), lines, functions }); + } + } + + return files; +} diff --git a/src/testController.ts b/src/testController.ts index 9fca13c..d06f3d1 100644 --- a/src/testController.ts +++ b/src/testController.ts @@ -34,6 +34,9 @@ export class CeedlingTestController { private readonly itemMeta = new WeakMap(); private readonly idToItem = new Map(); private readonly watchedReloadFiles = new Set(); + // Detailed per-file coverage from the last coverage run, keyed by file URI. VS Code asks for + // this lazily, after the run has ended, so it cannot be recomputed on demand. + private readonly detailedCoverage = new Map(); private projects: CeedlingProjectNode[] = []; private activeDebugSession: vscode.DebugSession | undefined; @@ -68,7 +71,15 @@ export class CeedlingTestController { 'Debug', vscode.TestRunProfileKind.Debug, (request, token) => this.handleDebug(request, token), true, ); - this.disposables.push(runProfile, debugProfile); + // Not the default profile: a coverage run rebuilds instrumented and is measurably slower + // than a plain one, so it stays opt-in behind the Run with Coverage button. + const coverageProfile = this.controller.createRunProfile( + 'Coverage', vscode.TestRunProfileKind.Coverage, + (request, token) => this.handleRun(request, token, true), false, + ); + coverageProfile.loadDetailedCoverage = async (_run, fileCoverage) => + this.detailedCoverage.get(fileCoverage.uri.toString()) ?? []; + this.disposables.push(runProfile, debugProfile, coverageProfile); this.disposables.push(vscode.workspace.onDidChangeConfiguration(event => { if (event.affectsConfiguration("ceedlingExplorer.problemMatching", this.workspaceFolder.uri)) { @@ -338,23 +349,33 @@ export class CeedlingTestController { }; } - private async handleRun(request: vscode.TestRunRequest, token: vscode.CancellationToken): Promise { + private async handleRun(request: vscode.TestRunRequest, token: vscode.CancellationToken, coverage = false): Promise { const run = this.controller.createTestRun(request); token.onCancellationRequested(() => this.engine.cancel()); const reporter = this.makeReporter(run); + const coveredProjects = new Set(); try { const plan = this.buildRunPlan(request.include); this.logger.debug(`handleRun: starting, plan has ${plan.length} entr${plan.length === 1 ? 'y' : 'ies'}`); for (const entry of plan) { if (token.isCancellationRequested) break; + // Once per project, not per entry: a later entry's failed build must not discard the report + // an earlier entry of this same run produced. + if (coverage && !coveredProjects.has(entry.projectKey)) { + await this.engine.deleteCoverageReport(entry.projectKey); + } + coveredProjects.add(entry.projectKey); if (entry.kind === 'project') { - await this.engine.runProjectAll(entry.projectKey, entry.files, reporter, token); + await this.engine.runProjectAll(entry.projectKey, entry.files, reporter, token, coverage); } else if (entry.kind === 'function') { - await this.engine.runFunction(entry.projectKey, entry.file, entry.fn, reporter, token); + await this.engine.runFunction(entry.projectKey, entry.file, entry.fn, reporter, token, coverage); } else { - await this.engine.runFile(entry.projectKey, entry.file, reporter, token); + await this.engine.runFile(entry.projectKey, entry.file, reporter, token, coverage); } } + if (coverage && !token.isCancellationRequested) { + await this.reportCoverage(run, coveredProjects); + } } catch (e) { this.logger.error(`handleRun error: ${util.format(e)}`); } finally { @@ -363,6 +384,28 @@ export class CeedlingTestController { } } + // Ceedling rewrites one report per project, so this reads each project's report once the + // whole plan has run rather than after each entry, which would only ever see the last one. + private async reportCoverage(run: vscode.TestRun, projectKeys: Set): Promise { + this.detailedCoverage.clear(); + for (const projectKey of projectKeys) { + for (const file of await this.engine.readCoverageReport(projectKey)) { + const uri = vscode.Uri.file(file.path); + const details: vscode.FileCoverageDetail[] = file.lines.map(line => new vscode.StatementCoverage( + line.hits, + // Cobertura line numbers are 1-based; VS Code positions are 0-based. + new vscode.Position(line.line - 1, 0), + line.branches.map(taken => new vscode.BranchCoverage(taken)), + )); + for (const fn of file.functions) { + details.push(new vscode.DeclarationCoverage(fn.name, fn.hits, new vscode.Position(fn.line - 1, 0))); + } + this.detailedCoverage.set(uri.toString(), details); + run.addCoverage(vscode.FileCoverage.fromDetails(uri, details)); + } + } + } + private async handleDebug(request: vscode.TestRunRequest, token: vscode.CancellationToken): Promise { const run = this.controller.createTestRun(request); token.onCancellationRequested(() => { diff --git a/tests/fixtures/gcovr-5.0/cobertura-mixed-paths.xml b/tests/fixtures/gcovr-5.0/cobertura-mixed-paths.xml new file mode 100644 index 0000000..d9f6164 --- /dev/null +++ b/tests/fixtures/gcovr-5.0/cobertura-mixed-paths.xml @@ -0,0 +1,37 @@ + + + + + . + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/gcovr-8.6/cobertura-mixed-paths.xml b/tests/fixtures/gcovr-8.6/cobertura-mixed-paths.xml new file mode 100644 index 0000000..26390f3 --- /dev/null +++ b/tests/fixtures/gcovr-8.6/cobertura-mixed-paths.xml @@ -0,0 +1,57 @@ + + + + + /home/dev/gcovr_probe/project + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/unit/gcovrCoberturaReport.test.ts b/tests/unit/gcovrCoberturaReport.test.ts new file mode 100644 index 0000000..84a7949 --- /dev/null +++ b/tests/unit/gcovrCoberturaReport.test.ts @@ -0,0 +1,91 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; +// Namespace import, not a default import: test.tsconfig.json has no esModuleInterop (unlike the +// root tsconfig.json, which production code relies on for `import xml2js from 'xml2js'`), and +// @types/xml2js has no default export - only this form type-checks here. +import * as xml2js from 'xml2js'; +import { getCoverageFromCoberturaReport, CoveredFile } from '../../src/gcovrCoberturaReport'; + +// __dirname at runtime is out/tests/unit (tsc mirrors the source tree under outDir); fixtures are +// plain data files tsc doesn't copy into out/, so walk back up to the repo root to find them. +const fixturesRoot = path.resolve(__dirname, '..', '..', '..', 'tests', 'fixtures'); + +function parseFixture(...segments: string[]): Promise { + const data = fs.readFileSync(path.resolve(fixturesRoot, ...segments), 'utf8'); + const parser = new xml2js.Parser({ explicitArray: false }); + return new Promise((resolve, reject) => { + parser.parseString(data, (error: any, result: any) => error ? reject(error) : resolve(result)); + }); +} + +// Both fixtures are one probe project's reports from Ceedling's gcov plugin, run in projectDir: +// src/Calculator.c lies inside gcovr's root, ../lib/Clamp.c above it, as sources often do. +const projectDir = path.resolve('/home/dev/gcovr_probe/project'); +const insideRoot = path.resolve(projectDir, 'src', 'Calculator.c'); +const outsideRoot = path.resolve('/home/dev/gcovr_probe/lib/Clamp.c'); + +function byPath(files: CoveredFile[], filePath: string): CoveredFile { + const found = files.find(file => file.path === filePath); + assert.ok(found, `expected a coverage entry for ${filePath}, got ${files.map(file => file.path)}`); + return found; +} + +for (const version of ['gcovr-5.0', 'gcovr-8.6']) { + suite(`getCoverageFromCoberturaReport (${version}, mixed relative/absolute paths)`, () => { + let files: CoveredFile[]; + suiteSetup(async () => { + files = getCoverageFromCoberturaReport(await parseFixture(version, 'cobertura-mixed-paths.xml'), projectDir); + }); + + test('reports every class in the report', () => { + assert.strictEqual(files.length, 2); + }); + + test('resolves a file inside the gcovr root to an absolute path', () => { + byPath(files, insideRoot); + }); + + test('keeps a file outside the gcovr root at its absolute path', () => { + byPath(files, outsideRoot); + }); + + test('carries per-line hit counts, including never-executed lines', () => { + const lines = byPath(files, outsideRoot).lines; + assert.deepStrictEqual(lines.map(line => [line.line, line.hits]), [[3, 1], [4, 1], [5, 0], [7, 1]]); + }); + + test('expands condition-coverage into one entry per branch outcome', () => { + const branches = byPath(files, outsideRoot).lines.find(line => line.line === 4)!.branches; + assert.deepStrictEqual(branches, [true, false]); + }); + + test('leaves a plain statement without branches', () => { + assert.deepStrictEqual(byPath(files, outsideRoot).lines.find(line => line.line === 3)!.branches, []); + }); + }); +} + +suite('getCoverageFromCoberturaReport (function entries)', () => { + test('gcovr 5.0 writes an empty , so there are none', async () => { + const files = getCoverageFromCoberturaReport(await parseFixture('gcovr-5.0', 'cobertura-mixed-paths.xml'), projectDir); + assert.deepStrictEqual(files.map(file => file.functions), [[], []]); + }); + + test('gcovr 8.6 fills them in; a function takes its hit count from its first line', async () => { + const files = getCoverageFromCoberturaReport(await parseFixture('gcovr-8.6', 'cobertura-mixed-paths.xml'), projectDir); + assert.deepStrictEqual(byPath(files, outsideRoot).functions, [{ name: 'clamp_positive', line: 3, hits: 1 }]); + assert.deepStrictEqual(byPath(files, insideRoot).functions, [{ name: 'add', line: 3, hits: 1 }]); + }); +}); + +suite('getCoverageFromCoberturaReport (degenerate reports)', () => { + test('returns nothing for a report with no packages', () => { + assert.deepStrictEqual(getCoverageFromCoberturaReport({ coverage: { sources: { source: '.' }, packages: '' } }, projectDir), []); + }); + + test('skips a class that has no filename', () => { + const report = { coverage: { packages: { package: { classes: { class: { $: {} } } } } } }; + assert.deepStrictEqual(getCoverageFromCoberturaReport(report, projectDir), []); + }); +});