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