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), []); + }); +});