#!/usr/bin/env node
* Copyright (c) 2026 Huawei Technologies Co., Ltd.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
* RNOH C++ gtest Coverage Check Script
*
* Builds RNOH C++ tests with coverage instrumentation, runs them, collects
* runtime profile data, and reports per-source-file LINE and BRANCH coverage.
*
* This is a DYNAMIC analysis tool (build + run + parse coverage data), in
* contrast to gtest-quality-check.js which is a STATIC (file-parse only)
* tool. The two are intentionally separate: quality-check is fast and has
* zero build dependencies; coverage-check requires a working toolchain and
* is run on demand / in CI to obtain real line & branch coverage numbers.
*
* Coverage results are reported as INDEPENDENT columns (LineCov%, BranchCov%)
* and are NOT folded into the static Health score — they measure different
* things and merging them would double-count "coverage".
*
* Toolchain:
* - macOS: clang (Xcode). llvm-cov / llvm-profdata resolved via `xcrun`.
* - Linux: clang or GCC. clang → source-based; GCC → gcov-style (needs lcov).
* - Windows:
* • clang/clang-cl (LLVM) — FULL line+branch coverage, same pipeline as
* macOS/Linux. Install LLVM (standalone or VS "C++ Clang tools").
* • MSVC (cl.exe) + OpenCppCoverage — LINE coverage only (branch
* unavailable). Install OpenCppCoverage (choco/scoop) for the fallback.
*
* Usage: node scripts/gtest-coverage-check.js [options]
* --dir <path> Source root to FILTER coverage report to (default: CPP_DIR)
* --build-dir <path> CMake build directory (default: <cpp>/tests/build-cov)
* --target <name> CMake/test executable target (default: auto-discover all)
* --all-targets Auto-discover & run all rnoh_* test targets
* --no-build Skip configure+build (use existing build-dir)
* --no-run Skip running tests (use existing profraw/profdata)
* --json Output JSON
* --quiet Summary only (no per-file breakdown)
* --help
*
* Thresholds (independent of Health):
* GREEN : line >= 70% AND branch >= 60%
* YELLOW : line >= 50% OR branch >= 30%
* RED : below YELLOW
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const {spawnSync} = require('child_process');
const CPP_DIR = path.join(
__dirname,
'..',
'packages',
'tester',
'harmony',
'react_native_openharmony',
'src',
'main',
'cpp',
);
const TESTS_DIR = path.join(CPP_DIR, 'tests');
const DEFAULT_TARGET = null;
const LINE_GREEN = 70;
const LINE_YELLOW = 50;
const BRANCH_GREEN = 60;
const BRANCH_YELLOW = 30;
let generatorOverride = null;
let jsonMode = false;
function progress(msg) {
if (!jsonMode) {
process.stdout.write(msg + '\n');
}
}
function warn(msg) {
process.stderr.write('⚠ ' + msg + '\n');
}
function isDarwin() {
return process.platform === 'darwin';
}
function isWindows() {
return process.platform === 'win32';
}
const WIN_LLVM_CANDIDATES = [
'C:\\Program Files\\LLVM\\bin',
'C:\\Program Files (x86)\\LLVM\\bin',
'C:\\Program Files\\Microsoft Visual Studio\\2022\\Community\\VC\\Tools\\Llvm\\x64\\bin',
'C:\\Program Files\\Microsoft Visual Studio\\2022\\Professional\\VC\\Tools\\Llvm\\x64\\bin',
'C:\\Program Files\\Microsoft Visual Studio\\2022\\Enterprise\\VC\\Tools\\Llvm\\x64\\bin',
'C:\\Program Files\\Microsoft Visual Studio\\2022\\BuildTools\\VC\\Tools\\Llvm\\x64\\bin',
];
const WIN_OPCPP_CANDIDATES = [
'C:\\Program Files\\OpenCppCoverage',
'C:\\Program Files (x86)\\OpenCppCoverage',
];
function findTool(name, extraDirs = []) {
const lookupBin = isWindows() ? 'where' : 'which';
const direct = spawnSync(lookupBin, [name], {
encoding: 'utf8',
shell: isWindows(),
});
if (direct.status === 0 && direct.stdout.trim()) {
return direct.stdout.trim().split(/\r?\n/)[0];
}
if (isDarwin()) {
const xcrun = spawnSync('xcrun', ['--find', name], {encoding: 'utf8'});
if (xcrun.status === 0 && xcrun.stdout.trim()) {
return xcrun.stdout.trim();
}
}
if (isWindows()) {
const exe = name.endsWith('.exe') ? name : name + '.exe';
for (const dir of [...extraDirs, ...WIN_LLVM_CANDIDATES]) {
const p = path.join(dir, exe);
if (fs.existsSync(p)) {
return p;
}
}
}
return null;
}
function run(cmd, args, opts = {}) {
const res = spawnSync(cmd, args, {encoding: 'utf8', ...opts});
if (res.error) {
throw new Error(`Failed to execute ${cmd}: ${res.error.message}`);
}
return res;
}
function runOrThrow(cmd, args, opts, label) {
const res = run(cmd, args, opts);
if (res.status !== 0) {
throw new Error(
`[${label}] ${cmd} ${args.join(' ')} exited ${res.status}\n${res.stderr || res.stdout}`,
);
}
return res;
}
function findCmake() {
const c = findTool('cmake');
if (!c) {
throw new Error(
isWindows()
? 'cmake not found. Install: winget install Kitware.CMake'
: 'cmake not found. Install: brew install cmake (or apt-get install cmake)',
);
}
return c;
}
function findOpenCppCoverage() {
const direct = spawnSync('where', ['OpenCppCoverage'], {
encoding: 'utf8',
shell: true,
});
if (direct.status === 0 && direct.stdout.trim()) {
return direct.stdout.trim().split(/\r?\n/)[0];
}
const exe = 'OpenCppCoverage.exe';
for (const dir of WIN_OPCPP_CANDIDATES) {
const p = path.join(dir, exe);
if (fs.existsSync(p)) {
return p;
}
}
return null;
}
function detectToolchain(cmakeBin, buildDir) {
const cachePath = path.join(buildDir, 'CMakeCache.txt');
let compiler = null;
let compilerId = null;
if (fs.existsSync(cachePath)) {
const cache = fs.readFileSync(cachePath, 'utf8');
const m = cache.match(/CMAKE_CXX_COMPILER:[^=]*=(.+)/);
if (m) {
compiler = m[1].trim();
}
const idM = cache.match(/CMAKE_CXX_COMPILER_ID:[^=]*=(\S+)/);
if (idM) {
compilerId = idM[1].trim();
}
}
const clang =
findTool('clang++') || findTool('clang') || findTool('clang-cl');
const gcc = findTool('g++');
const llvmCov = findTool('llvm-cov');
const llvmProfdata = findTool('llvm-profdata');
const isClangCompiler =
(compiler && /clang/i.test(compiler)) ||
(!compiler && !!clang && !gcc) ||
(isDarwin() && !!clang);
const isClangAvailable = isClangCompiler && !!llvmCov && !!llvmProfdata;
const openCpp = isWindows() ? findOpenCppCoverage() : null;
const isMsvcFallback = isWindows() && !isClangAvailable && !!openCpp;
const gcov = findTool('gcov');
const lcov = findTool('lcov');
const isGccCompiler =
compilerId === 'GNU' || (!compilerId && !!gcc && !isClangCompiler);
const isGcc =
!isClangAvailable && !isMsvcFallback && !!gcov && !!lcov && isGccCompiler;
return {
isClang: isClangAvailable,
isMsvcFallback,
isGcc,
clang,
llvmCov,
llvmProfdata,
gcov,
lcov,
openCpp,
};
}
function generatorFromArgs(genArgs) {
const i = genArgs.indexOf('-G');
if (i === -1) {
return null;
}
return genArgs[i + 1] || null;
}
function generatorChanged(buildDir, genArgs) {
const desired = generatorFromArgs(genArgs);
if (!desired) {
return false;
}
const cachePath = path.join(buildDir, 'CMakeCache.txt');
if (!fs.existsSync(cachePath)) {
return false;
}
const cache = fs.readFileSync(cachePath, 'utf8');
const m = cache.match(/^CMAKE_GENERATOR:[^=]*=(.+)$/m);
if (!m) {
return false;
}
return m[1].trim() !== desired;
}
function cleanBuildDirPreservingGtest(buildDir) {
const gtestSrc = path.join(buildDir, '_deps', 'googletest-src');
let stashed = null;
if (fs.existsSync(gtestSrc) && fs.statSync(gtestSrc).isDirectory()) {
stashed = path.join(os.tmpdir(), `rnoh-gtest-src-${process.pid}`);
fs.rmSync(stashed, {recursive: true, force: true});
fs.cpSync(gtestSrc, stashed, {recursive: true});
}
fs.rmSync(buildDir, {recursive: true, force: true});
fs.mkdirSync(buildDir, {recursive: true});
if (stashed) {
const restored = path.join(buildDir, '_deps', 'googletest-src');
fs.mkdirSync(path.dirname(restored), {recursive: true});
fs.cpSync(stashed, restored, {recursive: true});
fs.rmSync(stashed, {recursive: true, force: true});
}
return stashed !== null;
}
function gtestSourceDirFlag(buildDir) {
const gtestSrc = path.join(buildDir, '_deps', 'googletest-src');
if (fs.existsSync(gtestSrc) && fs.statSync(gtestSrc).isDirectory()) {
return ['-D', `FETCHCONTENT_SOURCE_DIR_GOOGLETEST=${gtestSrc}`];
}
return [];
}
function firstLine(s) {
if (!s) {
return '';
}
return s.split(/\r?\n/).find(l => l.trim()) || '';
}
function buildGeneratorAttempts(useCoverageFlags) {
if (!isWindows()) {
return [[]];
}
const userGen = generatorOverride;
const isVsGen = !userGen || /^Visual Studio/.test(userGen);
const toolset = useCoverageFlags && isVsGen ? ['-T', 'ClangCL'] : [];
if (userGen) {
return [['-G', userGen, ...toolset]];
}
return [
['-G', 'Visual Studio 17 2022', ...toolset],
['-G', 'Visual Studio 16 2019', ...toolset],
];
}
function tryConfigureAttempt(
cmakeBin,
buildDir,
flag,
gen,
i,
attemptsLen,
forceClean,
) {
let didRestore = false;
if (forceClean || generatorChanged(buildDir, gen)) {
didRestore = cleanBuildDirPreservingGtest(buildDir);
}
const gtestFlag = didRestore ? gtestSourceDirFlag(buildDir) : [];
const fallback = attemptsLen > 1 && i > 0 ? ` (fallback: ${gen[1]})` : '';
progress(`▸ Configuring (cmake ${flag}) → ${buildDir}${fallback}`);
const res = run(
cmakeBin,
[...gen, '-B', buildDir, '-S', TESTS_DIR, flag, ...gtestFlag],
{stdio: 'inherit'},
);
if (res.status === 0) {
return {ok: true, nextForceClean: false, error: null};
}
if (i < attemptsLen - 1) {
warn(
`cmake configure with ${gen[1] || 'default generator'} failed — trying fallback. ` +
`(${firstLine(res.stderr || res.stdout)})`,
);
}
return {ok: false, nextForceClean: i < attemptsLen - 1, error: res};
}
function configureBuild(cmakeBin, buildDir, target, useCoverageFlags = true) {
fs.mkdirSync(buildDir, {recursive: true});
const flag = useCoverageFlags ? '-DRNOH_COVERAGE=ON' : '-DRNOH_COVERAGE=OFF';
const attempts = buildGeneratorAttempts(useCoverageFlags);
let forceClean = false;
let lastError = null;
for (let i = 0; i < attempts.length; i++) {
const result = tryConfigureAttempt(
cmakeBin,
buildDir,
flag,
attempts[i],
i,
attempts.length,
forceClean,
);
if (result.ok) {
if (useCoverageFlags) {
verifyCoverageCompiler(buildDir);
}
return;
}
forceClean = result.nextForceClean;
lastError = result.error;
}
throw new Error(
`[cmake-configure] cmake ${flag} exited ${lastError.status}\n${lastError.stderr || lastError.stdout}`,
);
}
function readCmakeCxxCompiler(buildDir) {
const cachePath = path.join(buildDir, 'CMakeCache.txt');
if (!fs.existsSync(cachePath)) {
return null;
}
const cache = fs.readFileSync(cachePath, 'utf8');
const keys = [
'CMAKE_CXX_COMPILER',
'CMAKE_CXX_COMPILER_AR',
'CMAKE_CXX_COMPILER_RANLIB',
];
for (const key of keys) {
const m = cache.match(new RegExp(`^${key}:[^=]*=(.+)$`, 'm'));
if (!m) {
continue;
}
const p = m[1].trim();
if (!p || /NOTFOUND$/i.test(p)) {
continue;
}
return {path: p, key};
}
return null;
}
function findLlvmToolsForCompiler(compilerPath) {
if (!compilerPath) {
return {llvmProfdata: null, llvmCov: null};
}
const dir = path.dirname(compilerPath);
const exe = isWindows() ? '.exe' : '';
const profdata = path.join(dir, `llvm-profdata${exe}`);
const cov = path.join(dir, `llvm-cov${exe}`);
return {
llvmProfdata: fs.existsSync(profdata) ? profdata : null,
llvmCov: fs.existsSync(cov) ? cov : null,
};
}
function verifyCoverageCompiler(buildDir) {
if (!isWindows()) {
return;
}
const ci = readCmakeCxxCompiler(buildDir);
if (!ci) {
return;
}
const compiler = ci.path;
if (ci.key === 'CMAKE_CXX_COMPILER' && !/clang/i.test(compiler)) {
throw new Error(
`RNOH_COVERAGE=ON but CMake selected a non-clang compiler: ${compiler}. ` +
'On Windows the VS generator defaults to MSVC, which silently disables ' +
'coverage instrumentation. Install the VS "C++ Clang Compiler for ' +
'Windows" component (the script passes -T ClangCL), or use the ' +
'MSVC + OpenCppCoverage fallback path (install OpenCppCoverage).',
);
}
const aligned = findLlvmToolsForCompiler(compiler);
if (!aligned.llvmProfdata && !aligned.llvmCov) {
throw new Error(
`llvm-profdata/llvm-cov not found alongside the CMake-selected ` +
`compiler ${compiler} (resolved via ${ci.key}). A PATH-resolved ` +
'llvm-profdata/llvm-cov from a different LLVM install would reject ' +
'the .profraw with a version-mismatch error. Install the LLVM ' +
'matching the compiler, or add its bin dir to PATH ahead of any ' +
'other LLVM install.',
);
}
}
function buildTarget(cmakeBin, buildDir, target) {
progress(`▸ Building target: ${target}`);
const config = isWindows() ? ['--config', 'Release'] : [];
runOrThrow(
cmakeBin,
['--build', buildDir, '--target', target, ...config],
{stdio: 'inherit'},
'cmake-build',
);
}
function discoverTestTargets(cmakeBin, buildDir) {
const res = run(cmakeBin, ['--build', buildDir, '--target', 'help'], {
encoding: 'utf8',
});
if (res.status !== 0) {
const slnTargets = parseSlnTargets(buildDir);
if (slnTargets.length > 0) {
return slnTargets;
}
throw new Error(
`Failed to enumerate targets (cmake --build ${buildDir} --target help):\n${res.stderr || res.stdout}`,
);
}
const out = res.stdout || '';
const set = new Set();
for (const line of out.split(/\r?\n/)) {
const m = line.match(/\b(rnoh_[a-z0-9_]+)\b/);
if (!m) continue;
const t = m[1];
if (/\.(o|i|s|dir)$/.test(t)) continue;
set.add(t);
}
const list = [...set].sort();
if (list.length === 0) {
throw new Error(
`No rnoh_* test targets discovered in ${buildDir}. ` +
'Ensure tests/CMakeLists.txt defines at least one add_executable(rnoh_...).',
);
}
return list;
}
function parseSlnTargets(buildDir) {
const set = new Set();
let slns = [];
try {
slns = fs.readdirSync(buildDir).filter(f => f.endsWith('.sln'));
} catch {
return [];
}
const re = /Project\("[^"]*"\)\s*=\s*"(rnoh_[^"]+)"/g;
for (const sln of slns) {
let text = '';
try {
text = fs.readFileSync(path.join(buildDir, sln), 'utf8');
} catch {
continue;
}
let m;
while ((m = re.exec(text)) !== null) {
const t = m[1];
if (/\.(o|i|s|dir)$/.test(t)) continue;
set.add(t);
}
}
return [...set].sort();
}
function binaryPath(buildDir, target) {
const ext = process.platform === 'win32' ? '.exe' : '';
const name = target + ext;
const candidates = [
path.join(buildDir, name),
path.join(buildDir, target, name),
path.join(buildDir, 'Release', name),
path.join(buildDir, 'Release', target, name),
path.join(buildDir, 'Debug', name),
path.join(buildDir, 'Debug', target, name),
];
for (const c of candidates) {
if (fs.existsSync(c)) {
return c;
}
}
return candidates[0];
}
function runTests(binary, buildDir) {
const profrawGlob = path.join(buildDir, 'cov-%p.profraw');
progress(
`▸ Running ${path.basename(binary)} (LLVM_PROFILE_FILE=${profrawGlob})`,
);
const res = run(binary, [], {
encoding: 'utf8',
stdio: 'inherit',
env: {...process.env, LLVM_PROFILE_FILE: profrawGlob},
});
if (res.status !== 0) {
console.warn(
`⚠ ${path.basename(binary)} exited ${res.status} — some tests failed, collecting partial coverage`,
);
}
return res;
}
function runOpenCppCoverage(openCpp, binary, buildDir, reportDir, xmlPath) {
progress(
`▸ Running ${path.basename(binary)} under OpenCppCoverage → ${path.basename(xmlPath)}`,
);
const args = [
'--export_type',
'cobertura',
'--export_filename',
xmlPath,
'--sources',
reportDir,
'--',
binary,
];
const res = run(openCpp, args, {
encoding: 'utf8',
stdio: 'inherit',
shell: isWindows(),
});
if (res.status !== 0) {
throw new Error(
`OpenCppCoverage exited ${res.status} — coverage not collected`,
);
}
if (!fs.existsSync(xmlPath)) {
throw new Error(`OpenCppCoverage did not produce ${xmlPath}`);
}
return xmlPath;
}
function parseCoberturaXML(xml, reportDir) {
const rows = [];
const classRe = /<class\b[^>]*\bfilename="([^"]+)"[^>]*>/gi;
let m;
while ((m = classRe.exec(xml)) !== null) {
const classTag = m[0];
const filename = m[1];
const rel = relativize(filename);
if (!isRnohSource(rel)) {
continue;
}
if (!isUnderReportDir(rel, reportDir)) {
continue;
}
const lineRate = parseFloat(getAttr(classTag, 'line-rate') || '0');
const branchRate = parseFloat(getAttr(classTag, 'branch-rate') || '0');
const linesBlock = extractBlock(xml, m.index, 'lines');
const acc = parseClassLines(linesBlock);
const linePercent = acc.lineCount
? (acc.lineCovered / acc.lineCount) * 100
: lineRate * 100;
const branchPercent = acc.branchCount
? (acc.branchCovered / acc.branchCount) * 100
: acc.lineCovered > 0
? 100
: 0;
rows.push({
source: rel,
lineCount: acc.lineCount,
lineCovered: acc.lineCovered,
linePercent,
branchCount: acc.branchCount,
branchCovered: acc.branchCovered,
branchPercent,
coLocatedTest: coLocatedTestFile(rel),
});
}
return rows.sort((a, b) => a.source.localeCompare(b.source));
}
function parseClassLines(linesBlock) {
const acc = {
lineCount: 0,
lineCovered: 0,
branchCount: 0,
branchCovered: 0,
};
if (!linesBlock) {
return acc;
}
const lineRe = /<line\b[^>]*\/>/gi;
let lm;
while ((lm = lineRe.exec(linesBlock)) !== null) {
const lt = lm[0];
const hits = parseInt(getAttr(lt, 'hits') || '0', 10);
acc.lineCount++;
if (hits > 0) {
acc.lineCovered++;
}
if (getAttr(lt, 'branch') === 'true') {
const cc = getAttr(lt, 'condition-coverage') || '';
const ccMatch = cc.match(/(\d+)\s*\/\s*(\d+)/);
if (ccMatch) {
acc.branchCovered += parseInt(ccMatch[1], 10);
acc.branchCount += parseInt(ccMatch[2], 10);
} else {
acc.branchCount += 2;
}
}
}
return acc;
}
function getAttr(tag, name) {
const re = new RegExp(`\\b${name}="([^"]*)"`, 'i');
const m = tag.match(re);
return m ? m[1] : null;
}
function extractBlock(xml, startIdx, tag) {
const openRe = new RegExp(`<${tag}\\b[^>]*>`, 'gi');
openRe.lastIndex = startIdx;
const om = openRe.exec(xml);
if (!om) {
return null;
}
const closeTag = `</${tag}>`;
const closeIdx = xml.indexOf(closeTag, om.index + om[0].length);
if (closeIdx === -1) {
return null;
}
return xml.substring(om.index + om[0].length, closeIdx);
}
function collectProfraw(buildDir) {
const all = fs
.readdirSync(buildDir)
.filter(f => f.endsWith('.profraw'))
.map(f => path.join(buildDir, f));
return all;
}
function safeUnlink(p) {
try {
fs.unlinkSync(p);
} catch {
}
}
function cleanStaleCoverage(buildDir, profdataPath) {
if (fs.existsSync(buildDir)) {
const stale = fs.readdirSync(buildDir).filter(f => f.endsWith('.profraw'));
for (const f of stale) {
safeUnlink(path.join(buildDir, f));
}
}
if (profdataPath && fs.existsSync(profdataPath)) {
safeUnlink(profdataPath);
}
}
function mergeProfraw(profdataBin, profrawFiles, profdataPath) {
if (profrawFiles.length === 0) {
throw new Error(
'No .profraw files found — did the test run produce coverage data?',
);
}
progress(
`▸ Merging ${profrawFiles.length} profraw → ${path.basename(profdataPath)}`,
);
runOrThrow(
profdataBin,
['merge', '-sparse', ...profrawFiles, '-o', profdataPath],
{stdio: 'pipe'},
'llvm-profdata',
);
}
function exportCoverage(llvmCovBin, binaries, profdataPath) {
progress(
`▸ Exporting coverage JSON (llvm-cov export, ${binaries.length} binary/binaries)`,
);
const args = [
'export',
'-format=json',
`-instr-profile=${profdataPath}`,
binaries[0],
];
for (let i = 1; i < binaries.length; i++) {
args.push('-object', binaries[i]);
}
const withFlag = run(llvmCovBin, args, {
encoding: 'utf8',
maxBuffer: 256 * 1024 * 1024,
});
const candidate = withFlag.status === 0 ? withFlag.stdout : null;
let out = candidate;
if (!out) {
const noFlagArgs = [
'export',
`-instr-profile=${profdataPath}`,
binaries[0],
];
for (let i = 1; i < binaries.length; i++) {
noFlagArgs.push('-object', binaries[i]);
}
const noFlag = runOrThrow(
llvmCovBin,
noFlagArgs,
{encoding: 'utf8', maxBuffer: 256 * 1024 * 1024},
'llvm-cov-export',
);
out = noFlag.stdout;
}
const trimmed = out.trim();
if (!trimmed.startsWith('{')) {
throw new Error(
'llvm-cov export did not produce JSON. On GCC use lcov instead (not yet supported in v1).',
);
}
return JSON.parse(trimmed);
}
function relativize(absPath) {
if (!absPath) {
return absPath;
}
let p = absPath;
if (path.isAbsolute(p)) {
const rel = path.relative(CPP_DIR, p);
if (!rel.startsWith('..')) {
return rel;
}
}
return p;
}
function isRnohSource(relPath) {
if (!relPath) {
return false;
}
const norm = relPath.replace(/\\/g, '/');
if (norm.startsWith('third-party/')) {
return false;
}
if (norm.startsWith('tests/mock')) {
return false;
}
if (norm.startsWith('tests/mock_poc')) {
return false;
}
if (norm.startsWith('tests/stub')) {
return false;
}
if (norm.includes('/tests/')) {
return false;
}
if (/Test[s]?\.cpp$/i.test(norm)) {
return false;
}
return (
norm.startsWith('RNOH/') ||
norm.startsWith('RNOHCorePackage/') ||
norm === 'RNOH'
);
}
function isUnderReportDir(relPath, reportDir) {
const absFile = path.resolve(CPP_DIR, relPath);
const absReport = path.resolve(reportDir || CPP_DIR);
if (absFile === absReport) {
return true;
}
return absFile.startsWith(absReport + path.sep);
}
function coLocatedTestFile(relSourcePath) {
const dir = path.dirname(path.join(CPP_DIR, relSourcePath));
const testsDir = path.join(dir, 'tests');
if (fs.existsSync(testsDir)) {
const candidates = fs
.readdirSync(testsDir)
.filter(f => /Test[s]?\.cpp$/i.test(f))
.sort();
if (candidates.length > 0) {
const relDir = path.join(path.dirname(relSourcePath), 'tests');
return `${relDir}/ (${candidates.length}: ${candidates.join(', ')})`;
}
}
return null;
}
function collectCoverageFileRows(data, reportDir) {
const fileRows = [];
for (const unit of data) {
const files = unit.files || [];
for (const f of files) {
const rel = relativize(f.filename);
if (!isRnohSource(rel)) {
continue;
}
if (!isUnderReportDir(rel, reportDir)) {
continue;
}
const s = f.summary || {};
const lines = s.lines || {count: 0, covered: 0, percent: 0};
const branches = s.branches || {count: 0, covered: 0, percent: 0};
const bCount = branches.count;
const bCovered = branches.covered;
const bPercent = bCount
? (bCovered / bCount) * 100
: lines.covered > 0
? 100
: 0;
fileRows.push({
source: rel,
lineCount: lines.count,
lineCovered: lines.covered,
linePercent: lines.percent,
branchCount: bCount,
branchCovered: bCovered,
branchPercent: bPercent,
coLocatedTest: coLocatedTestFile(rel),
});
}
}
return fileRows;
}
function mergeRowsBySource(fileRows) {
const bySource = new Map();
for (const r of fileRows) {
const ex = bySource.get(r.source);
if (!ex) {
bySource.set(r.source, r);
} else {
ex.lineCovered = Math.max(ex.lineCovered, r.lineCovered);
ex.lineCount = r.lineCount || ex.lineCount;
ex.linePercent = ex.lineCount ? (ex.lineCovered / ex.lineCount) * 100 : 0;
ex.branchCovered = Math.max(ex.branchCovered, r.branchCovered);
ex.branchCount = r.branchCount || ex.branchCount;
ex.branchPercent = ex.branchCount
? (ex.branchCovered / ex.branchCount) * 100
: ex.lineCovered > 0
? 100
: 0;
}
}
return [...bySource.values()];
}
function summarizeCoverage(covJson, reportDir) {
const data = Array.isArray(covJson.data) ? covJson.data : [];
const fileRows = collectCoverageFileRows(data, reportDir);
return mergeRowsBySource(fileRows).sort((a, b) =>
a.source.localeCompare(b.source),
);
}
function rate(row) {
const line = row.linePercent;
const branch =
row.branchCount === 0 && row.lineCovered === 0 ? 0 : row.branchPercent;
if (line >= LINE_GREEN && branch >= BRANCH_GREEN) {
return {rating: 'GREEN', color: '🟢'};
}
if (line >= LINE_YELLOW || branch >= BRANCH_YELLOW) {
return {rating: 'YELLOW', color: '🟡'};
}
return {rating: 'RED', color: '🔴'};
}
function computeTotals(rows) {
const totalLine = rows.reduce(
(a, r) => ({
count: a.count + r.lineCount,
covered: a.covered + r.lineCovered,
}),
{count: 0, covered: 0},
);
const totalBranch = rows.reduce(
(a, r) => ({
count: a.count + r.branchCount,
covered: a.covered + r.branchCovered,
}),
{count: 0, covered: 0},
);
const linePct = totalLine.count
? (totalLine.covered / totalLine.count) * 100
: 0;
const branchPct = totalBranch.count
? (totalBranch.covered / totalBranch.count) * 100
: 0;
const overall = rate({linePercent: linePct, branchPercent: branchPct});
return {totalLine, totalBranch, linePct, branchPct, overall};
}
function formatRow(r) {
const rt = rate(r);
const lineStr = r.lineCount
? `${r.linePercent.toFixed(1)}% (${r.lineCovered}/${r.lineCount})`
: '—';
const brStr = r.branchCount
? `${r.branchPercent.toFixed(1)}% (${r.branchCovered}/${r.branchCount})`
: r.lineCovered > 0
? '100.0% (0/0)'
: '0.0% (0/0)';
const lines = [
`📄 ${r.source}`,
` Line: ${lineStr} | Branch: ${brStr} | ${rt.color} ${rt.rating}`,
];
if (r.coLocatedTest) {
lines.push(` ↳ co-located test: ${r.coLocatedTest}`);
}
return lines.join('\n');
}
function printJsonReport(rows, totals) {
console.log(
JSON.stringify(
{
summary: {
files: rows.length,
lineCoverage: Math.round(totals.linePct),
branchCoverage: Math.round(totals.branchPct),
rating: totals.overall.rating,
thresholds: {
lineGreen: LINE_GREEN,
lineYellow: LINE_YELLOW,
branchGreen: BRANCH_GREEN,
branchYellow: BRANCH_YELLOW,
},
},
files: rows.map(r => ({...r, rating: rate(r).rating})),
},
null,
2,
),
);
}
function printTextReport(rows, totals, quiet) {
console.log(
'\n╔════════════════════════════════════════════════════════════════╗',
);
console.log(
'║ RNOH C++ gtest Coverage Report (line + branch) ║',
);
console.log(
'╚════════════════════════════════════════════════════════════════╝\n',
);
if (!quiet) {
for (const r of rows) {
console.log(formatRow(r));
}
}
console.log(
'\n═══════════════════════════════════════════════════════════════',
);
console.log(
`Summary: ${rows.length} files | Line: ${totals.linePct.toFixed(1)}% | Branch: ${totals.branchPct.toFixed(1)}% | ${totals.overall.color} ${totals.overall.rating}`,
);
console.log(
'═══════════════════════════════════════════════════════════════\n',
);
}
function printReport(rows, json, quiet) {
if (rows.length === 0) {
console.log('No RNOH source files found in coverage report.');
return;
}
const totals = computeTotals(rows);
if (json) {
printJsonReport(rows, totals);
return;
}
printTextReport(rows, totals, quiet);
}
function printHelp() {
console.log('Usage: node scripts/gtest-coverage-check.js [options]');
console.log(
' --dir <path> Source root to FILTER coverage report to (default: RNOH cpp dir)',
);
console.log(
' --build-dir <path> CMake build dir (default: <cpp>/tests/build-cov)',
);
console.log(
' --generator <name> CMake -G override (default: VS2022→VS2019)',
);
console.log(
' --target <name> Executable target (default: auto-discover all)',
);
console.log(
' --all-targets Auto-discover & run all rnoh_* test targets (zero-maintenance)',
);
console.log(' --no-build Skip configure+build');
console.log(' --no-run Skip running tests');
console.log(' --json Output JSON');
console.log(' --quiet Summary only (no per-file breakdown)');
console.log(' --help Show this help');
}
function parseArgs(argv) {
const opts = {
reportDir: CPP_DIR,
buildDir: path.join(TESTS_DIR, 'build-cov'),
target: DEFAULT_TARGET,
allTargets: false,
noBuild: false,
noRun: false,
json: false,
quiet: false,
generator: null,
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--dir' && argv[i + 1]) {
opts.reportDir = argv[++i];
} else if (a === '--build-dir' && argv[i + 1]) {
opts.buildDir = argv[++i];
} else if (a === '--target' && argv[i + 1]) {
opts.target = argv[++i];
} else if (a === '--all-targets') {
opts.allTargets = true;
} else if (a === '--no-build') {
opts.noBuild = true;
} else if (a === '--no-run') {
opts.noRun = true;
} else if (a === '--json') {
opts.json = true;
} else if (a === '--quiet') {
opts.quiet = true;
} else if (a === '--generator' && argv[i + 1]) {
opts.generator = argv[++i];
} else if (a === '--help' || a === '-h') {
printHelp();
process.exit(0);
} else {
console.error(`Unknown option: ${a}`);
process.exit(2);
}
}
return opts;
}
function main() {
const opts = parseArgs(process.argv.slice(2));
jsonMode = opts.json;
generatorOverride = opts.generator;
if (!opts.target && !opts.allTargets) {
opts.allTargets = true;
}
const cmakeBin = findCmake();
const tc = detectToolchain(cmakeBin, opts.buildDir);
const targets = opts.allTargets || !opts.target ? [] : [opts.target];
let rows;
if (tc.isClang) {
rows = runClangCoverage(
cmakeBin,
tc,
opts.buildDir,
targets,
opts.reportDir,
opts.noBuild,
opts.noRun,
opts.allTargets,
);
} else if (tc.isMsvcFallback) {
rows = runMsvcCoverage(
cmakeBin,
tc,
opts.buildDir,
targets,
opts.reportDir,
opts.noBuild,
opts.noRun,
opts.allTargets,
);
} else if (tc.isGcc) {
rows = runGccCoverage(
cmakeBin,
tc,
opts.buildDir,
targets,
opts.reportDir,
opts.noBuild,
opts.noRun,
opts.allTargets,
);
} else {
printToolchainMissingError(tc);
process.exit(1);
}
printReport(rows, opts.json, opts.quiet);
}
if (require.main === module) {
main();
}
module.exports = {
parseCoberturaXML,
mergeCoberturaXML,
parseLcovInfo,
summarizeCoverage,
isRnohSource,
isUnderReportDir,
relativize,
coLocatedTestFile,
rate,
cleanStaleCoverage,
};
function discoverAndBuild(cmakeBin, buildDir, targets, allTargets, noBuild) {
if (allTargets) {
targets = discoverTestTargets(cmakeBin, buildDir);
progress(
`▸ Auto-discovered ${targets.length} test target(s):\n ${targets.join(', ')}`,
);
}
if (!noBuild) {
for (const t of targets) {
try {
buildTarget(cmakeBin, buildDir, t);
} catch (e) {
warn(
`Build failed for target ${t} — skipping. ` +
`(${String(e.message || e).split('\n')[0]})`,
);
}
}
}
return targets;
}
function alignLlvmTools(tc, buildDir) {
const ci = readCmakeCxxCompiler(buildDir);
if (!ci) {
return;
}
const aligned = findLlvmToolsForCompiler(ci.path);
if (!aligned.llvmProfdata && !aligned.llvmCov) {
if (tc.llvmProfdata) {
warn(
`No llvm-profdata/llvm-cov alongside compiler ${ci.path}; ` +
`falling back to ${tc.llvmProfdata} (profraw version mismatch may occur).`,
);
}
return;
}
if (tc.llvmProfdata !== aligned.llvmProfdata) {
progress(
` llvm-profdata aligned to compiler (${ci.key}): ${aligned.llvmProfdata}`,
);
}
if (aligned.llvmProfdata) {
tc.llvmProfdata = aligned.llvmProfdata;
}
if (aligned.llvmCov) {
tc.llvmCov = aligned.llvmCov;
}
}
function clangRunAndCollect(tc, buildDir, targets, noRun, profdataPath) {
if (!noRun) {
cleanStaleCoverage(buildDir, profdataPath);
for (const t of targets) {
const bin = binaryPath(buildDir, t);
if (!fs.existsSync(bin)) {
warn(`Binary not found for target ${t}: ${bin} — skipping`);
continue;
}
runTests(bin, buildDir);
}
const allProfraw = collectProfraw(buildDir);
if (allProfraw.length === 0) {
throw new Error(
'No .profraw produced. Ensure tests ran with LLVM_PROFILE_FILE set.',
);
}
mergeProfraw(tc.llvmProfdata, allProfraw, profdataPath);
} else if (!fs.existsSync(profdataPath)) {
throw new Error(`--no-run but no profdata at ${profdataPath}`);
}
}
function clangExport(tc, buildDir, targets, profdataPath, reportDir) {
const allBins = [];
for (const t of targets) {
const bin = binaryPath(buildDir, t);
if (fs.existsSync(bin)) {
allBins.push(bin);
} else {
warn(`Binary not found for target ${t}: ${bin} — skipping`);
}
}
if (allBins.length === 0) {
throw new Error('No test binaries found to export coverage from.');
}
const covJson = exportCoverage(tc.llvmCov, allBins, profdataPath);
return summarizeCoverage(covJson, reportDir);
}
function runClangCoverage(
cmakeBin,
tc,
buildDir,
targets,
reportDir,
noBuild,
noRun,
allTargets,
) {
progress(`Toolchain: clang source-based (line + branch)`);
progress(` clang : ${tc.clang}`);
progress(` llvm-cov : ${tc.llvmCov}`);
progress(` llvm-profdata: ${tc.llvmProfdata}`);
if (!noBuild) {
configureBuild(cmakeBin, buildDir, '', true);
}
if (isWindows()) {
alignLlvmTools(tc, buildDir);
}
targets = discoverAndBuild(cmakeBin, buildDir, targets, allTargets, noBuild);
const profdataPath = path.join(buildDir, 'coverage.profdata');
clangRunAndCollect(tc, buildDir, targets, noRun, profdataPath);
return clangExport(tc, buildDir, targets, profdataPath, reportDir);
}
function parseCoberturaLines(linesBlock, lineMap) {
const lineRe = /<line\b[^>]*\/>/gi;
let lm;
while ((lm = lineRe.exec(linesBlock)) !== null) {
const lt = lm[0];
const num = parseInt(getAttr(lt, 'number') || '0', 10);
if (!num) {
continue;
}
const hits = parseInt(getAttr(lt, 'hits') || '0', 10);
const isBranch = getAttr(lt, 'branch') === 'true';
let ccCovered = 0;
let ccTotal = 0;
if (isBranch) {
const cc = getAttr(lt, 'condition-coverage') || '';
const ccMatch = cc.match(/(\d+)\s*\/\s*(\d+)/);
if (ccMatch) {
ccCovered = parseInt(ccMatch[1], 10);
ccTotal = parseInt(ccMatch[2], 10);
} else {
ccTotal = 2;
}
}
let e = lineMap.get(num);
if (!e) {
e = {hits: 0, branch: false, ccCovered: 0, ccTotal: 0};
lineMap.set(num, e);
}
if (hits > e.hits) {
e.hits = hits;
}
if (isBranch) {
e.branch = true;
if (ccCovered > e.ccCovered) {
e.ccCovered = ccCovered;
}
if (ccTotal > e.ccTotal) {
e.ccTotal = ccTotal;
}
}
}
}
function aggregateCoberturaRows(byFile, reportDir) {
const rows = [];
for (const [filename, lineMap] of byFile) {
const rel = relativize(filename);
if (!isRnohSource(rel)) {
continue;
}
if (!isUnderReportDir(rel, reportDir)) {
continue;
}
let lineCount = 0;
let lineCovered = 0;
let branchCount = 0;
let branchCovered = 0;
for (const e of lineMap.values()) {
lineCount++;
if (e.hits > 0) {
lineCovered++;
}
if (e.branch) {
branchCount += e.ccTotal || 2;
branchCovered += e.ccCovered;
}
}
const linePercent = lineCount ? (lineCovered / lineCount) * 100 : 0;
const branchPercent = branchCount
? (branchCovered / branchCount) * 100
: lineCovered > 0
? 100
: 0;
rows.push({
source: rel,
lineCount,
lineCovered,
linePercent,
branchCount,
branchCovered,
branchPercent,
coLocatedTest: coLocatedTestFile(rel),
});
}
return rows.sort((a, b) => a.source.localeCompare(b.source));
}
function mergeCoberturaXML(xmls, reportDir) {
const byFile = new Map();
const classRe = /<class\b[^>]*\bfilename="([^"]+)"[^>]*>/gi;
for (const xml of xmls) {
let m;
classRe.lastIndex = 0;
while ((m = classRe.exec(xml)) !== null) {
const filename = m[1];
const linesBlock = extractBlock(xml, m.index, 'lines');
if (!linesBlock) {
continue;
}
let lineMap = byFile.get(filename);
if (!lineMap) {
lineMap = new Map();
byFile.set(filename, lineMap);
}
parseCoberturaLines(linesBlock, lineMap);
}
}
return aggregateCoberturaRows(byFile, reportDir);
}
function cleanStaleCoverageXml(buildDir) {
if (!fs.existsSync(buildDir)) {
return;
}
for (const f of fs.readdirSync(buildDir)) {
if (/^coverage.*\.xml$/i.test(f)) {
safeUnlink(path.join(buildDir, f));
}
}
}
function msvcRunAndCollect(tc, buildDir, targets, reportDir, noRun) {
if (!noRun) {
cleanStaleCoverageXml(buildDir);
const xmlFiles = [];
let ran = false;
for (const t of targets) {
const bin = binaryPath(buildDir, t);
if (!fs.existsSync(bin)) {
warn(`Binary not found for target ${t}: ${bin} — skipping`);
continue;
}
const tx = path.join(buildDir, `coverage-${t}.xml`);
runOpenCppCoverage(tc.openCpp, bin, buildDir, reportDir, tx);
xmlFiles.push(tx);
ran = true;
}
if (!ran) {
throw new Error('No test binary found to run under OpenCppCoverage.');
}
return mergeCoberturaXML(
xmlFiles.map(p => fs.readFileSync(p, 'utf8')),
reportDir,
);
}
const perTarget = fs.existsSync(buildDir)
? fs
.readdirSync(buildDir)
.filter(f => /^coverage-.*\.xml$/i.test(f))
.map(f => path.join(buildDir, f))
.sort()
: [];
if (perTarget.length > 0) {
return mergeCoberturaXML(
perTarget.map(p => fs.readFileSync(p, 'utf8')),
reportDir,
);
}
const xmlPath = path.join(buildDir, 'coverage.xml');
if (!fs.existsSync(xmlPath)) {
throw new Error(`--no-run but no coverage XML at ${xmlPath}`);
}
return parseCoberturaXML(fs.readFileSync(xmlPath, 'utf8'), reportDir);
}
function runMsvcCoverage(
cmakeBin,
tc,
buildDir,
targets,
reportDir,
noBuild,
noRun,
allTargets,
) {
progress(
`Toolchain: MSVC + OpenCppCoverage (LINE only — branch unavailable on MSVC)`,
);
progress(` OpenCppCoverage: ${tc.openCpp}`);
warn(
'Branch coverage is unavailable on the MSVC+OpenCppCoverage path. ' +
'Install LLVM (clang/clang-cl) for full line+branch coverage.',
);
if (!noBuild) {
configureBuild(cmakeBin, buildDir, '', false);
}
targets = discoverAndBuild(cmakeBin, buildDir, targets, allTargets, noBuild);
return msvcRunAndCollect(tc, buildDir, targets, reportDir, noRun);
}
function cleanStaleGcda(buildDir) {
if (!fs.existsSync(buildDir)) {
return;
}
const stack = [buildDir];
while (stack.length) {
const dir = stack.pop();
let entries;
try {
entries = fs.readdirSync(dir, {withFileTypes: true});
} catch {
continue;
}
for (const ent of entries) {
const p = path.join(dir, ent.name);
if (ent.isDirectory()) {
stack.push(p);
} else if (ent.name.endsWith('.gcda')) {
safeUnlink(p);
}
}
}
}
function handleLcovLine(line, byFile, cur) {
if (line.startsWith('SF:')) {
const rel = relativize(line.slice(3));
const existing = byFile.get(rel);
if (existing) {
return existing;
}
const entry = {rel, lines: new Map(), branches: []};
byFile.set(rel, entry);
return entry;
}
if (!cur) {
return cur;
}
if (line.startsWith('DA:')) {
const parts = line.slice(3).split(',');
const num = parseInt(parts[0], 10);
const hits = parseInt(parts[1] || '0', 10);
if (num) {
cur.lines.set(num, (cur.lines.get(num) || 0) + hits);
}
} else if (line.startsWith('BRDA:')) {
const parts = line.slice(5).split(',');
const num = parseInt(parts[0], 10);
const taken = parts[3];
if (num) {
cur.branches.push({line: num, taken});
}
} else if (line.startsWith('end_of_record')) {
return null;
}
return cur;
}
function aggregateLcovRow(rel, entry, reportDir) {
if (!isRnohSource(rel)) {
return null;
}
if (!isUnderReportDir(rel, reportDir)) {
return null;
}
let lineCount = 0;
let lineCovered = 0;
let branchCount = 0;
let branchCovered = 0;
for (const hits of entry.lines.values()) {
lineCount++;
if (hits > 0) {
lineCovered++;
}
}
for (const b of entry.branches) {
branchCount++;
if (b.taken !== '-' && parseInt(b.taken, 10) >= 1) {
branchCovered++;
}
}
const linePercent = lineCount ? (lineCovered / lineCount) * 100 : 0;
const branchPercent = branchCount
? (branchCovered / branchCount) * 100
: lineCovered > 0
? 100
: 0;
return {
source: rel,
lineCount,
lineCovered,
linePercent,
branchCount,
branchCovered,
branchPercent,
coLocatedTest: coLocatedTestFile(rel),
};
}
function parseLcovInfo(info, reportDir) {
const byFile = new Map();
let cur = null;
for (const line of info.split(/\r?\n/)) {
cur = handleLcovLine(line, byFile, cur);
}
const rows = [];
for (const [rel, e] of byFile) {
const row = aggregateLcovRow(rel, e, reportDir);
if (row) {
rows.push(row);
}
}
return rows.sort((a, b) => a.source.localeCompare(b.source));
}
function gccRunAndCapture(tc, buildDir, targets, noRun, infoPath) {
if (!noRun) {
cleanStaleGcda(buildDir);
let ran = false;
for (const t of targets) {
const bin = binaryPath(buildDir, t);
if (!fs.existsSync(bin)) {
warn(`Binary not found for target ${t}: ${bin} — skipping`);
continue;
}
progress(`▸ Running ${path.basename(bin)}`);
const res = run(bin, [], {encoding: 'utf8', stdio: 'inherit'});
if (res.status !== 0) {
warn(
`⚠ ${path.basename(bin)} exited ${res.status} — collecting partial coverage`,
);
}
ran = true;
}
if (!ran) {
throw new Error('No test binary found to run for GCC coverage.');
}
progress(
`▸ Capturing coverage (lcov --capture) → ${path.basename(infoPath)}`,
);
const lcovRes = run(
tc.lcov,
[
'--capture',
'--directory',
buildDir,
'--rc',
'branch_coverage=1',
'--output-file',
infoPath,
],
{stdio: 'inherit'},
);
if (lcovRes.status !== 0) {
throw new Error(
`lcov --capture exited ${lcovRes.status}\n${lcovRes.stderr || lcovRes.stdout}`,
);
}
if (!fs.existsSync(infoPath)) {
throw new Error(`lcov did not produce ${infoPath}`);
}
} else if (!fs.existsSync(infoPath)) {
throw new Error(`--no-run but no coverage.info at ${infoPath}`);
}
}
function runGccCoverage(
cmakeBin,
tc,
buildDir,
targets,
reportDir,
noBuild,
noRun,
allTargets,
) {
progress('Toolchain: GCC gcov + lcov (line + branch)');
progress(` gcov: ${tc.gcov}`);
progress(` lcov: ${tc.lcov}`);
if (!noBuild) {
configureBuild(cmakeBin, buildDir, '', true);
}
targets = discoverAndBuild(cmakeBin, buildDir, targets, allTargets, noBuild);
const infoPath = path.join(buildDir, 'coverage.info');
gccRunAndCapture(tc, buildDir, targets, noRun, infoPath);
const info = fs.readFileSync(infoPath, 'utf8');
if (!/^\s*BRDA:/m.test(info)) {
warn(
'lcov produced no branch (BRDA) records — branch coverage shown as 0/0. ' +
'Ensure lcov >= 1.10 and that --rc branch_coverage=1 is supported.',
);
}
return parseLcovInfo(info, reportDir);
}
function printToolchainMissingError(tc) {
console.error('No supported coverage toolchain found.');
if (isWindows()) {
console.error('');
console.error('Option A (recommended — full line + branch coverage):');
console.error(' Install LLVM: winget install LLVM.LLVM');
console.error(
" (or VS Installer → 'C++ Clang Compiler for Windows' + 'C++ Clang-cl'",
);
console.error(' then build with clang-cl via cmake -T ClangCL)');
console.error('');
console.error('Option B (line coverage only):');
console.error(' Install OpenCppCoverage: choco install opencppcoverage');
console.error(' (branch coverage not supported on the MSVC path)');
} else if (isDarwin()) {
console.error(
'clang source-based coverage requires llvm-cov + llvm-profdata.',
);
console.error(
'Install Xcode Command Line Tools, or run via `xcrun` (script does this).',
);
} else if (tc.gcov && !tc.lcov) {
console.error(
'gcov found but lcov is missing — lcov is required for the GCC path.',
);
console.error(' apt-get install lcov');
} else {
console.error(
'Install clang + llvm tools, or gcov + lcov for the GCC path.',
);
console.error(' apt-get install clang llvm lcov');
}
}