Skip to content

Commit 236b96c

Browse files
committed
Improve test coverage
1 parent 1637d18 commit 236b96c

File tree

71 files changed

+2223
-284
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

71 files changed

+2223
-284
lines changed

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,8 @@
1212
node_modules
1313
!package-lock.json
1414
npm-debug.log
15+
16+
# Coverage
17+
.nyc_output
18+
coverage
19+
*.lcov

packages/less/bin/lessc

Lines changed: 35 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -98,53 +98,48 @@ function render() {
9898
}
9999

100100
if (options.sourceMap) {
101-
sourceMapOptions.sourceMapInputFilename = input;
102-
103-
if (!sourceMapOptions.sourceMapFullFilename) {
104-
if (!output && !sourceMapFileInline) {
105-
console.error('the sourcemap option only has an optional filename if the css filename is given');
106-
console.error('consider adding --source-map-map-inline which embeds the sourcemap into the css');
107-
process.exitCode = 1;
108-
return;
109-
} // its in the same directory, so always just the basename
110-
111-
112-
if (output) {
113-
sourceMapOptions.sourceMapOutputFilename = path.basename(output);
114-
sourceMapOptions.sourceMapFullFilename = ''.concat(output, '.map');
115-
} // its in the same directory, so always just the basename
116-
117-
118-
if ('sourceMapFullFilename' in sourceMapOptions) {
119-
sourceMapOptions.sourceMapFilename = path.basename(sourceMapOptions.sourceMapFullFilename);
120-
}
121-
} else if (options.sourceMap && !sourceMapFileInline) {
122-
var mapFilename = path.resolve(process.cwd(), sourceMapOptions.sourceMapFullFilename);
123-
var mapDir = path.dirname(mapFilename);
124-
var outputDir = path.dirname(output); // find the path from the map to the output file
125-
126-
// eslint-disable-next-line max-len
127-
sourceMapOptions.sourceMapOutputFilename = path.join(path.relative(mapDir, outputDir), path.basename(output)); // make the sourcemap filename point to the sourcemap relative to the css file output directory
128-
129-
sourceMapOptions.sourceMapFilename = path.join(path.relative(outputDir, mapDir), path.basename(sourceMapOptions.sourceMapFullFilename));
130-
}
131-
101+
// Validate conflicting options
132102
if (sourceMapOptions.sourceMapURL && sourceMapOptions.disableSourcemapAnnotation) {
133103
console.error('You cannot provide flag --source-map-url with --source-map-no-annotation.');
134104
console.error('Please remove one of those flags.');
135105
process.exitcode = 1;
136106
return;
137107
}
138-
}
139-
140-
if (sourceMapOptions.sourceMapBasepath === undefined) {
141-
sourceMapOptions.sourceMapBasepath = input ? path.dirname(input) : process.cwd();
142-
}
143108

144-
if (sourceMapOptions.sourceMapRootpath === undefined) {
145-
var pathToMap = path.dirname((sourceMapFileInline ? output : sourceMapOptions.sourceMapFullFilename) || '.');
146-
var pathToInput = path.dirname(sourceMapOptions.sourceMapInputFilename || '.');
147-
sourceMapOptions.sourceMapRootpath = path.relative(pathToMap, pathToInput);
109+
// Handle explicit sourceMapFullFilename (from --source-map=filename)
110+
// Normalization of other options (sourceMapBasepath, sourceMapRootpath, etc.)
111+
// is handled automatically in parse-tree.js
112+
if (sourceMapOptions.sourceMapFullFilename && !sourceMapFileInline) {
113+
var mapFilename = path.resolve(process.cwd(), sourceMapOptions.sourceMapFullFilename);
114+
var mapDir = path.dirname(mapFilename);
115+
116+
if (output) {
117+
var outputDir = path.dirname(output);
118+
// Set sourceMapOutputFilename relative to map directory
119+
sourceMapOptions.sourceMapOutputFilename = path.join(
120+
path.relative(mapDir, outputDir),
121+
path.basename(output)
122+
);
123+
// Set sourceMapFilename relative to output directory (for sourceMappingURL comment)
124+
sourceMapOptions.sourceMapFilename = path.join(
125+
path.relative(outputDir, mapDir),
126+
path.basename(sourceMapOptions.sourceMapFullFilename)
127+
);
128+
} else {
129+
// No output filename, just use basename
130+
sourceMapOptions.sourceMapOutputFilename = path.basename(output || 'output.css');
131+
sourceMapOptions.sourceMapFilename = path.basename(sourceMapOptions.sourceMapFullFilename);
132+
}
133+
} else if (!sourceMapOptions.sourceMapFullFilename && output && !sourceMapFileInline) {
134+
// No explicit sourcemap filename, derive from output
135+
sourceMapOptions.sourceMapOutputFilename = path.basename(output);
136+
sourceMapOptions.sourceMapFullFilename = ''.concat(output, '.map');
137+
} else if (!output && !sourceMapFileInline) {
138+
console.error('the sourcemap option only has an optional filename if the css filename is given');
139+
console.error('consider adding --source-map-map-inline which embeds the sourcemap into the css');
140+
process.exitCode = 1;
141+
return;
142+
}
148143
}
149144

