chore(ci): scan this repo with SonarQube (phase 4, slice 0)
Until now this was the one part of the platform that had never been scanned.
The 75 files here arrived in the Phase 3 extraction and left their Sonar
history behind in core's project, so a whole module's worth of shipped code
has no dashboard at all.
Adds sonar-project.properties (project key Module-uo) and a sonarqube.yml
mirroring website's: push to main, never a PR gate, nothing waiting on the
quality gate.
Two things differ from core's config, both because this repo is shaped
differently:
- There is no src/ to point sonar.sources at — the server half keeps
boot.js/core.js/index.js at server/ root beside its subdirectories — so
the whole tree is included and the non-source parts are excluded. That
direction is deliberate: a new top-level server directory is scanned by
default rather than silently unscanned.
- server/scripts and client/scripts are IN. checkImports.js and
checkExternals.js are the enforcement of MODULE_API.md 5.1 and 3.6, they
carry their own test suites, and both have already shipped defects a
reviewer missed. Build code that decides whether a release is allowed out
is not throwaway code.
The workflow builds the client chunk before running either suite, for the
reason pr-checks.yml already calls load-bearing: build.test.js and
registration.test.js read dist/entry.js and SKIP without it, so the other
order reports coverage for a suite that quietly asked less than it looks like
it did.
Both suites run from the repo root rather than with --prefix, so the LCOV SF:
paths come out repo-root-relative and resolve against sonar.sources. That is
why the server suite's --require is spelled out here instead of reusing
`npm test --prefix server`, whose path is relative to server/.
Verified locally: 385 server test cases across 57 covered files and 40 client
cases, both LCOV and Generic Test Execution XML well-formed with
repo-root-relative paths.
Needs one-time setup in the Gitea UI before it can run — secret SONAR_TOKEN
and variable SONAR_HOST_URL, same as the other repos.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
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'
|
||||
}
|
||||
Reference in New Issue
Block a user