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>
65 lines
2.6 KiB
JavaScript
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) => ({
|
|
'<': '<',
|
|
'>': '>',
|
|
'&': '&',
|
|
'"': '"',
|
|
"'": ''',
|
|
})[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'
|
|
}
|