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>
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'
|
|
}
|