cobertura
Version:
A cobertura reporter for `node:test`
232 lines (204 loc) • 7.5 kB
JavaScript
;
const path = require('path');
function escapeAttribute(s = '') {
return s.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/"/g, '"')
.replace(/\n/g, '');
}
function escapeContent(s = '') {
return s.replace(/&/g, '&').replace(/</g, '<');
}
function treeToXML(tree) {
if (typeof tree === 'string') {
return `${escapeContent(tree)}\n`;
}
const { tag, attrs = {}, nesting, children = [] } = tree;
const indent = '\t'.repeat(nesting + 1);
const attrsString = Object.entries(attrs)
.map(([key, value]) => `${key}="${escapeAttribute(String(value))}"`)
.join(' ');
if (!children.length) {
return `${indent}<${tag} ${attrsString}/>\n`;
}
const childrenString = children.map(treeToXML).join('');
return `${indent}<${tag} ${attrsString}>\n${childrenString}${indent}</${tag}>\n`;
}
module.exports = async function* coberturaReporter(source) {
yield '<?xml version="1.0" ?>\n';
yield '<!DOCTYPE coverage SYSTEM "http://cobertura.sourceforge.net/xml/coverage-04.dtd">\n';
for await (const event of source) {
switch (event.type) {
case 'test:coverage': {
const { totals, workingDirectory, files } = event.data.summary;
const rootName = path.parse(workingDirectory).name;
// individual test files becomes "classes" in terminology of cobertura
const classes = files.map(f => {
const relativePath = path.relative(workingDirectory, f.path);
const dirname = path.dirname(relativePath);
const withReplacesSep = dirname.replaceAll(path.sep, '.');
const branchesByLine = f.branches.reduce((acc, b) => {
if (!acc[b.line]) {
acc[b.line] = [];
}
acc[b.line].push(b);
return acc;
}, {});
return {
filename: relativePath,
packageName: dirname === '.' ? rootName : `${rootName}.${withReplacesSep}`,
className: path.basename(f.path),
classLineRate: (f.coveredLinePercent / 100).toFixed(4),
classTotalLineCount: f.totalLineCount,
classCoveredLineCount: f.coveredLineCount,
classBranchRate: (f.coveredBranchPercent / 100).toFixed(4),
classTotalBranchCount: f.totalBranchCount,
classCoveredBranchCount: f.coveredBranchCount,
methods: f.functions.map((fn, i) => ({
name: fn.name || `(anonymous_${i})`,
hits: fn.count,
signature: '()V',
number: fn.line,
})),
lines: f.lines.map(l => {
const branches = branchesByLine[l.line] || [];
const total = branches.length;
const covered = branches.filter(b => b.count !== 0).length;
const conditionCoverage = branches.length
? `${(covered / total) * 100}% (${covered}/${total})`
: undefined;
return {
branch: branches.length !== 0,
number: l.line,
hits: l.count,
conditionCoverage,
};
}),
};
});
// folders becomes packages where children are direct tests within that folder
const packages = classes.reduce((acc, c) => {
if (!acc[c.packageName]) {
acc[c.packageName] = {
children: [],
packageTotalLineCount: 0,
packageCoveredLineCount: 0,
packageTotalBranchCount: 0,
packageCoveredBranchCount: 0,
};
}
acc[c.packageName].children.push(c);
acc[c.packageName].packageTotalLineCount += c.classTotalLineCount;
acc[c.packageName].packageCoveredLineCount += c.classCoveredLineCount;
acc[c.packageName].packageTotalBranchCount += c.classTotalBranchCount;
acc[c.packageName].packageCoveredBranchCount += c.classCoveredBranchCount;
return acc;
}, {});
const packageTree = {
tag: 'packages',
nesting: 0,
children: Object.entries(packages).map(({ 0: key, 1: value }) => ({
tag: 'package',
attrs: {
name: key,
'line-rate': (value.packageCoveredLineCount / value.packageTotalLineCount).toFixed(4),
'branch-rate': (
value.packageCoveredBranchCount / value.packageTotalBranchCount
).toFixed(4),
},
nesting: 1,
children: value.children.map(c => ({
tag: 'class',
attrs: {
name: c.className,
filename: c.filename,
'line-rate': c.classLineRate,
'branch-rate': c.classBranchRate,
},
nesting: 2,
children: [
{
tag: 'methods',
nesting: 3,
children: c.methods.map(m => ({
tag: 'method',
attrs: {
name: m.name,
hits: m.hits,
signature: m.signature,
},
nesting: 4,
children: [
{
tag: 'lines',
nesting: 5,
children: [
{
tag: 'line',
attrs: {
number: m.number,
hits: m.hits,
},
nesting: 6,
},
],
},
],
})),
},
{
tag: 'lines',
nesting: 3,
children: c.lines.map(l => {
const attrs = {
number: l.number,
hits: l.hits,
branch: l.branch,
};
if (l.branch) {
attrs['condition-coverage'] = l.conditionCoverage;
}
return { tag: 'line', nesting: 4, attrs };
}),
},
],
})),
})),
};
const result = {
tag: 'coverage',
attrs: {
'lines-valid': totals.totalLineCount,
'lines-covered': totals.coveredLineCount,
'line-rate': (totals.coveredLineCount / totals.totalLineCount).toFixed(4),
'branches-valid': totals.totalBranchCount,
'branches-covered': totals.coveredBranchCount,
'branch-rate': (totals.coveredBranchCount / totals.totalBranchCount).toFixed(4),
timestamp: new Date().getTime(),
complexity: '0',
version: '0.1',
},
nesting: -1,
children: [
{
tag: 'sources',
children: [
{
tag: 'source',
children: [workingDirectory],
nesting: 1,
},
],
nesting: 0,
},
packageTree,
],
};
yield treeToXML(result);
break;
}
default:
break;
}
}
};