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