From 2f3e8f7df5ca437a634b6f48e31f9fa4469879bf Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 21 Jul 2026 01:12:51 -0500 Subject: [PATCH] ci(sonarqube): report test execution so the Unit Tests measure populates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lcov reports only feed SonarQube's Coverage metric — the "Unit Tests" tile stayed "-" because we never provided a test-execution report (a separate input via sonar.testExecutionReportPaths, in SonarQube's own Generic Test Execution XML format, which the lcov/junit reporters don't produce). Add a dependency-free custom node:test reporter (scripts/sonar-test-reporter.mjs) that emits that XML — repo-root-relative entries matching sonar.tests, integer-ms durations — and wire it into both the server and client coverage runs in sonarqube.yml, plus sonar.testExecutionReportPaths in sonar-project.properties. Verified locally: server 380 + client 43 test cases, well-formed XML, all three reporters (spec/lcov/sonar) coexist in one `node --test` invocation. Co-Authored-By: Claude --- .gitea/workflows/sonarqube.yml | 2 ++ scripts/sonar-test-reporter.mjs | 64 +++++++++++++++++++++++++++++++++ sonar-project.properties | 8 +++++ 3 files changed, 74 insertions(+) create mode 100644 scripts/sonar-test-reporter.mjs diff --git a/.gitea/workflows/sonarqube.yml b/.gitea/workflows/sonarqube.yml index 77e0a3a..6426a98 100644 --- a/.gitea/workflows/sonarqube.yml +++ b/.gitea/workflows/sonarqube.yml @@ -64,6 +64,7 @@ jobs: node --test --experimental-test-coverage \ --test-reporter=spec --test-reporter-destination=stdout \ --test-reporter=lcov --test-reporter-destination=server/coverage/lcov.info \ + --test-reporter=./scripts/sonar-test-reporter.mjs --test-reporter-destination=server/coverage/test-execution.xml \ server/test/*.test.js - name: Generate client test coverage (LCOV) @@ -76,6 +77,7 @@ jobs: node --test --experimental-test-coverage \ --test-reporter=spec --test-reporter-destination=stdout \ --test-reporter=lcov --test-reporter-destination=client/coverage/lcov.info \ + --test-reporter=./scripts/sonar-test-reporter.mjs --test-reporter-destination=client/coverage/test-execution.xml \ client/test/*.test.js - name: Run SonarQube scan diff --git a/scripts/sonar-test-reporter.mjs b/scripts/sonar-test-reporter.mjs new file mode 100644 index 0000000..6805320 --- /dev/null +++ b/scripts/sonar-test-reporter.mjs @@ -0,0 +1,64 @@ +// Custom node:test reporter that emits SonarQube's Generic Test Execution XML. +// +// Node's built-in reporters give us coverage (`lcov`) and pass/fail output +// (`spec`/`tap`/`junit`), but SonarQube's "Unit Tests" measure is fed by a +// SEPARATE report in *its own* format via `sonar.testExecutionReportPaths` — the +// lcov report only populates Coverage, which is why the dashboard shows coverage +// while the Unit Tests tile stays "-". This reporter produces that missing report. +// +// Format: https://docs.sonarsource.com/sonarqube/latest/analyzing-source-code/test-coverage/generic-test-data/ +// +// +// +// +// +// +// Paths are emitted repo-root-relative (POSIX separators) so they match the +// `sonar.tests` roots; the workflow runs `node --test` from the repo root, so the +// absolute `file` on each event strips cleanly against process.cwd(). +import path from 'node:path' + +function xmlEscape(s) { + return String(s).replace(/[<>&"']/g, (c) => ({ + '<': '<', + '>': '>', + '&': '&', + '"': '"', + "'": ''', + })[c]) +} + +export default async function* sonarTestReporter(source) { + const byFile = new Map() + const cwd = process.cwd() + + for await (const event of source) { + if (event.type !== 'test:pass' && event.type !== 'test:fail') continue + const d = event.data + // Skip the container events (a `describe` suite) and anything without a file + // — only real test cases go in the report, so the count matches the runner's. + if (!d.file || (d.details && d.details.type === 'suite')) continue + + const rel = path.relative(cwd, d.file).split(path.sep).join('/') + if (!byFile.has(rel)) byFile.set(rel, []) + byFile.get(rel).push({ + name: d.name, + duration: Math.max(0, Math.round(d.details?.duration_ms ?? 0)), + failed: event.type === 'test:fail', + skipped: Boolean(d.skip || d.todo), + }) + } + + yield '\n\n' + for (const [file, cases] of byFile) { + yield ` \n` + for (const c of cases) { + const attrs = `name="${xmlEscape(c.name)}" duration="${c.duration}"` + if (c.failed) yield ` \n` + else if (c.skipped) yield ` \n` + else yield ` \n` + } + yield ' \n' + } + yield '\n' +} diff --git a/sonar-project.properties b/sonar-project.properties index 1de3fba..98c9734 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -22,6 +22,14 @@ sonar.test.inclusions=server/test/**/*.test.js,client/test/**/*.test.js # the scanner resolves them against the project base dir. sonar.javascript.lcov.reportPaths=server/coverage/lcov.info,client/coverage/lcov.info +# Test execution ("Unit Tests" measure). This is a SEPARATE report from coverage: +# the lcov files above only populate Coverage, so without this the dashboard shows +# a coverage % but an empty "Unit Tests" tile. The sonarqube.yml workflow writes +# these with a custom node:test reporter (scripts/sonar-test-reporter.mjs) that +# emits SonarQube's Generic Test Execution XML; the entries are +# repo-root-relative and must fall under sonar.tests above. +sonar.testExecutionReportPaths=server/coverage/test-execution.xml,client/coverage/test-execution.xml + # Never analyse dependencies, build output, generated specs, or runtime dirs. sonar.exclusions=**/node_modules/**,client/dist/**,client/public/**,server/swagger/**,server/logs/**,server/uploads/**,server/coverage/**,client/coverage/**,**/*.min.js