Files
website/scripts/sonar-test-reporter.mjs
wtclaude 2f3e8f7df5
All checks were successful
PR Checks / bot-install (pull_request) Successful in 14s
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 9m25s
ci(sonarqube): report test execution so the Unit Tests measure populates
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 <file path> 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 <noreply@anthropic.com>
2026-07-21 01:16:08 -05:00

65 lines
2.6 KiB
JavaScript

// 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/
// <testExecutions version="1">
// <file path="server/test/foo.test.js">
// <testCase name="..." duration="12"/> <!-- duration = integer ms -->
// </file>
// </testExecutions>
//
// 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) => ({
'<': '&lt;',
'>': '&gt;',
'&': '&amp;',
'"': '&quot;',
"'": '&apos;',
})[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 '<?xml version="1.0" encoding="UTF-8"?>\n<testExecutions version="1">\n'
for (const [file, cases] of byFile) {
yield ` <file path="${xmlEscape(file)}">\n`
for (const c of cases) {
const attrs = `name="${xmlEscape(c.name)}" duration="${c.duration}"`
if (c.failed) yield ` <testCase ${attrs}><failure message="test failed"/></testCase>\n`
else if (c.skipped) yield ` <testCase ${attrs}><skipped/></testCase>\n`
else yield ` <testCase ${attrs}/>\n`
}
yield ' </file>\n'
}
yield '</testExecutions>\n'
}