150145
if (!input) {

packages/less/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"scripts": {
3838
"quicktest": "grunt quicktest",
3939
"test": "grunt test",
40+
"test:coverage": "c8 -r lcov -r json-summary -r text-summary -r html --include=\"lib/**/*.js\" --include=\"bin/**/*.js\" --exclude=\"dist/**\" --exclude=\"**/*.test.js\" --exclude=\"**/*.spec.js\" --exclude=\"test/**\" --exclude=\"tmp/**\" --exclude=\"**/abstract-file-manager.js\" --exclude=\"**/abstract-plugin-loader.js\" grunt shell:test && node scripts/coverage-report.js && node scripts/coverage-lines.js",
4041
"grunt": "grunt",
4142
"lint": "eslint '**/*.{ts,js}'",
4243
"lint:fix": "eslint '**/*.{ts,js}' --fix",
@@ -67,6 +68,7 @@
6768
"benny": "^3.6.12",
6869
"bootstrap-less-port": "0.3.0",
6970
"chai": "^4.2.0",
71+
"c8": "^10.1.3",
7072
"chalk": "^4.1.2",
7173
"cosmiconfig": "~9.0.0",
7274
"cross-env": "^7.0.3",
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Generates a line-by-line coverage report showing uncovered lines
5+
* Reads from LCOV format and displays in terminal
6+
* Also outputs JSON file with uncovered lines for programmatic access
7+
*/
8+
9+
const fs = require('fs');
10+
const path = require('path');
11+
12+
const lcovPath = path.join(__dirname, '..', 'coverage', 'lcov.info');
13+
const jsonOutputPath = path.join(__dirname, '..', 'coverage', 'uncovered-lines.json');
14+
15+
if (!fs.existsSync(lcovPath)) {
16+
console.error('LCOV coverage file not found. Run pnpm test:coverage first.');
17+
process.exit(1);
18+
}
19+
20+
const lcovContent = fs.readFileSync(lcovPath, 'utf8');
21+
22+
// Parse LCOV format
23+
const files = [];
24+
let currentFile = null;
25+
26+
const lines = lcovContent.split('\n');
27+
for (let i = 0; i < lines.length; i++) {
28+
const line = lines[i];
29+
30+
// SF: source file
31+
if (line.startsWith('SF:')) {
32+
if (currentFile) {
33+
files.push(currentFile);
34+
}
35+
const filePath = line.substring(3);
36+
// Only include src/ files (not less-browser) and bin/
37+
// Exclude abstract base classes (they're meant to be overridden)
38+
const normalized = filePath.replace(/\\/g, '/');
39+
const abstractClasses = ['abstract-file-manager', 'abstract-plugin-loader'];
40+
const isAbstract = abstractClasses.some(abstract => normalized.includes(abstract));
41+
42+
if (!isAbstract &&
43+
((normalized.includes('src/less/') && !normalized.includes('src/less-browser/')) ||
44+
normalized.includes('src/less-node/') ||
45+
normalized.includes('bin/'))) {
46+
// Extract relative path - match src/less/... or src/less-node/... or bin/...
47+
// Path format: src/less/tree/debug-info.js or src/less-node/file-manager.js
48+
// Match from src/ or bin/ to end of path
49+
const match = normalized.match(/(src\/[^/]+\/.+|bin\/.+)$/);
50+
const relativePath = match ? match[1] : (normalized.includes('/src/') || normalized.includes('/bin/') ? normalized.split('/').slice(-3).join('/') : path.basename(filePath));
51+
currentFile = {
52+
path: relativePath,
53+
fullPath: filePath,
54+
uncoveredLines: [],
55+
uncoveredLineCode: {}, // line number -> source code
56+
totalLines: 0,
57+
coveredLines: 0
58+
};
59+
} else {
60+
currentFile = null;
61+
}
62+
}
63+
64+
// DA: line data (line number, execution count)
65+
if (currentFile && line.startsWith('DA:')) {
66+
const match = line.match(/^DA:(\d+),(\d+)$/);
67+
if (match) {
68+
const lineNum = parseInt(match[1], 10);
69+
const count = parseInt(match[2], 10);
70+
currentFile.totalLines++;
71+
if (count > 0) {
72+
currentFile.coveredLines++;
73+
} else {
74+
currentFile.uncoveredLines.push(lineNum);
75+
}
76+
}
77+
}
78+
}
79+
80+
if (currentFile) {
81+
files.push(currentFile);
82+
}
83+
84+
// Read source code for uncovered lines
85+
files.forEach(file => {
86+
if (file.uncoveredLines.length > 0 && fs.existsSync(file.fullPath)) {
87+
try {
88+
const sourceCode = fs.readFileSync(file.fullPath, 'utf8');
89+
const sourceLines = sourceCode.split('\n');
90+
file.uncoveredLines.forEach(lineNum => {
91+
// LCOV uses 1-based line numbers
92+
if (lineNum > 0 && lineNum <= sourceLines.length) {
93+
file.uncoveredLineCode[lineNum] = sourceLines[lineNum - 1].trim();
94+
}
95+
});
96+
} catch (err) {
97+
// If we can't read the source (e.g., it's in lib/ but we want src/), that's ok
98+
// We'll just skip the source code
99+
}
100+
}
101+
});
102+
103+
// Filter to only files with uncovered lines and sort by coverage
104+
const filesWithGaps = files
105+
.filter(f => f.uncoveredLines.length > 0)
106+
.sort((a, b) => {
107+
const aPct = a.totalLines > 0 ? a.coveredLines / a.totalLines : 1;
108+
const bPct = b.totalLines > 0 ? b.coveredLines / b.totalLines : 1;
109+
return aPct - bPct;
110+
});
111+
112+
if (filesWithGaps.length === 0) {
113+
if (files.length === 0) {
114+
console.log('\n⚠️ No source files found in coverage data. This may indicate an issue with the coverage report.\n');
115+
} else {
116+
console.log('\n✅ All analyzed files have 100% line coverage!\n');
117+
console.log(`(Analyzed ${files.length} files from src/less/, src/less-node/, and bin/)\n`);
118+
}
119+
process.exit(0);
120+
}
121+
122+
console.log('\n' + '='.repeat(100));
123+
console.log('Uncovered Lines Report');
124+
console.log('='.repeat(100) + '\n');
125+
126+
filesWithGaps.forEach(file => {
127+
const coveragePct = file.totalLines > 0
128+
? ((file.coveredLines / file.totalLines) * 100).toFixed(1)
129+
: '0.0';
130+
131+
console.log(`\n${file.path} (${coveragePct}% coverage)`);
132+
console.log('-'.repeat(100));
133+
134+
// Group consecutive lines into ranges
135+
const ranges = [];
136+
let start = file.uncoveredLines[0];
137+
let end = file.uncoveredLines[0];
138+
139+
for (let i = 1; i < file.uncoveredLines.length; i++) {
140+
if (file.uncoveredLines[i] === end + 1) {
141+
end = file.uncoveredLines[i];
142+
} else {
143+
ranges.push(start === end ? `${start}` : `${start}..${end}`);
144+
start = file.uncoveredLines[i];
145+
end = file.uncoveredLines[i];
146+
}
147+
}
148+
ranges.push(start === end ? `${start}` : `${start}..${end}`);
149+
150+
// Display ranges (max 5 per line for readability)
151+
const linesPerRow = 5;
152+
for (let i = 0; i < ranges.length; i += linesPerRow) {
153+
const row = ranges.slice(i, i + linesPerRow);
154+
console.log(` Lines: ${row.join(', ')}`);
155+
}
156+
157+
console.log(` Total uncovered: ${file.uncoveredLines.length} of ${file.totalLines} lines`);
158+
});
159+
160+
console.log('\n' + '='.repeat(100) + '\n');
161+
162+
// Write JSON output for programmatic access
163+
const jsonOutput = {
164+
generated: new Date().toISOString(),
165+
files: filesWithGaps.map(file => ({
166+
path: file.path,
167+
fullPath: file.fullPath,
168+
sourcePath: (() => {
169+
// Try to map lib/ path to src/ path
170+
const normalized = file.fullPath.replace(/\\/g, '/');
171+
if (normalized.includes('/lib/')) {
172+
return normalized.replace('/lib/', '/src/').replace(/\.js$/, '.ts');
173+
}
174+
return file.fullPath;
175+
})(),
176+
coveragePercent: file.totalLines > 0
177+
? parseFloat(((file.coveredLines / file.totalLines) * 100).toFixed(1))
178+
: 0,
179+
totalLines: file.totalLines,
180+
coveredLines: file.coveredLines,
181+
uncoveredLines: file.uncoveredLines,
182+
uncoveredLineCode: file.uncoveredLineCode || {},
183+
uncoveredRanges: (() => {
184+
const ranges = [];
185+
if (file.uncoveredLines.length === 0) return ranges;
186+
187+
let start = file.uncoveredLines[0];
188+
let end = file.uncoveredLines[0];
189+
190+
for (let i = 1; i < file.uncoveredLines.length; i++) {
191+
if (file.uncoveredLines[i] === end + 1) {
192+
end = file.uncoveredLines[i];
193+
} else {
194+
ranges.push({ start, end });
195+
start = file.uncoveredLines[i];
196+
end = file.uncoveredLines[i];
197+
}
198+
}
199+
ranges.push({ start, end });
200+
return ranges;
201+
})()
202+
}))
203+
};
204+
205+
fs.writeFileSync(jsonOutputPath, JSON.stringify(jsonOutput, null, 2), 'utf8');
206+
console.log(`\n📄 Uncovered lines data written to: coverage/uncovered-lines.json\n`);
207+

0 commit comments

Comments
 (0)