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