Files
less.js/packages/less/scripts/coverage-lines.js
Matthew Dean 432286970a [Needs reviews!] Mega test refactor (#4378)
* Migrate Less to use valid CSS

* Re-organize test structure

* Lots of test refactoring

* More test updates

* Add restructured tests

* WIP

Signed-off-by: Matthew Dean <matthew-dean@users.noreply.github.com>

* More fixes to tests

* More test fixes

* All tests passing

* WIP fix tests

* Finished fixing browser tests

* Improve test coverage

* Add debug tests

* Add back debug tests to show equal test coverage

* Fix browser tests

* More test coverage

* Fix sourcemap absolute path for CI

* More source map normalization for Windows

* Fix source map normalization

* Another attempted fix for Windows

---------

Signed-off-by: Matthew Dean <matthew-dean@users.noreply.github.com>
2025-12-06 11:28:46 -08:00

208 lines
7.4 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Generates a line-by-line coverage report showing uncovered lines
* Reads from LCOV format and displays in terminal
* Also outputs JSON file with uncovered lines for programmatic access
*/
const fs = require('fs');
const path = require('path');
const lcovPath = path.join(__dirname, '..', 'coverage', 'lcov.info');
const jsonOutputPath = path.join(__dirname, '..', 'coverage', 'uncovered-lines.json');
if (!fs.existsSync(lcovPath)) {
console.error('LCOV coverage file not found. Run pnpm test:coverage first.');
process.exit(1);
}
const lcovContent = fs.readFileSync(lcovPath, 'utf8');
// Parse LCOV format
const files = [];
let currentFile = null;
const lines = lcovContent.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// SF: source file
if (line.startsWith('SF:')) {
if (currentFile) {
files.push(currentFile);
}
const filePath = line.substring(3);
// Only include src/ files (not less-browser) and bin/
// Exclude abstract base classes (they're meant to be overridden)
const normalized = filePath.replace(/\\/g, '/');
const abstractClasses = ['abstract-file-manager', 'abstract-plugin-loader'];
const isAbstract = abstractClasses.some(abstract => normalized.includes(abstract));
if (!isAbstract &&
((normalized.includes('src/less/') && !normalized.includes('src/less-browser/')) ||
normalized.includes('src/less-node/') ||
normalized.includes('bin/'))) {
// Extract relative path - match src/less/... or src/less-node/... or bin/...
// Path format: src/less/tree/debug-info.js or src/less-node/file-manager.js
// Match from src/ or bin/ to end of path
const match = normalized.match(/(src\/[^/]+\/.+|bin\/.+)$/);
const relativePath = match ? match[1] : (normalized.includes('/src/') || normalized.includes('/bin/') ? normalized.split('/').slice(-3).join('/') : path.basename(filePath));
currentFile = {
path: relativePath,
fullPath: filePath,
uncoveredLines: [],
uncoveredLineCode: {}, // line number -> source code
totalLines: 0,
coveredLines: 0
};
} else {
currentFile = null;
}
}
// DA: line data (line number, execution count)
if (currentFile && line.startsWith('DA:')) {
const match = line.match(/^DA:(\d+),(\d+)$/);
if (match) {
const lineNum = parseInt(match[1], 10);
const count = parseInt(match[2], 10);
currentFile.totalLines++;
if (count > 0) {
currentFile.coveredLines++;
} else {
currentFile.uncoveredLines.push(lineNum);
}
}
}
}
if (currentFile) {
files.push(currentFile);
}
// Read source code for uncovered lines
files.forEach(file => {
if (file.uncoveredLines.length > 0 && fs.existsSync(file.fullPath)) {
try {
const sourceCode = fs.readFileSync(file.fullPath, 'utf8');
const sourceLines = sourceCode.split('\n');
file.uncoveredLines.forEach(lineNum => {
// LCOV uses 1-based line numbers
if (lineNum > 0 && lineNum <= sourceLines.length) {
file.uncoveredLineCode[lineNum] = sourceLines[lineNum - 1].trim();
}
});
} catch (err) {
// If we can't read the source (e.g., it's in lib/ but we want src/), that's ok
// We'll just skip the source code
}
}
});
// Filter to only files with uncovered lines and sort by coverage
const filesWithGaps = files
.filter(f => f.uncoveredLines.length > 0)
.sort((a, b) => {
const aPct = a.totalLines > 0 ? a.coveredLines / a.totalLines : 1;
const bPct = b.totalLines > 0 ? b.coveredLines / b.totalLines : 1;
return aPct - bPct;
});
if (filesWithGaps.length === 0) {
if (files.length === 0) {
console.log('\n⚠ No source files found in coverage data. This may indicate an issue with the coverage report.\n');
} else {
console.log('\n✅ All analyzed files have 100% line coverage!\n');
console.log(`(Analyzed ${files.length} files from src/less/, src/less-node/, and bin/)\n`);
}
process.exit(0);
}
console.log('\n' + '='.repeat(100));
console.log('Uncovered Lines Report');
console.log('='.repeat(100) + '\n');
filesWithGaps.forEach(file => {
const coveragePct = file.totalLines > 0
? ((file.coveredLines / file.totalLines) * 100).toFixed(1)
: '0.0';
console.log(`\n${file.path} (${coveragePct}% coverage)`);
console.log('-'.repeat(100));
// Group consecutive lines into ranges
const ranges = [];
let start = file.uncoveredLines[0];
let end = file.uncoveredLines[0];
for (let i = 1; i < file.uncoveredLines.length; i++) {
if (file.uncoveredLines[i] === end + 1) {
end = file.uncoveredLines[i];
} else {
ranges.push(start === end ? `${start}` : `${start}..${end}`);
start = file.uncoveredLines[i];
end = file.uncoveredLines[i];
}
}
ranges.push(start === end ? `${start}` : `${start}..${end}`);
// Display ranges (max 5 per line for readability)
const linesPerRow = 5;
for (let i = 0; i < ranges.length; i += linesPerRow) {
const row = ranges.slice(i, i + linesPerRow);
console.log(` Lines: ${row.join(', ')}`);
}
console.log(` Total uncovered: ${file.uncoveredLines.length} of ${file.totalLines} lines`);
});
console.log('\n' + '='.repeat(100) + '\n');
// Write JSON output for programmatic access
const jsonOutput = {
generated: new Date().toISOString(),
files: filesWithGaps.map(file => ({
path: file.path,
fullPath: file.fullPath,
sourcePath: (() => {
// Try to map lib/ path to src/ path
const normalized = file.fullPath.replace(/\\/g, '/');
if (normalized.includes('/lib/')) {
return normalized.replace('/lib/', '/src/').replace(/\.js$/, '.ts');
}
return file.fullPath;
})(),
coveragePercent: file.totalLines > 0
? parseFloat(((file.coveredLines / file.totalLines) * 100).toFixed(1))
: 0,
totalLines: file.totalLines,
coveredLines: file.coveredLines,
uncoveredLines: file.uncoveredLines,
uncoveredLineCode: file.uncoveredLineCode || {},
uncoveredRanges: (() => {
const ranges = [];
if (file.uncoveredLines.length === 0) return ranges;
let start = file.uncoveredLines[0];
let end = file.uncoveredLines[0];
for (let i = 1; i < file.uncoveredLines.length; i++) {
if (file.uncoveredLines[i] === end + 1) {
end = file.uncoveredLines[i];
} else {
ranges.push({ start, end });
start = file.uncoveredLines[i];
end = file.uncoveredLines[i];
}
}
ranges.push({ start, end });
return ranges;
})()
}))
};
fs.writeFileSync(jsonOutputPath, JSON.stringify(jsonOutput, null, 2), 'utf8');
console.log('\n📄 Uncovered lines data written to: coverage/uncovered-lines.json\n');