Merge pull request 'ci(sonarqube): populate the "Unit Tests" measure via a test-execution report' (#87) from ci/sonar-test-execution-report into main
Reviewed-on: #87 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
@@ -64,6 +64,7 @@ jobs:
|
|||||||
node --test --experimental-test-coverage \
|
node --test --experimental-test-coverage \
|
||||||
--test-reporter=spec --test-reporter-destination=stdout \
|
--test-reporter=spec --test-reporter-destination=stdout \
|
||||||
--test-reporter=lcov --test-reporter-destination=server/coverage/lcov.info \
|
--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
|
server/test/*.test.js
|
||||||
|
|
||||||
- name: Generate client test coverage (LCOV)
|
- name: Generate client test coverage (LCOV)
|
||||||
@@ -76,6 +77,7 @@ jobs:
|
|||||||
node --test --experimental-test-coverage \
|
node --test --experimental-test-coverage \
|
||||||
--test-reporter=spec --test-reporter-destination=stdout \
|
--test-reporter=spec --test-reporter-destination=stdout \
|
||||||
--test-reporter=lcov --test-reporter-destination=client/coverage/lcov.info \
|
--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
|
client/test/*.test.js
|
||||||
|
|
||||||
- name: Run SonarQube scan
|
- name: Run SonarQube scan
|
||||||
|
|||||||
64
scripts/sonar-test-reporter.mjs
Normal file
64
scripts/sonar-test-reporter.mjs
Normal file
@@ -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/
|
||||||
|
// <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'
|
||||||
|
}
|
||||||
@@ -22,6 +22,14 @@ sonar.test.inclusions=server/test/**/*.test.js,client/test/**/*.test.js
|
|||||||
# the scanner resolves them against the project base dir.
|
# the scanner resolves them against the project base dir.
|
||||||
sonar.javascript.lcov.reportPaths=server/coverage/lcov.info,client/coverage/lcov.info
|
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 <file path=…> 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.
|
# 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
|
sonar.exclusions=**/node_modules/**,client/dist/**,client/public/**,server/swagger/**,server/logs/**,server/uploads/**,server/coverage/**,client/coverage/**,**/*.min.js
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user