[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>
This commit is contained in:
Matthew Dean
2025-12-06 11:28:46 -08:00
committed by GitHub
parent 1bde4bddff
commit 432286970a
648 changed files with 4820 additions and 1165 deletions

5
.gitignore vendored
View File

@@ -12,3 +12,8 @@
node_modules
!package-lock.json
npm-debug.log
# Coverage
.nyc_output
coverage
*.lcov

View File

@@ -4,7 +4,7 @@ var resolve = require('resolve');
var path = require('path');
var testFolder = path.relative(process.cwd(), path.dirname(resolve.sync('@less/test-data')));
var lessFolder = path.join(testFolder, 'less');
var lessFolder = testFolder;
module.exports = function(grunt) {
grunt.option("stack", true);
@@ -85,8 +85,7 @@ module.exports = function(grunt) {
"relative-urls",
"rewrite-urls",
"browser",
"no-js-errors",
"legacy"
"no-js-errors"
];
function makeJob(testName) {
@@ -214,7 +213,7 @@ module.exports = function(grunt) {
command: "node build/rollup.js --browser --out=./tmp/browser/less.min.js"
},
test: {
command: 'ts-node test/test-es6.ts && node test/index.js'
command: 'npx ts-node test/test-es6.ts && node test/index.js'
},
generatebrowser: {
command: 'node test/browser/generator/generate.js'
@@ -230,35 +229,35 @@ module.exports = function(grunt) {
command: [
// @TODO: make this more thorough
// CURRENT OPTIONS
`node bin/lessc --ie-compat ${lessFolder}/_main/lazy-eval.less tmp/lazy-eval.css`,
`node bin/lessc --ie-compat ${lessFolder}/tests-unit/lazy-eval/lazy-eval.less tmp/lazy-eval.css`,
// --math
`node bin/lessc --math=always ${lessFolder}/_main/lazy-eval.less tmp/lazy-eval.css`,
`node bin/lessc --math=parens-division ${lessFolder}/_main/lazy-eval.less tmp/lazy-eval.css`,
`node bin/lessc --math=parens ${lessFolder}/_main/lazy-eval.less tmp/lazy-eval.css`,
`node bin/lessc --math=strict ${lessFolder}/_main/lazy-eval.less tmp/lazy-eval.css`,
`node bin/lessc --math=strict-legacy ${lessFolder}/_main/lazy-eval.less tmp/lazy-eval.css`,
`node bin/lessc --math=always ${lessFolder}/tests-unit/lazy-eval/lazy-eval.less tmp/lazy-eval.css`,
`node bin/lessc --math=parens-division ${lessFolder}/tests-unit/lazy-eval/lazy-eval.less tmp/lazy-eval.css`,
`node bin/lessc --math=parens ${lessFolder}/tests-unit/lazy-eval/lazy-eval.less tmp/lazy-eval.css`,
`node bin/lessc --math=strict ${lessFolder}/tests-unit/lazy-eval/lazy-eval.less tmp/lazy-eval.css`,
`node bin/lessc --math=strict-legacy ${lessFolder}/tests-unit/lazy-eval/lazy-eval.less tmp/lazy-eval.css`,
// DEPRECATED OPTIONS
// --strict-math
`node bin/lessc --strict-math=on ${lessFolder}/_main/lazy-eval.less tmp/lazy-eval.css`
`node bin/lessc --strict-math=on ${lessFolder}/tests-unit/lazy-eval/lazy-eval.less tmp/lazy-eval.css`
].join(" && ")
},
plugin: {
command: [
`node bin/lessc --clean-css="--s1 --advanced" ${lessFolder}/_main/lazy-eval.less tmp/lazy-eval.css`,
`node bin/lessc --clean-css="--s1 --advanced" ${lessFolder}/tests-unit/lazy-eval/lazy-eval.less tmp/lazy-eval.css`,
"cd lib",
`node ../bin/lessc --clean-css="--s1 --advanced" ../${lessFolder}/_main/lazy-eval.less ../tmp/lazy-eval.css`,
`node ../bin/lessc --source-map=lazy-eval.css.map --autoprefix ../${lessFolder}/_main/lazy-eval.less ../tmp/lazy-eval.css`,
`node ../bin/lessc --clean-css="--s1 --advanced" ../${lessFolder}/tests-unit/lazy-eval/lazy-eval.less ../tmp/lazy-eval.css`,
`node ../bin/lessc --source-map=lazy-eval.css.map --autoprefix ../${lessFolder}/tests-unit/lazy-eval/lazy-eval.less ../tmp/lazy-eval.css`,
"cd ..",
// Test multiple plugins
`node bin/lessc --plugin=clean-css="--s1 --advanced" --plugin=autoprefix="ie 11,Edge >= 13,Chrome >= 47,Firefox >= 45,iOS >= 9.2,Safari >= 9" ${lessFolder}/_main/lazy-eval.less tmp/lazy-eval.css`
`node bin/lessc --plugin=clean-css="--s1 --advanced" --plugin=autoprefix="ie 11,Edge >= 13,Chrome >= 47,Firefox >= 45,iOS >= 9.2,Safari >= 9" ${lessFolder}/tests-unit/lazy-eval/lazy-eval.less tmp/lazy-eval.css`
].join(" && ")
},
"sourcemap-test": {
// quoted value doesn't seem to get picked up by time-grunt, or isn't output, at least; maybe just "sourcemap" is fine?
command: [
`node bin/lessc --source-map=test/sourcemaps/maps/import-map.map ${lessFolder}/_main/import.less test/sourcemaps/import.css`,
`node bin/lessc --source-map ${lessFolder}/sourcemaps/basic.less test/sourcemaps/basic.css`
`node bin/lessc --source-map=test/sourcemaps/maps/import-map.map ${lessFolder}/tests-unit/import/import.less test/sourcemaps/import.css`,
`node bin/lessc --source-map ${lessFolder}/tests-config/sourcemaps/basic.less test/sourcemaps/basic.css`
].join(" && ")
}
},

View File

@@ -98,53 +98,48 @@ function render() {
}
if (options.sourceMap) {
sourceMapOptions.sourceMapInputFilename = input;
if (!sourceMapOptions.sourceMapFullFilename) {
if (!output && !sourceMapFileInline) {
console.error('the sourcemap option only has an optional filename if the css filename is given');
console.error('consider adding --source-map-map-inline which embeds the sourcemap into the css');
process.exitCode = 1;
return;
} // its in the same directory, so always just the basename
if (output) {
sourceMapOptions.sourceMapOutputFilename = path.basename(output);
sourceMapOptions.sourceMapFullFilename = ''.concat(output, '.map');
} // its in the same directory, so always just the basename
if ('sourceMapFullFilename' in sourceMapOptions) {
sourceMapOptions.sourceMapFilename = path.basename(sourceMapOptions.sourceMapFullFilename);
}
} else if (options.sourceMap && !sourceMapFileInline) {
var mapFilename = path.resolve(process.cwd(), sourceMapOptions.sourceMapFullFilename);
var mapDir = path.dirname(mapFilename);
var outputDir = path.dirname(output); // find the path from the map to the output file
// eslint-disable-next-line max-len
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
sourceMapOptions.sourceMapFilename = path.join(path.relative(outputDir, mapDir), path.basename(sourceMapOptions.sourceMapFullFilename));
}
// Validate conflicting options
if (sourceMapOptions.sourceMapURL && sourceMapOptions.disableSourcemapAnnotation) {
console.error('You cannot provide flag --source-map-url with --source-map-no-annotation.');
console.error('Please remove one of those flags.');
process.exitcode = 1;
return;
}
}
if (sourceMapOptions.sourceMapBasepath === undefined) {
sourceMapOptions.sourceMapBasepath = input ? path.dirname(input) : process.cwd();
}
if (sourceMapOptions.sourceMapRootpath === undefined) {
var pathToMap = path.dirname((sourceMapFileInline ? output : sourceMapOptions.sourceMapFullFilename) || '.');
var pathToInput = path.dirname(sourceMapOptions.sourceMapInputFilename || '.');
sourceMapOptions.sourceMapRootpath = path.relative(pathToMap, pathToInput);
// Handle explicit sourceMapFullFilename (from --source-map=filename)
// Normalization of other options (sourceMapBasepath, sourceMapRootpath, etc.)
// is handled automatically in parse-tree.js
if (sourceMapOptions.sourceMapFullFilename && !sourceMapFileInline) {
var mapFilename = path.resolve(process.cwd(), sourceMapOptions.sourceMapFullFilename);
var mapDir = path.dirname(mapFilename);
if (output) {
var outputDir = path.dirname(output);
// Set sourceMapOutputFilename relative to map directory
sourceMapOptions.sourceMapOutputFilename = path.join(
path.relative(mapDir, outputDir),
path.basename(output)
);
// Set sourceMapFilename relative to output directory (for sourceMappingURL comment)
sourceMapOptions.sourceMapFilename = path.join(
path.relative(outputDir, mapDir),
path.basename(sourceMapOptions.sourceMapFullFilename)
);
} else {
// No output filename, just use basename
sourceMapOptions.sourceMapOutputFilename = path.basename(output || 'output.css');
sourceMapOptions.sourceMapFilename = path.basename(sourceMapOptions.sourceMapFullFilename);
}
} else if (!sourceMapOptions.sourceMapFullFilename && output && !sourceMapFileInline) {
// No explicit sourcemap filename, derive from output
sourceMapOptions.sourceMapOutputFilename = path.basename(output);
sourceMapOptions.sourceMapFullFilename = ''.concat(output, '.map');
} else if (!output && !sourceMapFileInline) {
console.error('the sourcemap option only has an optional filename if the css filename is given');
console.error('consider adding --source-map-map-inline which embeds the sourcemap into the css');
process.exitCode = 1;
return;
}
}
if (!input) {

View File

@@ -37,6 +37,7 @@
"scripts": {
"quicktest": "grunt quicktest",
"test": "grunt test",
"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",
"grunt": "grunt",
"lint": "eslint '**/*.{ts,js}'",
"lint:fix": "eslint '**/*.{ts,js}' --fix",
@@ -44,7 +45,8 @@
"clean": "shx rm -rf ./lib tsconfig.tsbuildinfo",
"compile": "tsc -p tsconfig.build.json",
"dev": "tsc -p tsconfig.build.json -w",
"prepublishOnly": "grunt dist"
"prepublishOnly": "grunt dist",
"postinstall": "node scripts/postinstall.js"
},
"optionalDependencies": {
"errno": "^0.1.1",
@@ -66,11 +68,14 @@
"benny": "^3.6.12",
"bootstrap-less-port": "0.3.0",
"chai": "^4.2.0",
"c8": "^10.1.3",
"chalk": "^4.1.2",
"cosmiconfig": "~9.0.0",
"cross-env": "^7.0.3",
"diff": "^3.2.0",
"eslint": "^7.29.0",
"fs-extra": "^8.1.0",
"git-rev": "^0.2.1",
"glob": "~11.0.3",
"globby": "^10.0.1",
"grunt": "^1.0.4",
"grunt-cli": "^1.3.2",
@@ -80,17 +85,17 @@
"grunt-saucelabs": "^9.0.1",
"grunt-shell": "^1.3.0",
"html-template-tag": "^3.2.0",
"jest-diff": "~30.1.2",
"jit-grunt": "^0.10.0",
"less-plugin-autoprefix": "^1.5.1",
"less-plugin-clean-css": "^1.6.0",
"minimist": "^1.2.0",
"mocha": "^6.2.1",
"playwright": "1.50.1",
"mocha-teamcity-reporter": "^3.0.0",
"nock": "^11.8.2",
"npm-run-all": "^4.1.5",
"performance-now": "^0.2.0",
"phin": "^2.2.3",
"playwright": "1.50.1",
"promise": "^7.1.1",
"read-glob": "^3.0.0",
"resolve": "^1.17.0",

View File

@@ -0,0 +1,207 @@
#!/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');

View File

@@ -0,0 +1,158 @@
#!/usr/bin/env node
/**
* Generates a per-file coverage report table for src/ directories
*/
const fs = require('fs');
const path = require('path');
const coverageSummaryPath = path.join(__dirname, '..', 'coverage', 'coverage-summary.json');
if (!fs.existsSync(coverageSummaryPath)) {
console.error('Coverage summary not found. Run pnpm test:coverage first.');
process.exit(1);
}
const coverage = JSON.parse(fs.readFileSync(coverageSummaryPath, 'utf8'));
// Filter to only src/ files (less, less-node) and bin/ files
// Note: src/less-browser/ is excluded because browser tests aren't included in coverage
// Abstract base classes are excluded as they're meant to be overridden by implementations
const abstractClasses = [
'abstract-file-manager',
'abstract-plugin-loader'
];
const srcFiles = Object.entries(coverage)
.filter(([filePath]) => {
const normalized = filePath.replace(/\\/g, '/');
// Exclude abstract classes
if (abstractClasses.some(abstract => normalized.includes(abstract))) {
return false;
}
return (normalized.includes('/src/less/') && !normalized.includes('/src/less-browser/')) ||
normalized.includes('/src/less-node/') ||
normalized.includes('/bin/');
})
.map(([filePath, data]) => {
// Extract relative path from absolute path
const normalized = filePath.replace(/\\/g, '/');
// Match src/ paths or bin/ paths
const match = normalized.match(/((?:src\/[^/]+\/[^/]+\/|bin\/).+)$/);
const relativePath = match ? match[1] : path.basename(filePath);
return {
path: relativePath,
statements: data.statements,
branches: data.branches,
functions: data.functions,
lines: data.lines
};
})
.sort((a, b) => {
// Sort by directory first, then by coverage percentage
const pathCompare = a.path.localeCompare(b.path);
if (pathCompare !== 0) return pathCompare;
return a.statements.pct - b.statements.pct;
});
if (srcFiles.length === 0) {
console.log('No src/ files found in coverage report.');
process.exit(0);
}
// Group by directory
const grouped = {
'src/less/': [],
'src/less-node/': [],
'bin/': []
};
srcFiles.forEach(file => {
if (file.path.startsWith('src/less/')) {
grouped['src/less/'].push(file);
} else if (file.path.startsWith('src/less-node/')) {
grouped['src/less-node/'].push(file);
} else if (file.path.startsWith('bin/')) {
grouped['bin/'].push(file);
}
});
// Print table
console.log('\n' + '='.repeat(100));
console.log('Per-File Coverage Report (src/less/, src/less-node/, and bin/)');
console.log('='.repeat(100));
console.log('For line-by-line coverage details, open coverage/index.html in your browser.');
console.log('='.repeat(100) + '\n');
Object.entries(grouped).forEach(([dir, files]) => {
if (files.length === 0) return;
console.log(`\n${dir.toUpperCase()}`);
console.log('-'.repeat(100));
console.log(
'File'.padEnd(50) +
'Statements'.padStart(12) +
'Branches'.padStart(12) +
'Functions'.padStart(12) +
'Lines'.padStart(12)
);
console.log('-'.repeat(100));
files.forEach(file => {
const filename = file.path.replace(dir, '');
const truncated = filename.length > 48 ? '...' + filename.slice(-45) : filename;
console.log(
truncated.padEnd(50) +
`${file.statements.pct.toFixed(1)}%`.padStart(12) +
`${file.branches.pct.toFixed(1)}%`.padStart(12) +
`${file.functions.pct.toFixed(1)}%`.padStart(12) +
`${file.lines.pct.toFixed(1)}%`.padStart(12)
);
});
// Summary for this directory
const totals = files.reduce((acc, file) => {
acc.statements.total += file.statements.total;
acc.statements.covered += file.statements.covered;
acc.branches.total += file.branches.total;
acc.branches.covered += file.branches.covered;
acc.functions.total += file.functions.total;
acc.functions.covered += file.functions.covered;
acc.lines.total += file.lines.total;
acc.lines.covered += file.lines.covered;
return acc;
}, {
statements: { total: 0, covered: 0 },
branches: { total: 0, covered: 0 },
functions: { total: 0, covered: 0 },
lines: { total: 0, covered: 0 }
});
const stmtPct = totals.statements.total > 0
? (totals.statements.covered / totals.statements.total * 100).toFixed(1)
: '0.0';
const branchPct = totals.branches.total > 0
? (totals.branches.covered / totals.branches.total * 100).toFixed(1)
: '0.0';
const funcPct = totals.functions.total > 0
? (totals.functions.covered / totals.functions.total * 100).toFixed(1)
: '0.0';
const linePct = totals.lines.total > 0
? (totals.lines.covered / totals.lines.total * 100).toFixed(1)
: '0.0';
console.log('-'.repeat(100));
console.log(
'TOTAL'.padEnd(50) +
`${stmtPct}%`.padStart(12) +
`${branchPct}%`.padStart(12) +
`${funcPct}%`.padStart(12) +
`${linePct}%`.padStart(12)
);
});
console.log('\n' + '='.repeat(100) + '\n');

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env node
/**
* Post-install script for Less.js package
*
* This script installs Playwright browsers only when:
* 1. This is a development environment (not when installed as a dependency)
* 2. We're in a monorepo context (parent package.json exists)
* 3. Not running in CI or other automated environments
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
// Check if we're in a development environment
function isDevelopmentEnvironment() {
// Skip if this is a global install or user config
if (process.env.npm_config_user_config || process.env.npm_config_global) {
return false;
}
// Skip in CI environments
if (process.env.CI || process.env.GITHUB_ACTIONS || process.env.TRAVIS) {
return false;
}
// Check if we're in a monorepo (parent package.json exists)
const parentPackageJson = path.join(__dirname, '../../../package.json');
if (!fs.existsSync(parentPackageJson)) {
return false;
}
// Check if this is the root of the monorepo
const currentPackageJson = path.join(__dirname, '../package.json');
if (!fs.existsSync(currentPackageJson)) {
return false;
}
return true;
}
// Install Playwright browsers
function installPlaywrightBrowsers() {
try {
console.log('🎭 Installing Playwright browsers for development...');
execSync('pnpm exec playwright install', {
stdio: 'inherit',
cwd: path.join(__dirname, '..')
});
console.log('✅ Playwright browsers installed successfully');
} catch (error) {
console.warn('⚠️ Failed to install Playwright browsers:', error.message);
console.warn(' You can install them manually with: pnpm exec playwright install');
}
}
// Main execution
if (isDevelopmentEnvironment()) {
installPlaywrightBrowsers();
}

View File

@@ -34,7 +34,7 @@ const lessc_helper = {
console.log(' -l, --lint Syntax check only (lint).');
console.log(' -s, --silent Suppresses output of error messages.');
console.log(' --quiet Suppresses output of warnings.');
console.log(' --strict-imports Forces evaluation of imports.');
console.log(' --strict-imports (DEPRECATED) Ignores .less imports inside selector blocks. Has confusing behavior.');
console.log(' --insecure Allows imports from insecure https hosts.');
console.log(' -v, --version Prints version number and exit.');
console.log(' --verbose Be verbose.');
@@ -74,12 +74,13 @@ const lessc_helper = {
console.log(' -sm=on|off Legacy parens-only math. Use --math');
console.log(' --strict-math=on|off ');
console.log('');
console.log(' --line-numbers=TYPE Outputs filename and line numbers.');
console.log(' TYPE can be either \'comments\', which will output');
console.log(' the debug info within comments, \'mediaquery\'');
console.log(' that will output the information within a fake');
console.log(' media query which is compatible with the SASS');
console.log(' format, and \'all\' which will do both.');
console.log(' --line-numbers=TYPE (DEPRECATED) Outputs filename and line numbers.');
console.log(' TYPE can be either \'comments\', \'mediaquery\', or \'all\'.');
console.log(' The entire dumpLineNumbers option is deprecated.');
console.log(' Use sourcemaps (--source-map) instead.');
console.log(' All modes will be removed in a future version.');
console.log(' Note: \'mediaquery\' and \'all\' modes generate @media -sass-debug-info');
console.log(' which had short-lived usage and is no longer recommended.');
console.log(' -x, --compress Compresses output by removing some whitespaces.');
console.log(' We recommend you use a dedicated minifer like less-plugin-clean-css');
console.log('');

View File

@@ -22,10 +22,9 @@ const parseCopyProperties = [
'rootpath', // option - rootpath to append to URL's
'strictImports', // option -
'insecure', // option - whether to allow imports from insecure ssl hosts
'dumpLineNumbers', // option - whether to dump line numbers
'dumpLineNumbers', // option - @deprecated The dumpLineNumbers option is deprecated. Use sourcemaps instead. All modes ('comments', 'mediaquery', 'all') will be removed in a future version.
'compress', // option - whether to compress
'syncImport', // option - whether to import synchronously
'chunkInput', // option - whether to chunk input. more performant but causes parse issues.
'mime', // browser only - mime type for sheet import
'useFileCache', // browser only - whether to use the per file session cache
// context

View File

@@ -25,9 +25,28 @@ export default function() {
/* color output in the terminal */
color: true,
/* The strictImports controls whether the compiler will allow an @import inside of either
* @media blocks or (a later addition) other selector blocks.
* See: https://github.com/less/less.js/issues/656 */
/**
* @deprecated This option has confusing behavior and may be removed in a future version.
*
* When true, prevents @import statements for .less files from being evaluated inside
* selector blocks (rulesets). The imports are silently ignored and not output.
*
* Behavior:
* - @import at root level: Always processed
* - @import inside @-rules (@media, @supports, etc.): Processed (these are not selector blocks)
* - @import inside selector blocks (.class, #id, etc.): NOT processed (silently ignored)
*
* When false (default): All @import statements are processed regardless of context.
*
* Note: Despite the name "strict", this option does NOT throw an error when imports
* are used in selector blocks - it silently ignores them. This is confusing
* behavior that may catch users off guard.
*
* Note: Only affects .less file imports. CSS imports (url(...) or .css files) are
* always output as CSS @import statements regardless of this setting.
*
* @see https://github.com/less/less.js/issues/656
*/
strictImports: false,
/* Allow Imports from Insecure HTTPS Hosts */

View File

@@ -31,6 +31,9 @@ const LessError = function(e, fileContentMap, currentFilename) {
this.message = e.message;
this.stack = e.stack;
// Set type early so it's always available, even if fileContentMap is missing
this.type = e.type || 'Syntax';
if (fileContentMap && filename) {
const input = fileContentMap.contents[filename];
@@ -40,7 +43,6 @@ const LessError = function(e, fileContentMap, currentFilename) {
const callLine = e.call && utils.getLocation(e.call, input).line;
const lines = input ? input.split('\n') : '';
this.type = e.type || 'Syntax';
this.filename = filename;
this.index = e.index;
this.line = typeof line === 'number' ? line + 1 : null;

View File

@@ -28,12 +28,70 @@ export default function(SourceMapBuilder) {
const toCSSOptions = {
compress,
// @deprecated The dumpLineNumbers option is deprecated. Use sourcemaps instead. All modes will be removed in a future version.
dumpLineNumbers: options.dumpLineNumbers,
strictUnits: Boolean(options.strictUnits),
numPrecision: 8};
if (options.sourceMap) {
sourceMapBuilder = new SourceMapBuilder(options.sourceMap);
// Normalize sourceMap option: if it's just true, convert to object
if (options.sourceMap === true) {
options.sourceMap = {};
}
const sourceMapOpts = options.sourceMap;
// Set sourceMapInputFilename if not set and filename is available
if (!sourceMapOpts.sourceMapInputFilename && options.filename) {
sourceMapOpts.sourceMapInputFilename = options.filename;
}
// Default sourceMapBasepath to the input file's directory if not set
// This matches the behavior documented and implemented in bin/lessc
if (sourceMapOpts.sourceMapBasepath === undefined && options.filename) {
// Get directory from filename using string manipulation (works cross-platform)
const lastSlash = Math.max(options.filename.lastIndexOf('/'), options.filename.lastIndexOf('\\'));
if (lastSlash >= 0) {
sourceMapOpts.sourceMapBasepath = options.filename.substring(0, lastSlash);
} else {
// No directory separator found, use current directory
sourceMapOpts.sourceMapBasepath = '.';
}
}
// Handle sourceMapFullFilename (CLI-specific: --source-map=filename)
// This is converted to sourceMapFilename and sourceMapOutputFilename
if (sourceMapOpts.sourceMapFullFilename && !sourceMapOpts.sourceMapFileInline) {
// This case is handled by lessc before calling render
// We just need to ensure sourceMapFilename is set if sourceMapFullFilename is provided
if (!sourceMapOpts.sourceMapFilename && !sourceMapOpts.sourceMapURL) {
// Extract just the basename for the sourceMappingURL comment
const mapBase = sourceMapOpts.sourceMapFullFilename.split(/[/\\]/).pop();
sourceMapOpts.sourceMapFilename = mapBase;
}
} else if (!sourceMapOpts.sourceMapFilename && !sourceMapOpts.sourceMapURL) {
// If sourceMapFilename is not set and sourceMapURL is not set,
// derive it from the output filename (if available) or input filename
if (sourceMapOpts.sourceMapOutputFilename) {
// Use output filename + .map
sourceMapOpts.sourceMapFilename = sourceMapOpts.sourceMapOutputFilename + '.map';
} else if (options.filename) {
// Fallback to input filename + .css.map
const inputBase = options.filename.replace(/\.[^/.]+$/, '');
sourceMapOpts.sourceMapFilename = inputBase + '.css.map';
}
}
// Default sourceMapOutputFilename if not set
if (!sourceMapOpts.sourceMapOutputFilename) {
if (options.filename) {
const inputBase = options.filename.replace(/\.[^/.]+$/, '');
sourceMapOpts.sourceMapOutputFilename = inputBase + '.css';
} else {
sourceMapOpts.sourceMapOutputFilename = 'output.css';
}
}
sourceMapBuilder = new SourceMapBuilder(sourceMapOpts);
result.css = sourceMapBuilder.toCSS(evaldRoot, toCSSOptions, this.imports);
} else {
result.css = evaldRoot.toCSS(toCSSOptions);

View File

@@ -1,122 +0,0 @@
// Split the input into chunks.
export default function (input, fail) {
const len = input.length;
let level = 0;
let parenLevel = 0;
let lastOpening;
let lastOpeningParen;
let lastMultiComment;
let lastMultiCommentEndBrace;
const chunks = [];
let emitFrom = 0;
let chunkerCurrentIndex;
let currentChunkStartIndex;
let cc;
let cc2;
let matched;
function emitChunk(force) {
const len = chunkerCurrentIndex - emitFrom;
if (((len < 512) && !force) || !len) {
return;
}
chunks.push(input.slice(emitFrom, chunkerCurrentIndex + 1));
emitFrom = chunkerCurrentIndex + 1;
}
for (chunkerCurrentIndex = 0; chunkerCurrentIndex < len; chunkerCurrentIndex++) {
cc = input.charCodeAt(chunkerCurrentIndex);
if (((cc >= 97) && (cc <= 122)) || (cc < 34)) {
// a-z or whitespace
continue;
}
switch (cc) {
case 40: // (
parenLevel++;
lastOpeningParen = chunkerCurrentIndex;
continue;
case 41: // )
if (--parenLevel < 0) {
return fail('missing opening `(`', chunkerCurrentIndex);
}
continue;
case 59: // ;
if (!parenLevel) { emitChunk(); }
continue;
case 123: // {
level++;
lastOpening = chunkerCurrentIndex;
continue;
case 125: // }
if (--level < 0) {
return fail('missing opening `{`', chunkerCurrentIndex);
}
if (!level && !parenLevel) { emitChunk(); }
continue;
case 92: // \
if (chunkerCurrentIndex < len - 1) { chunkerCurrentIndex++; continue; }
return fail('unescaped `\\`', chunkerCurrentIndex);
case 34:
case 39:
case 96: // ", ' and `
matched = 0;
currentChunkStartIndex = chunkerCurrentIndex;
for (chunkerCurrentIndex = chunkerCurrentIndex + 1; chunkerCurrentIndex < len; chunkerCurrentIndex++) {
cc2 = input.charCodeAt(chunkerCurrentIndex);
if (cc2 > 96) { continue; }
if (cc2 == cc) { matched = 1; break; }
if (cc2 == 92) { // \
if (chunkerCurrentIndex == len - 1) {
return fail('unescaped `\\`', chunkerCurrentIndex);
}
chunkerCurrentIndex++;
}
}
if (matched) { continue; }
return fail(`unmatched \`${String.fromCharCode(cc)}\``, currentChunkStartIndex);
case 47: // /, check for comment
if (parenLevel || (chunkerCurrentIndex == len - 1)) { continue; }
cc2 = input.charCodeAt(chunkerCurrentIndex + 1);
if (cc2 == 47) {
// //, find lnfeed
for (chunkerCurrentIndex = chunkerCurrentIndex + 2; chunkerCurrentIndex < len; chunkerCurrentIndex++) {
cc2 = input.charCodeAt(chunkerCurrentIndex);
if ((cc2 <= 13) && ((cc2 == 10) || (cc2 == 13))) { break; }
}
} else if (cc2 == 42) {
// /*, find */
lastMultiComment = currentChunkStartIndex = chunkerCurrentIndex;
for (chunkerCurrentIndex = chunkerCurrentIndex + 2; chunkerCurrentIndex < len - 1; chunkerCurrentIndex++) {
cc2 = input.charCodeAt(chunkerCurrentIndex);
if (cc2 == 125) { lastMultiCommentEndBrace = chunkerCurrentIndex; }
if (cc2 != 42) { continue; }
if (input.charCodeAt(chunkerCurrentIndex + 1) == 47) { break; }
}
if (chunkerCurrentIndex == len - 1) {
return fail('missing closing `*/`', currentChunkStartIndex);
}
chunkerCurrentIndex++;
}
continue;
case 42: // *, check for unmatched */
if ((chunkerCurrentIndex < len - 1) && (input.charCodeAt(chunkerCurrentIndex + 1) == 47)) {
return fail('unmatched `/*`', chunkerCurrentIndex);
}
continue;
}
}
if (level !== 0) {
if ((lastMultiComment > lastOpening) && (lastMultiCommentEndBrace > lastMultiComment)) {
return fail('missing closing `}` or `*/`', lastOpening);
} else {
return fail('missing closing `}`', lastOpening);
}
} else if (parenLevel !== 0) {
return fail('missing closing `)`', lastOpeningParen);
}
emitChunk(true);
return chunks;
}

View File

@@ -1,5 +1,3 @@
import chunker from './chunker';
export default () => {
let // Less input string
input;
@@ -351,26 +349,11 @@ export default () => {
return (c > CHARCODE_9 || c < CHARCODE_PLUS) || c === CHARCODE_FORWARD_SLASH || c === CHARCODE_COMMA;
};
parserInput.start = (str, chunkInput, failFunction) => {
parserInput.start = (str) => {
input = str;
parserInput.i = j = currentPos = furthest = 0;
// chunking apparently makes things quicker (but my tests indicate
// it might actually make things slower in node at least)
// and it is a non-perfect parse - it can't recognise
// unquoted urls, meaning it can't distinguish comments
// meaning comments with quotes or {}() in them get 'counted'
// and then lead to parse errors.
// In addition if the chunking chunks in the wrong place we might
// not be able to parse a parser statement in one go
// this is officially deprecated but can be switched on via an option
// in the case it causes too much performance issues.
if (chunkInput) {
chunks = chunker(str, failFunction);
} else {
chunks = [str];
}
chunks = [str];
current = chunks[0];
skipWhitespace(0);

View File

@@ -124,12 +124,7 @@ const Parser = function Parser(context, imports, fileInfo, currentIndex) {
const parser = parserInput;
try {
parser.start(str, false, function fail(msg, index) {
callback({
message: msg,
index: index + currentIndex
});
});
parser.start(str);
for (let x = 0, p; (p = parseList[x]); x++) {
result = parsers[p]();
returnNodes.push(result || null);
@@ -209,14 +204,7 @@ const Parser = function Parser(context, imports, fileInfo, currentIndex) {
// with the `root` property set to true, so no `{}` are
// output. The callback is called when the input is parsed.
try {
parserInput.start(str, context.chunkInput, function fail(msg, index) {
throw new LessError({
index,
type: 'Parse',
message: msg,
filename: fileInfo.filename
}, imports);
});
parserInput.start(str);
tree.Node.prototype.parse = this;
root = new tree.Ruleset(null, this.parsers.primary());

View File

@@ -8,7 +8,7 @@ export default function (environment) {
if (options.sourceMapFilename) {
this._sourceMapFilename = options.sourceMapFilename.replace(/\\/g, '/');
}
this._outputFilename = options.outputFilename;
this._outputFilename = options.outputFilename ? options.outputFilename.replace(/\\/g, '/') : options.outputFilename;
this.sourceMapURL = options.sourceMapURL;
if (options.sourceMapBasepath) {
this._sourceMapBasepath = options.sourceMapBasepath.replace(/\\/g, '/');

View File

@@ -1,7 +1,23 @@
/**
* @deprecated The dumpLineNumbers option is deprecated. Use sourcemaps instead.
* This will be removed in a future version.
*
* @param {Object} ctx - Context object with debugInfo
* @returns {string} Debug info as CSS comment
*/
function asComment(ctx) {
return `/* line ${ctx.debugInfo.lineNumber}, ${ctx.debugInfo.fileName} */\n`;
}
/**
* @deprecated The dumpLineNumbers option is deprecated. Use sourcemaps instead.
* This function generates Sass-compatible debug info using @media -sass-debug-info syntax.
* This format had short-lived usage and is no longer recommended.
* This will be removed in a future version.
*
* @param {Object} ctx - Context object with debugInfo
* @returns {string} Sass-compatible debug info as @media query
*/
function asMediaQuery(ctx) {
let filenameWithProtocol = ctx.debugInfo.fileName;
if (!/^[a-z]+:\/\//i.test(filenameWithProtocol)) {
@@ -15,6 +31,19 @@ function asMediaQuery(ctx) {
})}}line{font-family:\\00003${ctx.debugInfo.lineNumber}}}\n`;
}
/**
* Generates debug information (line numbers) for CSS output.
*
* @param {Object} context - Context object with dumpLineNumbers option
* @param {Object} ctx - Context object with debugInfo
* @param {string} [lineSeparator] - Separator between comment and media query (for 'all' mode)
* @returns {string} Debug info string
*
* @deprecated The dumpLineNumbers option is deprecated. Use sourcemaps instead.
* All modes ('comments', 'mediaquery', 'all') are deprecated and will be removed in a future version.
* The 'mediaquery' and 'all' modes generate Sass-compatible @media -sass-debug-info output
* which had short-lived usage and is no longer recommended.
*/
function debugInfo(context, ctx, lineSeparator) {
let result = '';
if (context.dumpLineNumbers && !context.compress) {

1
packages/less/test.less Normal file
View File

@@ -0,0 +1 @@
.test { color: red; }

View File

@@ -99,6 +99,18 @@ testSheet = function (sheet) {
window.navigator.userAgent.indexOf('Trident/') >= 0) {
text = ieFormat(text);
}
// Normalize URLs: convert absolute URLs back to relative for comparison
// The browser resolves relative URLs when reading from DOM, but we want to compare against the original relative URLs
lessOutput = lessOutput.replace(/url\("http:\/\/localhost:8081\/packages\/less\/node_modules\/@less\/test-data\/tests-unit\/([^"]+)"\)/g, 'url("$1")');
// Also normalize directory-prefixed relative URLs (e.g., "at-rules/myfont.woff2" -> "myfont.woff2")
// This happens because the browser resolves URLs relative to the HTML document location
lessOutput = lessOutput.replace(/url\("([a-z-]+\/)([^"]+)"\)/g, 'url("$2")');
// Also normalize @import statements that get resolved to absolute URLs
lessOutput = lessOutput.replace(/@import "http:\/\/localhost:8081\/packages\/less\/node_modules\/@less\/test-data\/tests-unit\/([^"]+)"(.*);/g, '@import "$1"$2;');
// Also normalize @import with directory prefix (e.g., "at-rules-keyword-comments/test.css" -> "test.css")
lessOutput = lessOutput.replace(/@import "([a-z-]+\/)([^"]+)"(.*);/g, '@import "$2"$3;');
expect(lessOutput).to.equal(text);
done();
})
@@ -164,12 +176,21 @@ testErrorSheet = function (sheet) {
.replace(/\nStack Trace\n[\s\S]*/i, '')
.replace(/\n$/, '')
.trim();
actualErrorMsg = actualErrorMsg
.replace(/ in [\w\-]+\.less( on line \d+, column \d+)?:?$/, '') // Remove filename and optional line/column from end of error message
.replace(/\{path\}/g, '')
.replace(/\{pathrel\}/g, '')
.replace(/\{pathhref\}/g, 'http://localhost:8081/packages/less/node_modules/@less/test-data/tests-error/eval/')
.replace(/\{404status\}/g, ' (404)')
.replace(/\{node\}[\s\S]*\{\/node\}/g, '')
.replace(/\n$/, '')
.trim();
errorFile
.then(function (errorTxt) {
errorTxt = errorTxt
.replace(/\{path\}/g, '')
.replace(/\{pathrel\}/g, '')
.replace(/\{pathhref\}/g, 'http://localhost:8081/test/less/errors/')
.replace(/\{pathhref\}/g, 'http://localhost:8081/packages/less/node_modules/@less/test-data/tests-error/eval/')
.replace(/\{404status\}/g, ' (404)')
.replace(/\{node\}[\s\S]*\{\/node\}/g, '')
.replace(/\n$/, '')

View File

@@ -4,21 +4,25 @@ var { forceCovertToBrowserPath } = require('./utils');
/** Root of repo */
var testFolder = forceCovertToBrowserPath(path.dirname(resolve.sync('@less/test-data')));
var lessFolder = forceCovertToBrowserPath(path.join(testFolder, 'less'));
var testsUnitFolder = forceCovertToBrowserPath(path.join(testFolder, 'tests-unit'));
var testsConfigFolder = forceCovertToBrowserPath(path.join(testFolder, 'tests-config'));
var localTests = forceCovertToBrowserPath(path.resolve(__dirname, '..'));
module.exports = {
main: {
// src is used to build list of less files to compile
src: [
`${lessFolder}/_main/*.less`,
`!${lessFolder}/_main/plugin-preeval.less`, // uses ES6 syntax
`${testsUnitFolder}/*/*.less`,
`!${testsUnitFolder}/plugin-preeval/plugin-preeval.less`, // uses ES6 syntax
// Don't test NPM import, obviously
`!${lessFolder}/_main/plugin-module.less`,
`!${lessFolder}/_main/import-module.less`,
`!${lessFolder}/_main/javascript.less`,
`!${lessFolder}/_main/urls.less`,
`!${lessFolder}/_main/empty.less`
`!${testsUnitFolder}/plugin-module/plugin-module.less`,
`!${testsUnitFolder}/import/import-module.less`,
`!${testsUnitFolder}/javascript/javascript.less`,
`!${testsUnitFolder}/urls/urls.less`,
`!${testsUnitFolder}/empty/empty.less`,
`!${testsUnitFolder}/color-functions/operations.less`, // conflicts with operations/operations.less
// Exclude debug line numbers tests - these are Node.js only (dumpLineNumbers is deprecated)
`!${testsConfigFolder}/debug/**/*.less`
],
options: {
helpers: 'test/browser/runner-main-options.js',
@@ -26,16 +30,8 @@ module.exports = {
outfile: 'tmp/browser/test-runner-main.html'
}
},
legacy: {
src: [`${lessFolder}/legacy/*.less`],
options: {
helpers: 'test/browser/runner-legacy-options.js',
specs: 'test/browser/runner-legacy-spec.js',
outfile: 'tmp/browser/test-runner-legacy.html'
}
},
strictUnits: {
src: [`${lessFolder}/units/strict/*.less`],
src: [`${testsConfigFolder}/units/strict/*.less`],
options: {
helpers: 'test/browser/runner-strict-units-options.js',
specs: 'test/browser/runner-strict-units-spec.js',
@@ -44,8 +40,8 @@ module.exports = {
},
errors: {
src: [
`${lessFolder}/errors/*.less`,
`${testFolder}/errors/javascript-error.less`,
`${testFolder}/tests-error/eval/*.less`,
`${testFolder}/tests-error/parse/*.less`,
`${localTests}/less/errors/*.less`
],
options: {
@@ -56,7 +52,7 @@ module.exports = {
}
},
noJsErrors: {
src: [`${lessFolder}/no-js-errors/*.less`],
src: [`${testsConfigFolder}/no-js-errors/*.less`],
options: {
helpers: 'test/browser/runner-no-js-errors-options.js',
specs: 'test/browser/runner-no-js-errors-spec.js',
@@ -141,7 +137,7 @@ module.exports = {
}
},
postProcessorPlugin: {
src: [`${lessFolder}/postProcessorPlugin/*.less`],
src: [`${testsConfigFolder}/postProcessorPlugin/*.less`],
options: {
helpers: [
'test/plugins/postprocess/index.js',
@@ -153,7 +149,7 @@ module.exports = {
}
},
preProcessorPlugin: {
src: [`${lessFolder}/preProcessorPlugin/*.less`],
src: [`${testsConfigFolder}/preProcessorPlugin/*.less`],
options: {
helpers: [
'test/plugins/preprocess/index.js',
@@ -164,7 +160,7 @@ module.exports = {
}
},
visitorPlugin: {
src: [`${lessFolder}/visitorPlugin/*.less`],
src: [`${testsConfigFolder}/visitorPlugin/*.less`],
options: {
helpers: [
'test/plugins/visitor/index.js',
@@ -175,7 +171,7 @@ module.exports = {
}
},
filemanagerPlugin: {
src: [`${lessFolder}/filemanagerPlugin/*.less`],
src: [`${testsConfigFolder}/filemanagerPlugin/*.less`],
options: {
helpers: [
'test/plugins/filemanager/index.js',

View File

@@ -25,9 +25,20 @@ module.exports = (stylesheets, helpers, spec, less) => {
<!-- for each test, generate CSS/LESS link tags -->
$${stylesheets.map(function(fullLessName) {
var pathParts = fullLessName.split('/');
var fullCssName = fullLessName
.replace(/\/(browser|test-data)\/less\//g, '/$1/css/')
.replace(/less$/, 'css')
var fullCssName = fullLessName.replace(/less$/, 'css');
// Check if the CSS file exists in the same directory as the LESS file
var fs = require('fs');
var cssExists = fs.existsSync(fullCssName);
// If not, try the css/ directory for local browser tests
if (!cssExists && fullLessName.includes('/test/browser/less/')) {
var cssInCssDir = fullLessName.replace('/test/browser/less/', '/test/browser/css/').replace(/less$/, 'css');
if (fs.existsSync(cssInCssDir)) {
fullCssName = cssInCssDir;
}
}
var lessName = pathParts[pathParts.length - 1];
var name = lessName.split('.')[0];
return `

View File

@@ -6,7 +6,7 @@ var less = {
};
// test inline less in style tags by grabbing an assortment of less files and doing `@import`s
var testFiles = ['charsets', 'colors', 'comments', 'css-3', 'strings', 'media', 'mixins'],
var testFiles = ['charsets/charsets', 'color-functions/basic', 'comments/comments', 'css-3/css-3', 'strings/strings', 'media/media', 'mixins/mixins'],
testSheets = [];
// setup style tags with less and link tags pointing to expected css output
@@ -14,13 +14,13 @@ var testFiles = ['charsets', 'colors', 'comments', 'css-3', 'strings', 'media',
/**
* @todo - generate the node_modules path for this file and in templates
*/
var lessFolder = '../../node_modules/@less/test-data/less'
var cssFolder = '../../node_modules/@less/test-data/css'
var lessFolder = '../../node_modules/@less/test-data/tests-unit'
var cssFolder = '../../node_modules/@less/test-data/tests-unit'
for (var i = 0; i < testFiles.length; i++) {
var file = testFiles[i],
lessPath = lessFolder + '/_main/' + file + '.less',
cssPath = cssFolder + '/_main/' + file + '.css',
lessPath = lessFolder + '/' + file + '.less',
cssPath = cssFolder + '/' + file + '.css',
lessStyle = document.createElement('style'),
cssLink = document.createElement('link'),
lessText = '@import "' + lessPath + '";';

View File

@@ -1,6 +0,0 @@
var less = {
logLevel: 4,
errorReporting: 'console',
math: 'always',
strictUnits: false
};

View File

@@ -1,3 +0,0 @@
describe('less.js legacy tests', function() {
testLessEqualsInDocument();
});

View File

@@ -1,112 +1,307 @@
var lessTest = require('./less-test'),
lessTester = lessTest(),
path = require('path'),
stylize = require('../lib/less-node/lessc-helper').stylize,
nock = require('nock');
// Mock needle for HTTP requests BEFORE any other requires
const Module = require('module');
const originalRequire = Module.prototype.require;
Module.prototype.require = function(id) {
if (id === 'needle') {
return {
get: function(url, options, callback) {
// Handle CDN requests
if (url.includes('cdn.jsdelivr.net')) {
if (url.includes('selectors.less')) {
setTimeout(() => {
callback(null, { statusCode: 200 }, fs.readFileSync(path.join(__dirname, '../../test-data/tests-unit/selectors/selectors.less'), 'utf8'));
}, 10);
return;
}
if (url.includes('media.less')) {
setTimeout(() => {
callback(null, { statusCode: 200 }, fs.readFileSync(path.join(__dirname, '../../test-data/tests-unit/media/media.less'), 'utf8'));
}, 10);
return;
}
if (url.includes('empty.less')) {
setTimeout(() => {
callback(null, { statusCode: 200 }, fs.readFileSync(path.join(__dirname, '../../test-data/tests-unit/empty/empty.less'), 'utf8'));
}, 10);
return;
}
}
// Handle redirect test - simulate needle's automatic redirect handling
if (url.includes('example.com/redirect.less')) {
setTimeout(() => {
// Simulate the final response after needle automatically follows the redirect
callback(null, { statusCode: 200 }, 'h1 { color: blue; }');
}, 10);
return;
}
if (url.includes('example.com/target.less')) {
setTimeout(() => {
callback(null, { statusCode: 200 }, 'h1 { color: blue; }');
}, 10);
return;
}
// Default error for unmocked URLs
setTimeout(() => {
callback(new Error('Unmocked URL: ' + url), null, null);
}, 10);
}
};
}
return originalRequire.apply(this, arguments);
};
// Now load other modules after mocking is set up
var path = require('path'),
fs = require('fs'),
lessTest = require('./less-test'),
stylize = require('../lib/less-node/lessc-helper').stylize;
// Parse command line arguments for test filtering
var args = process.argv.slice(2);
var testFilter = args.length > 0 ? args[0] : null;
// Create the test runner with the filter
var lessTester = lessTest(testFilter);
// HTTP mocking is now handled by needle mocking above
// Test HTTP redirect functionality
function testHttpRedirects() {
const less = require('../lib/less-node').default;
console.log('🧪 Testing HTTP redirect functionality...');
const redirectTest = `
@import "https://example.com/redirect.less";
h1 { color: red; }
`;
return less.render(redirectTest, {
filename: 'test-redirect.less'
}).then(result => {
console.log('✅ HTTP redirect test SUCCESS:');
console.log(result.css);
// Check if both imported and local content are present
if (result.css.includes('color: blue') && result.css.includes('color: red')) {
console.log('🎉 HTTP redirect test PASSED - both imported and local content found');
return true;
} else {
console.log('❌ HTTP redirect test FAILED - missing expected content');
return false;
}
}).catch(err => {
console.log('❌ HTTP redirect test ERROR:');
console.log(err.message);
return false;
});
}
// Test import-remote functionality
function testImportRemote() {
const less = require('../lib/less-node').default;
const fs = require('fs');
const path = require('path');
console.log('🧪 Testing import-remote functionality...');
const testFile = path.join(__dirname, '../../test-data/tests-unit/import/import-remote.less');
const expectedFile = path.join(__dirname, '../../test-data/tests-unit/import/import-remote.css');
const content = fs.readFileSync(testFile, 'utf8');
const expected = fs.readFileSync(expectedFile, 'utf8');
return less.render(content, {
filename: testFile
}).then(result => {
console.log('✅ Import-remote test SUCCESS:');
console.log('Expected:', expected.trim());
console.log('Actual:', result.css.trim());
if (result.css.trim() === expected.trim()) {
console.log('🎉 Import-remote test PASSED - CDN imports and variable resolution working');
return true;
} else {
console.log('❌ Import-remote test FAILED - output mismatch');
return false;
}
}).catch(err => {
console.log('❌ Import-remote test ERROR:');
console.log(err.message);
return false;
});
}
console.log('\n' + stylize('Less', 'underline') + '\n');
if (testFilter) {
console.log('Running tests matching: ' + testFilter + '\n');
}
// Glob patterns for main test runs (excluding problematic tests that will run separately)
var globPatterns = [
'tests-config/*/*.less',
'tests-unit/*/*.less',
// Debug tests have nested subdirectories (comments/, mediaquery/, all/)
'tests-config/debug/*/linenumbers-*.less',
'!tests-config/sourcemaps/**/*.less', // Exclude sourcemaps (need special handling)
'!tests-config/sourcemaps-empty/*', // Exclude sourcemaps-empty (need special handling)
'!tests-config/sourcemaps-disable-annotation/*', // Exclude sourcemaps-disable-annotation (need special handling)
'!tests-config/sourcemaps-variable-selector/*', // Exclude sourcemaps-variable-selector (need special handling)
'!tests-config/globalVars/*', // Exclude globalVars (need JSON config handling)
'!tests-config/modifyVars/*', // Exclude modifyVars (need JSON config handling)
'!tests-config/js-type-errors/*', // Exclude js-type-errors (need special test function)
'!tests-config/no-js-errors/*', // Exclude no-js-errors (need special test function)
'!tests-unit/import/import-remote.less', // Exclude import-remote (tested separately in isolation)
// HTTP import tests are now included since we have needle mocking
];
var testMap = [
[{
// TODO: Change this to rewriteUrls: 'all' once the relativeUrls option is removed
relativeUrls: true,
silent: true,
javascriptEnabled: true
}, '_main/'],
[{}, 'namespacing/'],
[{
math: 'parens'
}, 'math/strict/'],
[{
math: 'parens-division'
}, 'math/parens-division/'],
[{
math: 'always'
}, 'math/always/'],
// Use legacy strictMath: true here to demonstrate it still works
[{strictMath: true, strictUnits: true, javascriptEnabled: true}, '../errors/eval/',
lessTester.testErrors, null],
[{strictMath: true, strictUnits: true, javascriptEnabled: true}, '../errors/parse/',
lessTester.testErrors, null],
[{math: 'strict', strictUnits: true, javascriptEnabled: true}, 'js-type-errors/',
lessTester.testTypeErrors, null],
[{math: 'strict', strictUnits: true, javascriptEnabled: false}, 'no-js-errors/',
lessTester.testErrors, null],
[{math: 'strict', dumpLineNumbers: 'comments'}, 'debug/', null,
function(name) { return name + '-comments'; }],
[{math: 'strict', dumpLineNumbers: 'mediaquery'}, 'debug/', null,
function(name) { return name + '-mediaquery'; }],
[{math: 'strict', dumpLineNumbers: 'all'}, 'debug/', null,
function(name) { return name + '-all'; }],
// TODO: Change this to rewriteUrls: false once the relativeUrls option is removed
[{math: 'strict', relativeUrls: false, rootpath: 'folder (1)/'}, 'static-urls/'],
[{math: 'strict', compress: true}, 'compression/'],
// Main test runs using glob patterns (cosmiconfig handles configs)
{
patterns: globPatterns
},
[{math: 0, strictUnits: true}, 'units/strict/'],
[{math: 0, strictUnits: false}, 'units/no-strict/'],
// Error tests
{
patterns: ['tests-error/eval/*.less'],
verifyFunction: lessTester.testErrors
},
{
patterns: ['tests-error/parse/*.less'],
verifyFunction: lessTester.testErrors
},
[{math: 'strict', strictUnits: true, sourceMap: true, globalVars: true }, 'sourcemaps/',
lessTester.testSourcemap, null, null,
function(filename, type, baseFolder) {
// Special test cases with specific handling
{
patterns: ['tests-config/js-type-errors/*.less'],
verifyFunction: lessTester.testTypeErrors
},
{
patterns: ['tests-config/no-js-errors/*.less'],
verifyFunction: lessTester.testErrors
},
// Sourcemap tests with special handling
{
patterns: [
'tests-config/sourcemaps/**/*.less',
'tests-config/sourcemaps-url/**/*.less',
'tests-config/sourcemaps-rootpath/**/*.less',
'tests-config/sourcemaps-basepath/**/*.less',
'tests-config/sourcemaps-include-source/**/*.less'
],
verifyFunction: lessTester.testSourcemap,
getFilename: function(filename, type, baseFolder) {
if (type === 'vars') {
return path.join(baseFolder, filename) + '.json';
}
return path.join('test/sourcemaps', filename) + '.json';
}],
// Extract just the filename (without directory) for the JSON file
var jsonFilename = path.basename(filename);
// For sourcemap type, return path relative to test directory
if (type === 'sourcemap') {
return path.join('test/sourcemaps', jsonFilename) + '.json';
}
return path.join('test/sourcemaps', jsonFilename) + '.json';
}
},
{
patterns: ['tests-config/sourcemaps-empty/*.less'],
verifyFunction: lessTester.testEmptySourcemap
},
{
patterns: ['tests-config/sourcemaps-disable-annotation/*.less'],
verifyFunction: lessTester.testSourcemapWithoutUrlAnnotation
},
{
patterns: ['tests-config/sourcemaps-variable-selector/*.less'],
verifyFunction: lessTester.testSourcemapWithVariableInSelector
},
[{math: 'strict', strictUnits: true, globalVars: true }, '_main/import/json/',
lessTester.testImports, null, true,
function(filename, type, baseFolder) {
return path.join(baseFolder, filename) + '.json';
}],
[{math: 'strict', strictUnits: true, sourceMap: {sourceMapFileInline: true}},
'sourcemaps-empty/', lessTester.testEmptySourcemap],
[{math: 'strict', strictUnits: true, sourceMap: {disableSourcemapAnnotation: true}},
'sourcemaps-disable-annotation/', lessTester.testSourcemapWithoutUrlAnnotation],
[{math: 'strict', strictUnits: true, sourceMap: true},
'sourcemaps-variable-selector/', lessTester.testSourcemapWithVariableInSelector],
[{globalVars: true, banner: '/**\n * Test\n */\n'}, 'globalVars/',
null, null, null, function(name, type, baseFolder) { return path.join(baseFolder, name) + '.json'; }],
[{modifyVars: true}, 'modifyVars/',
null, null, null, function(name, type, baseFolder) { return path.join(baseFolder, name) + '.json'; }],
[{urlArgs: '424242'}, 'url-args/'],
[{rewriteUrls: 'all'}, 'rewrite-urls-all/'],
[{rewriteUrls: 'local'}, 'rewrite-urls-local/'],
[{rootpath: 'http://example.com/assets/css/', rewriteUrls: 'all'}, 'rootpath-rewrite-urls-all/'],
[{rootpath: 'http://example.com/assets/css/', rewriteUrls: 'local'}, 'rootpath-rewrite-urls-local/'],
[{paths: ['data/', '_main/import/']}, 'include-path/'],
[{paths: 'data/'}, 'include-path-string/'],
[{plugin: 'test/plugins/postprocess/'}, 'postProcessorPlugin/'],
[{plugin: 'test/plugins/preprocess/'}, 'preProcessorPlugin/'],
[{plugin: 'test/plugins/visitor/'}, 'visitorPlugin/'],
[{plugin: 'test/plugins/filemanager/'}, 'filemanagerPlugin/'],
[{math: 0}, '3rd-party/'],
[{ processImports: false }, 'process-imports/']
// Import tests with JSON configs
{
patterns: ['tests-config/globalVars/*.less'],
lessOptions: {
globalVars: function(file) {
const fs = require('fs');
const path = require('path');
const basename = path.basename(file, '.less');
const jsonPath = path.join(path.dirname(file), basename + '.json');
try {
return JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
} catch (e) {
return {};
}
}
}
},
{
patterns: ['tests-config/modifyVars/*.less'],
lessOptions: {
modifyVars: function(file) {
const fs = require('fs');
const path = require('path');
const basename = path.basename(file, '.less');
const jsonPath = path.join(path.dirname(file), basename + '.json');
try {
return JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
} catch (e) {
return {};
}
}
}
}
];
testMap.forEach(function(args) {
lessTester.runTestSet.apply(lessTester, args)
// Note: needle mocking is set up globally at the top of the file
testMap.forEach(function(testConfig) {
// For glob patterns, pass lessOptions as the first parameter and patterns as the second
if (testConfig.patterns) {
lessTester.runTestSet(
testConfig.lessOptions || {}, // First param: options (including lessOptions)
testConfig.patterns, // Second param: patterns
testConfig.verifyFunction || null, // Third param: verifyFunction
testConfig.nameModifier || null, // Fourth param: nameModifier
testConfig.doReplacements || null, // Fifth param: doReplacements
testConfig.getFilename || null // Sixth param: getFilename
);
} else {
// Legacy format for non-glob tests
var args = [
testConfig.options || {}, // First param: options
testConfig.foldername, // Second param: foldername
testConfig.verifyFunction || null, // Third param: verifyFunction
testConfig.nameModifier || null, // Fourth param: nameModifier
testConfig.doReplacements || null, // Fifth param: doReplacements
testConfig.getFilename || null // Sixth param: getFilename
];
lessTester.runTestSet.apply(lessTester, args);
}
});
lessTester.testSyncronous({syncImport: true}, '_main/import');
lessTester.testSyncronous({syncImport: true}, '_main/plugin');
lessTester.testSyncronous({syncImport: true}, 'math/strict/css');
// Special synchronous tests
lessTester.testSyncronous({syncImport: true}, 'tests-unit/import/import');
lessTester.testSyncronous({syncImport: true}, 'tests-config/math-strict/css');
lessTester.testNoOptions();
lessTester.testDisablePluginRule();
lessTester.testJSImport();
lessTester.finished();
(() => {
// Create new tester, since tests are not independent and tests
// above modify tester in a way that breaks remote imports.
lessTester = lessTest();
var scope = nock('https://example.com')
.get('/redirect.less').query(true)
.reply(301, null, { location: '/target.less' })
.get('/target.less').query(true)
.reply(200);
lessTester.runTestSet(
{},
'import-redirect/',
lessTester.testImportRedirect(scope)
);
lessTester.finished();
})();
// Test HTTP redirect functionality
console.log('\nTesting HTTP redirect functionality...');
testHttpRedirects();
console.log('HTTP redirect test completed');
// Test import-remote functionality in isolation
console.log('\nTesting import-remote functionality...');
testImportRemote();
console.log('Import-remote test completed');

View File

@@ -1,6 +1,8 @@
/* jshint latedef: nofunc */
var semver = require('semver');
var logger = require('../lib/less/logger').default;
var { cosmiconfigSync } = require('cosmiconfig');
var glob = require('glob');
var isVerbose = process.env.npm_config_loglevel !== 'concise';
logger.addListener({
@@ -18,7 +20,7 @@ logger.addListener({
});
module.exports = function() {
module.exports = function(testFilter) {
var path = require('path'),
fs = require('fs'),
clone = require('copy-anything').copy;
@@ -29,11 +31,11 @@ module.exports = function() {
var globals = Object.keys(global);
var oneTestOnly = process.argv[2],
var oneTestOnly = testFilter || process.argv[2],
isFinished = false;
var testFolder = path.dirname(require.resolve('@less/test-data'));
var lessFolder = path.join(testFolder, 'less');
var lessFolder = testFolder;
// Define String.prototype.endsWith if it doesn't exist (in older versions of node)
// This is required by the testSourceMap function below
@@ -83,33 +85,202 @@ module.exports = function() {
}
});
function testSourcemap(name, err, compiledLess, doReplacements, sourcemap, baseFolder) {
function validateSourcemapMappings(sourcemap, lessFile, compiledCSS) {
// Validate sourcemap mappings using SourceMapConsumer
var SourceMapConsumer = require('source-map').SourceMapConsumer;
// sourcemap can be either a string or already parsed object
var sourceMapObj = typeof sourcemap === 'string' ? JSON.parse(sourcemap) : sourcemap;
var consumer = new SourceMapConsumer(sourceMapObj);
// Read the LESS source file
var lessSource = fs.readFileSync(lessFile, 'utf8');
var lessLines = lessSource.split('\n');
// Use the compiled CSS (remove sourcemap annotation for validation)
var cssSource = compiledCSS.replace(/\/\*# sourceMappingURL=.*\*\/\s*$/, '').trim();
var cssLines = cssSource.split('\n');
var errors = [];
var validatedMappings = 0;
// Validate mappings for each line in the CSS
for (var cssLine = 1; cssLine <= cssLines.length; cssLine++) {
var cssLineContent = cssLines[cssLine - 1];
// Skip empty lines
if (!cssLineContent.trim()) {
continue;
}
// Check mapping for the start of this CSS line
var mapping = consumer.originalPositionFor({
line: cssLine,
column: 0
});
if (mapping.source) {
validatedMappings++;
// Verify the source file exists in the sourcemap
if (!sourceMapObj.sources || sourceMapObj.sources.indexOf(mapping.source) === -1) {
errors.push('Line ' + cssLine + ': mapped to source "' + mapping.source + '" which is not in sources array');
}
// Verify the line number is valid
if (mapping.line && mapping.line > 0) {
// If we can find the source file, validate the line exists
var sourceIndex = sourceMapObj.sources.indexOf(mapping.source);
if (sourceIndex >= 0 && sourceMapObj.sourcesContent && sourceMapObj.sourcesContent[sourceIndex] !== undefined && sourceMapObj.sourcesContent[sourceIndex] !== null) {
var sourceContent = sourceMapObj.sourcesContent[sourceIndex];
// Ensure sourceContent is a string (it should be, but be defensive)
if (typeof sourceContent !== 'string') {
sourceContent = String(sourceContent);
}
// Split by newline - handle both \n and \r\n
var sourceLines = sourceContent.split(/\r?\n/);
if (mapping.line > sourceLines.length) {
errors.push('Line ' + cssLine + ': mapped to line ' + mapping.line + ' in "' + mapping.source + '" but source only has ' + sourceLines.length + ' lines');
}
} else if (sourceIndex >= 0) {
// Source content not embedded, try to validate against the actual file if it matches
// This is a best-effort validation
}
}
}
}
// Validate that all sources in the sourcemap are valid
if (sourceMapObj.sources) {
sourceMapObj.sources.forEach(function(source, index) {
if (sourceMapObj.sourcesContent && sourceMapObj.sourcesContent[index]) {
// Source content is embedded, validate it's not empty
if (!sourceMapObj.sourcesContent[index].trim()) {
errors.push('Source "' + source + '" has empty content');
}
}
});
}
if (consumer.destroy && typeof consumer.destroy === 'function') {
consumer.destroy();
}
return {
valid: errors.length === 0,
errors: errors,
mappingsValidated: validatedMappings
};
}
function testSourcemap(name, err, compiledLess, doReplacements, sourcemap, baseFolder, getFilename) {
if (err) {
fail('ERROR: ' + (err && err.message));
return;
}
// Check the sourceMappingURL at the bottom of the file
var expectedSourceMapURL = name + '.css.map',
sourceMappingPrefix = '/*# sourceMappingURL=',
sourceMappingSuffix = ' */',
expectedCSSAppendage = sourceMappingPrefix + expectedSourceMapURL + sourceMappingSuffix;
if (!compiledLess.endsWith(expectedCSSAppendage)) {
// To display a better error message, we need to figure out what the actual sourceMappingURL value was, if it was even present
var indexOfSourceMappingPrefix = compiledLess.indexOf(sourceMappingPrefix);
if (indexOfSourceMappingPrefix === -1) {
fail('ERROR: sourceMappingURL was not found in ' + baseFolder + '/' + name + '.css.');
return;
}
var startOfSourceMappingValue = indexOfSourceMappingPrefix + sourceMappingPrefix.length,
indexOfNextSpace = compiledLess.indexOf(' ', startOfSourceMappingValue),
actualSourceMapURL = compiledLess.substring(startOfSourceMappingValue, indexOfNextSpace === -1 ? compiledLess.length : indexOfNextSpace);
fail('ERROR: sourceMappingURL should be "' + expectedSourceMapURL + '" but is "' + actualSourceMapURL + '".');
// Default expected URL is name + '.css.map', but can be overridden by sourceMapURL option
var sourceMappingPrefix = '/*# sourceMappingURL=',
sourceMappingSuffix = ' */';
var indexOfSourceMappingPrefix = compiledLess.indexOf(sourceMappingPrefix);
if (indexOfSourceMappingPrefix === -1) {
fail('ERROR: sourceMappingURL was not found in ' + baseFolder + '/' + name + '.css.');
return;
}
var startOfSourceMappingValue = indexOfSourceMappingPrefix + sourceMappingPrefix.length,
indexOfSuffix = compiledLess.indexOf(sourceMappingSuffix, startOfSourceMappingValue),
actualSourceMapURL = compiledLess.substring(startOfSourceMappingValue, indexOfSuffix === -1 ? compiledLess.length : indexOfSuffix).trim();
// For tests with custom sourceMapURL, we just verify it exists and is non-empty
// The actual value will be validated by comparing the sourcemap JSON
if (!actualSourceMapURL) {
fail('ERROR: sourceMappingURL is empty in ' + baseFolder + '/' + name + '.css.');
return;
}
fs.readFile(path.join('test/', name) + '.json', 'utf8', function (e, expectedSourcemap) {
// Use getFilename if available (for sourcemap tests with subdirectories)
var jsonPath;
if (getFilename && typeof getFilename === 'function') {
jsonPath = getFilename(name, 'sourcemap', baseFolder);
} else {
// Fallback: extract just the filename for sourcemap JSON files
var jsonFilename = path.basename(name);
jsonPath = path.join('test/sourcemaps', jsonFilename) + '.json';
}
fs.readFile(jsonPath, 'utf8', function (e, expectedSourcemap) {
process.stdout.write('- ' + path.join(baseFolder, name) + ': ');
if (sourcemap === expectedSourcemap) {
if (e) {
fail('ERROR: Could not read expected sourcemap file: ' + jsonPath + ' - ' + e.message);
return;
}
// Apply doReplacements to the expected sourcemap to handle {path} placeholders
// This normalizes absolute paths that differ between environments
// For sourcemaps, we need to ensure {path} uses forward slashes to avoid breaking JSON
// (backslashes in JSON strings need escaping, and sourcemaps should use forward slashes anyway)
var replacementPath = path.join(path.dirname(path.join(baseFolder, name) + '.less'), '/');
// Normalize to forward slashes for sourcemap JSON (web-compatible)
replacementPath = replacementPath.replace(/\\/g, '/');
// Replace {path} with normalized forward-slash path BEFORE calling doReplacements
// This ensures the JSON is always valid and uses web-compatible paths
expectedSourcemap = expectedSourcemap.replace(/\{path\}/g, replacementPath);
// Also handle other placeholders that might be in the sourcemap (but {path} is already done)
expectedSourcemap = doReplacements(expectedSourcemap, baseFolder, path.join(baseFolder, name) + '.less');
// Normalize paths in sourcemap JSON to use forward slashes (web-compatible)
// We need to parse the JSON, normalize the file property, then stringify for comparison
// This avoids breaking escape sequences like \n in the JSON string
function normalizeSourcemapPaths(sm) {
try {
var parsed = typeof sm === 'string' ? JSON.parse(sm) : sm;
if (parsed.file) {
parsed.file = parsed.file.replace(/\\/g, '/');
}
// Also normalize paths in sources array
if (parsed.sources && Array.isArray(parsed.sources)) {
parsed.sources = parsed.sources.map(function(src) {
return src.replace(/\\/g, '/');
});
}
return JSON.stringify(parsed, null, 0);
} catch (parseErr) {
// If parsing fails, return original (shouldn't happen)
return sm;
}
}
var normalizedSourcemap = normalizeSourcemapPaths(sourcemap);
var normalizedExpected = normalizeSourcemapPaths(expectedSourcemap);
if (normalizedSourcemap === normalizedExpected) {
// Validate the sourcemap mappings are correct
// Find the actual LESS file - it might be in a subdirectory
var nameParts = name.split('/');
var lessFileName = nameParts[nameParts.length - 1];
var lessFileDir = nameParts.length > 1 ? nameParts.slice(0, -1).join('/') : '';
var lessFile = path.join(lessFolder, lessFileDir, lessFileName) + '.less';
// Only validate if the LESS file exists
if (fs.existsSync(lessFile)) {
try {
// Parse the sourcemap once for validation (avoid re-parsing)
// Use the original sourcemap string, not the normalized one
var sourceMapObjForValidation = typeof sourcemap === 'string' ? JSON.parse(sourcemap) : sourcemap;
var validation = validateSourcemapMappings(sourceMapObjForValidation, lessFile, compiledLess);
if (!validation.valid) {
fail('ERROR: Sourcemap validation failed:\n' + validation.errors.join('\n'));
return;
}
if (isVerbose && validation.mappingsValidated > 0) {
process.stdout.write(' (validated ' + validation.mappingsValidated + ' mappings)');
}
} catch (validationErr) {
if (isVerbose) {
process.stdout.write(' (validation error: ' + validationErr.message + ')');
}
// Don't fail the test if validation has an error, just log it
}
}
ok('OK');
} else if (err) {
fail('ERROR: ' + (err && err.message));
@@ -118,7 +289,7 @@ module.exports = function() {
process.stdout.write(err.stack + '\n');
}
} else {
difference('FAIL', expectedSourcemap, sourcemap);
difference('FAIL', normalizedExpected, normalizedSourcemap);
}
});
}
@@ -281,7 +452,7 @@ module.exports = function() {
return new less.tree.Anonymous('file');
});
var expected = '@charset "utf-8";\n';
toCSS({}, path.join(lessFolder, 'root-registry', 'root.less'), function(error, output) {
toCSS({}, path.join(lessFolder, 'tests-config', 'root-registry', 'root.less'), function(error, output) {
if (error) {
return fail('ERROR: ' + error);
}
@@ -294,9 +465,42 @@ module.exports = function() {
function globalReplacements(input, directory, filename) {
var path = require('path');
var p = filename ? path.join(path.dirname(filename), '/') : directory,
pathimport = path.join(directory + 'import/'),
pathesc = p.replace(/[.:/\\]/g, function(a) { return '\\' + (a == '\\' ? '\/' : a); }),
var p = filename ? path.join(path.dirname(filename), '/') : directory;
// For debug tests in subdirectories (comments/, mediaquery/, all/),
// the import/ directory and main linenumbers.less file are at the parent debug/ level, not in the subdirectory
var isDebugSubdirectory = false;
var debugParentPath = null;
if (directory) {
// Normalize directory path separators for matching
var normalizedDir = directory.replace(/\\/g, '/');
// Check if we're in a debug subdirectory
if (normalizedDir.includes('/debug/') && (normalizedDir.includes('/comments/') || normalizedDir.includes('/mediaquery/') || normalizedDir.includes('/all/'))) {
isDebugSubdirectory = true;
// Extract the debug/ directory path (parent of the subdirectory)
// Match everything up to and including /debug/ (works with both absolute and relative paths)
var debugMatch = normalizedDir.match(/(.+\/debug)\//);
if (debugMatch) {
debugParentPath = debugMatch[1];
}
}
}
if (isDebugSubdirectory && debugParentPath) {
// For {path} placeholder, use the parent debug/ directory
// Convert back to native path format
p = debugParentPath.replace(/\//g, path.sep) + path.sep;
}
var pathimport;
if (isDebugSubdirectory && debugParentPath) {
pathimport = path.join(debugParentPath.replace(/\//g, path.sep), 'import') + path.sep;
} else {
pathimport = path.join(directory + 'import/');
}
var pathesc = p.replace(/[.:/\\]/g, function(a) { return '\\' + (a == '\\' ? '\/' : a); }),
pathimportesc = pathimport.replace(/[.:/\\]/g, function(a) { return '\\' + (a == '\\' ? '\/' : a); });
return input.replace(/\{path\}/g, p)
@@ -340,7 +544,18 @@ module.exports = function() {
}
function runTestSet(options, foldername, verifyFunction, nameModifier, doReplacements, getFilename) {
options = options ? clone(options) : {};
// Handle case where first parameter is glob patterns (no options object)
if (Array.isArray(options)) {
// First parameter is glob patterns, no options object
foldername = options;
options = {};
} else if (typeof options === 'string') {
// First parameter is foldername (no options object)
foldername = options;
options = {};
} else {
options = options ? clone(options) : {};
}
runTestSetInternal(lessFolder, options, foldername, verifyFunction, nameModifier, doReplacements, getFilename);
}
@@ -357,41 +572,125 @@ module.exports = function() {
doReplacements = globalReplacements;
}
function getBasename(file) {
return foldername + path.basename(file, '.less');
// Handle glob patterns with exclusions
if (Array.isArray(foldername)) {
var patterns = foldername;
var includePatterns = [];
var excludePatterns = [];
patterns.forEach(function(pattern) {
if (pattern.startsWith('!')) {
excludePatterns.push(pattern.substring(1));
} else {
includePatterns.push(pattern);
}
});
// Use glob to find all matching files, excluding the excluded patterns
var allFiles = [];
includePatterns.forEach(function(pattern) {
var files = glob.sync(pattern, {
cwd: baseFolder,
absolute: true,
ignore: excludePatterns
});
allFiles = allFiles.concat(files);
});
// Note: needle mocking is set up globally in index.js
// Process each .less file found
allFiles.forEach(function(filePath) {
if (/\.less$/.test(filePath)) {
var file = path.basename(filePath);
// For glob patterns, we need to construct the relative path differently
// The filePath is absolute, so we need to get the path relative to the test-data directory
var relativePath = path.relative(baseFolder, path.dirname(filePath)) + '/';
// Only process files that have corresponding .css files (these are the actual tests)
var cssPath = path.join(path.dirname(filePath), path.basename(file, '.less') + '.css');
if (fs.existsSync(cssPath)) {
// Process this file using the existing logic
processFileWithInfo({
file: file,
fullPath: filePath,
relativePath: relativePath
});
}
}
});
return;
}
fs.readdirSync(path.join(baseFolder, foldername)).forEach(function (file) {
if (!/\.less$/.test(file)) { return; }
function processFileWithInfo(fileInfo) {
var file = fileInfo.file;
var fullPath = fileInfo.fullPath;
var relativePath = fileInfo.relativePath;
// Load config for this specific file using cosmiconfig
var configResult = cosmiconfigSync('styles').search(path.dirname(fullPath));
// Deep clone the original options to prevent Less from modifying shared objects
var options = JSON.parse(JSON.stringify(originalOptions || {}));
if (configResult && configResult.config && configResult.config.language && configResult.config.language.less) {
// Deep clone and merge the language.less settings with the original options
var lessConfig = JSON.parse(JSON.stringify(configResult.config.language.less));
Object.keys(lessConfig).forEach(function(key) {
options[key] = lessConfig[key];
});
}
// Merge any lessOptions from the testMap (for dynamic options like getVars functions)
if (originalOptions && originalOptions.lessOptions) {
Object.keys(originalOptions.lessOptions).forEach(function(key) {
var value = originalOptions.lessOptions[key];
if (typeof value === 'function') {
// For functions, call them with the file path
var result = value(fullPath);
options[key] = result;
} else {
// For static values, use them directly
options[key] = value;
}
});
}
var options = clone(originalOptions);
// Don't pass stylize to less.render as it's not a valid option
options.stylize = stylize;
var name = getBasename(file, relativePath);
var name = getBasename(file);
if (oneTestOnly && name !== oneTestOnly) {
if (oneTestOnly && typeof oneTestOnly === 'string' && !name.includes(oneTestOnly)) {
return;
}
totalTests++;
if (options.sourceMap && !options.sourceMap.sourceMapFileInline) {
options.sourceMap = {
sourceMapOutputFilename: name + '.css',
sourceMapBasepath: baseFolder,
sourceMapRootpath: 'testweb/',
disableSourcemapAnnotation: options.sourceMap.disableSourcemapAnnotation
};
// This options is normally set by the bin/lessc script. Setting it causes the sourceMappingURL comment to be appended to the CSS
// output. The value is designed to allow the sourceMapBasepath option to be tested, as it should be removed by less before
// setting the sourceMappingURL value, leaving just the sourceMapOutputFilename and .map extension.
options.sourceMap.sourceMapFilename = options.sourceMap.sourceMapBasepath + '/' + options.sourceMap.sourceMapOutputFilename + '.map';
// Set test infrastructure defaults only if not already set by styles.config.cjs
// Less.js core (parse-tree.js) will handle normalization of:
// - sourceMapBasepath (defaults to input file's directory)
// - sourceMapInputFilename (defaults to options.filename)
// - sourceMapFilename (derived from sourceMapOutputFilename or input filename)
// - sourceMapOutputFilename (derived from input filename if not set)
if (!options.sourceMap.sourceMapOutputFilename) {
// Needed for sourcemap file name in JSON output
options.sourceMap.sourceMapOutputFilename = name + '.css';
}
if (!options.sourceMap.sourceMapRootpath) {
// Test-specific default for consistent test output paths
options.sourceMap.sourceMapRootpath = 'testweb/';
}
}
options.getVars = function(file) {
try {
return JSON.parse(fs.readFileSync(getFilename(getBasename(file), 'vars', baseFolder), 'utf8'));
return JSON.parse(fs.readFileSync(getFilename(getBasename(file, relativePath), 'vars', baseFolder), 'utf8'));
}
catch (e) {
return {};
@@ -400,7 +699,7 @@ module.exports = function() {
var doubleCallCheck = false;
queue(function() {
toCSS(options, path.join(baseFolder, foldername + file), function (err, result) {
toCSS(options, fullPath, function (err, result) {
if (doubleCallCheck) {
totalTests++;
@@ -416,7 +715,7 @@ module.exports = function() {
*/
if (verifyFunction) {
var verificationResult = verifyFunction(
name, err, result && result.css, doReplacements, result && result.map, baseFolder, result && result.imports
name, err, result && result.css, doReplacements, result && result.map, baseFolder, result && result.imports, getFilename
);
release();
return verificationResult;
@@ -439,31 +738,90 @@ module.exports = function() {
var css_name = name;
if (nameModifier) { css_name = nameModifier(name); }
fs.readFile(path.join(testFolder, 'css', css_name) + '.css', 'utf8', function (e, css) {
process.stdout.write('- ' + path.join(baseFolder, css_name) + ': ');
// Check if we're using the new co-located structure (tests-unit/ or tests-config/) or the old separated structure
var cssPath;
if (relativePath.startsWith('tests-unit/') || relativePath.startsWith('tests-config/')) {
// New co-located structure: CSS file is in the same directory as LESS file
cssPath = path.join(path.dirname(fullPath), path.basename(file, '.less') + '.css');
} else {
// Old separated structure: CSS file is in separate css/ folder
// Windows compatibility: css_name may already contain path separators
// Use path.join with empty string to let path.join handle normalization
cssPath = path.join(testFolder, css_name) + '.css';
}
css = css && doReplacements(css, path.join(baseFolder, foldername));
if (result.css === css) { ok('OK'); }
else {
difference('FAIL', css, result.css);
// For the new structure, we need to handle replacements differently
var replacementPath;
if (relativePath.startsWith('tests-unit/') || relativePath.startsWith('tests-config/')) {
replacementPath = path.dirname(fullPath);
// Ensure replacementPath ends with a path separator for consistent matching
if (!replacementPath.endsWith(path.sep)) {
replacementPath += path.sep;
}
release();
});
} else {
replacementPath = path.join(baseFolder, relativePath);
}
var testName = fullPath.replace(/\.less$/, '');
process.stdout.write('- ' + testName + ': ');
var css = fs.readFileSync(cssPath, 'utf8');
css = css && doReplacements(css, replacementPath);
if (result.css === css) { ok('OK'); }
else {
difference('FAIL', css, result.css);
}
release();
});
});
}
function getBasename(file, relativePath) {
var basePath = relativePath || foldername;
// Ensure basePath ends with a slash for proper path construction
if (basePath.charAt(basePath.length - 1) !== '/') {
basePath = basePath + '/';
}
return basePath + path.basename(file, '.less');
}
// This function is only called for non-glob patterns now
// For glob patterns, we use the glob library in the calling code
var dirPath = path.join(baseFolder, foldername);
var items = fs.readdirSync(dirPath);
items.forEach(function(item) {
if (/\.less$/.test(item)) {
processFileWithInfo({
file: item,
fullPath: path.join(dirPath, item),
relativePath: foldername
});
}
});
}
function diff(left, right) {
require('diff').diffLines(left, right).forEach(function(item) {
if (item.added || item.removed) {
var text = item.value && item.value.replace('\n', String.fromCharCode(182) + '\n').replace('\ufeff', '[[BOM]]');
process.stdout.write(stylize(text, item.added ? 'green' : 'red'));
} else {
process.stdout.write(item.value && item.value.replace('\ufeff', '[[BOM]]'));
}
// Configure chalk to always show colors
var chalk = require('chalk');
chalk.level = 3; // Force colors on
// Use jest-diff for much clearer output like Vitest
var diffResult = require('jest-diff').diffStringsUnified(left || '', right || '', {
expand: false,
includeChangeCounts: true,
contextLines: 1,
aColor: chalk.red,
bColor: chalk.green,
changeColor: chalk.inverse,
commonColor: chalk.dim
});
process.stdout.write('\n');
// jest-diff returns a string with ANSI colors, so we can output it directly
process.stdout.write(diffResult + '\n');
}
function fail(msg) {
@@ -476,6 +834,9 @@ module.exports = function() {
process.stdout.write(stylize(msg, 'yellow') + '\n');
failedTests++;
// Only show the diff, not the full text
process.stdout.write(stylize('Diff:', 'yellow') + '\n');
diff(left || '', right || '');
endTest();
}
@@ -528,27 +889,41 @@ module.exports = function() {
* @param {Function} callback
*/
function toCSS(options, filePath, callback) {
options = options || {};
// Deep clone options to prevent modifying the original, but preserve functions
var originalOptions = options || {};
options = JSON.parse(JSON.stringify(originalOptions));
// Restore functions that were lost in JSON serialization
if (originalOptions.getVars) {
options.getVars = originalOptions.getVars;
}
var str = fs.readFileSync(filePath, 'utf8'), addPath = path.dirname(filePath);
// Initialize paths array if it doesn't exist
if (typeof options.paths !== 'string') {
options.paths = options.paths || [];
if (!contains(options.paths, addPath)) {
options.paths.push(addPath);
}
} else {
options.paths = [options.paths]
options.paths = [options.paths];
}
// Add the current directory to paths if not already present
if (!contains(options.paths, addPath)) {
options.paths.push(addPath);
}
// Resolve all paths relative to the test file's directory
options.paths = options.paths.map(searchPath => {
return path.resolve(lessFolder, searchPath)
if (path.isAbsolute(searchPath)) {
return searchPath;
}
// Resolve relative to the test file's directory
return path.resolve(path.dirname(filePath), searchPath);
})
options.filename = path.resolve(process.cwd(), filePath);
options.optimization = options.optimization || 0;
if (options.globalVars) {
options.globalVars = options.getVars(filePath);
} else if (options.modifyVars) {
options.modifyVars = options.getVars(filePath);
}
// Note: globalVars and modifyVars are now handled via styles.config.cjs or lessOptions
if (options.plugin) {
var Plugin = require(path.resolve(process.cwd(), options.plugin));
options.plugins = [Plugin];
@@ -571,22 +946,7 @@ module.exports = function() {
ok(stylize('OK\n', 'green'));
}
function testImportRedirect(nockScope) {
return (name, err, css, doReplacements, sourcemap, baseFolder) => {
process.stdout.write('- ' + path.join(baseFolder, name) + ': ');
if (err) {
fail('FAIL: ' + (err && err.message));
return;
}
const expected = 'h1 {\n color: red;\n}\n';
if (css !== expected) {
difference('FAIL', expected, css);
return;
}
nockScope.done();
ok('OK');
};
}
// HTTP redirect testing is now handled directly in test/index.js
function testDisablePluginRule() {
less.render(
@@ -615,7 +975,6 @@ module.exports = function() {
testSourcemapWithoutUrlAnnotation: testSourcemapWithoutUrlAnnotation,
testSourcemapWithVariableInSelector: testSourcemapWithVariableInSelector,
testImports: testImports,
testImportRedirect: testImportRedirect,
testEmptySourcemap: testEmptySourcemap,
testNoOptions: testNoOptions,
testDisablePluginRule: testDisablePluginRule,

View File

@@ -0,0 +1 @@
{"version":3,"sources":["comprehensive.less"],"names":[],"mappings":"AAoBA;EACE,aAAA;EACA,mBAAA;;AAFF,UAIE;EACE,YAAA;EACA,eAAA;;AANJ,UAIE,QAIE;EACE,iBAAA;EACA,mBAAA;;AAVN,UAcE;EACE,mBAAA;EACA,aAAA;;AAhBJ,UAcE,SAIE;EACE,SAAA;EACA,gBAAA;;AAMN;EACE,OAAO,qBAAP;EACA,QAAQ,iBAAR;EACA,YAAA;;AAIF;EACE,cAAA;EACA,mBAAA;EACA,yCAAA;;AAIF;EAlDE,mBAAA;EACA,2BAAA;EACA,wBAAA;EAIA,yCAAA;EA+CA,aAAA;EACA,iBAAA;;AAIF,QAA0B;EACxB;IACE,aAAA;;EADF,UAGE;IACE,eAAA;;;AAKN;EACE;IACE,aAAA;IACA,uBAAuB,cAAvB;IACA,SAAA;;;AAKJ;AAMA;EALE,kBAAA;EACA,YAAA;EACA,eAAA;;AAGF;EAEE,mBAAA;EACA,YAAA;;AAIF,WACE;EACE,gBAAA;;AAFJ,WACE,GAGE;EACE,qBAAA;;AALN,WACE,GAGE,GAGE;EACE,qBAAA;;AAEA,WATN,GAGE,GAGE,EAGG;EACC,cAAA;;AAGF,WAbN,GAGE,GAGE,EAOG;EACC,cAAA;;AAYT;EACC,cAAA;;AAIF,OACE,QACE,QACE;EACE,cAAA","file":"{path}comprehensive.css"}

View File

@@ -0,0 +1 @@
{"version":3,"sources":["testweb/sourcemaps-basepath.less"],"names":[],"mappings":"AAEA;EACE,eAAA;EACA,gBAAA;;AAGF;EACE,iBAAA","file":"tests-config/sourcemaps-basepath/sourcemaps-basepath.css"}

View File

@@ -0,0 +1 @@
{"version":3,"sources":["testweb/sourcemaps-include-source.less"],"names":[],"mappings":"AAGA;EACE,mBAAA;EACA,YAAA;EACA,kBAAA;;AAGF;EACE,mBAAA","file":"tests-config/sourcemaps-include-source/sourcemaps-include-source.css","sourcesContent":["@primary: #007bff;\n@secondary: #6c757d;\n\n.button {\n background: @primary;\n color: white;\n padding: 10px 20px;\n}\n\n.secondary {\n background: @secondary;\n}\n\n"]}

View File

@@ -0,0 +1 @@
{"version":3,"sources":["https://example.com/less/sourcemaps-rootpath.less"],"names":[],"mappings":"AAEA;EACE,aAAA;EACA,YAAA;;AAGF;EACE,WAAA","file":"tests-config/sourcemaps-rootpath/sourcemaps-rootpath.css"}

View File

@@ -0,0 +1 @@
{"version":3,"sources":["testweb/sourcemaps-url.less"],"names":[],"mappings":"AAEA;EACE,UAAA;EACA,gBAAA;;AAGF;EACE,YAAA","file":"tests-config/sourcemaps-url/sourcemaps-url.css"}

View File

@@ -1,140 +0,0 @@
#yelow #short {
color: #fea;
}
#yelow #long {
color: #ffeeaa;
}
#yelow #rgba {
color: rgba(255, 238, 170, 0.1);
}
#yelow #argb {
color: #1affeeaa;
}
#blue #short {
color: #00f;
}
#blue #long {
color: #0000ff;
}
#blue #rgba {
color: rgba(0, 0, 255, 0.1);
}
#blue #argb {
color: #1a0000ff;
}
#alpha #hsla {
color: hsla(11, 20%, 20%, 0.6);
}
#overflow .a {
color: #000000;
}
#overflow .b {
color: #ffffff;
}
#overflow .c {
color: #ffffff;
}
#overflow .d {
color: #00ff00;
}
#overflow .e {
color: rgba(0, 31, 255, 0.42);
}
#grey {
color: #c8c8c8;
}
#aa3333 {
color: #aa3333;
}
#bb8080 {
color: hsl(0, 30%, 62%);
}
#ccff00 {
color: hsl(72, 100%, 50%);
}
.lightenblue {
color: #3333ff;
}
.darkenblue {
color: #0000cc;
}
.unknowncolors {
color: blue2;
border: 2px solid superred;
}
.transparent {
color: transparent;
background-color: rgba(0, 0, 0, 0);
}
#alpha #fromvar {
opacity: 0.7;
}
#alpha #short {
opacity: 1;
}
#alpha #long {
opacity: 1;
}
#alpha #rgba {
opacity: 0.2;
}
#alpha #hsl {
opacity: 1;
}
#percentage {
color: 255;
border-color: rgba(255, 0, 0, 0.5);
}
#rrggbbaa {
test-1: #55FF5599;
test-2: #5F59;
test-3: rgba(136, 255, 136, 0.6);
test-4: rgba(85, 255, 85, 0.1);
test-5: rgba(85, 255, 85, 0.6);
test-6: rgba(85, 255, 85, 0.6);
test-7: rgba(85, 255, 85, 0.5);
test-8: rgba(var(--color-accent), 0.2);
test-9: rgb(var(--color-accent));
test-9: hsla(var(--color-accent));
test-10: #55FF5599;
test-11: hsla(120, 100%, 66.66666667%, 0.6);
test-12: hsla(120, 100%, 66.66666667%, 0.5);
--semi-transparent-dark-background: #001e00ee;
--semi-transparent-dark-background-2: #001e00;
}
.color-oklch-sub {
background: oklch(from #0000FF calc(l - 0.1) c h);
}
.color-oklch-add {
background: oklch(from #0000FF calc(l + 0.1) c h);
}
.color-oklch-mult {
background: oklch(from #0000FF calc(l * 0.1) c h);
}
.color-oklch-div {
background: oklch(from #0000FF calc(l / 2) c h);
}
.color-hsl-sub {
background: hsl(from #0000FF calc(h - 1) s l);
}
.color-hsl-add {
background: hsl(from #0000FF calc(h + 1) s l);
}
.color-hsl-mult {
background: hsl(from #0000FF calc(h * 1) s l);
}
.color-hsl-div {
background: hsl(from #0000FF calc(h / 2) s l);
}
.color-rgb-sub {
background: rgb(from #0000FF calc(r - 1) g b);
}
.color-rgb-add {
background: rgb(from #0000FF calc(r + 1) g b);
}
.color-rgb-mult {
background: rgb(from #0000FF calc(r * 1) g b);
}
.color-rgb-div {
background: rgb(from #0000FF calc(r / 2) g b);
}

View File

@@ -1,97 +0,0 @@
.variables {
width: 14cm;
}
.variable-dash .q {
padding: 30px 15px;
}
.variables {
height: 24px;
color: #888;
font-family: "Trebuchet MS", Verdana, sans-serif;
quotes: "~" "~";
}
.redef {
zero: 0;
}
.redef .inition {
three: 3;
}
.values {
minus-one: -1;
font-family: 'Trebuchet', 'Trebuchet', 'Trebuchet';
color: #888 !important;
same-color: #888 !important;
same-again: #888 !important;
multi-important: #888 #888, 'Trebuchet' !important;
multi: something 'A', B, C, 'Trebuchet';
}
.variable-names .quoted {
name: 'hello';
}
.variable-names .unquoted {
name: 'hello';
}
.variable-names .color-keyword {
name: 'hello';
}
.alpha {
filter: alpha(opacity=42);
}
.test-rulePollution {
a: 'no-pollution';
}
.units {
width: 1px;
same-unit-as-previously: 1px;
square-pixel-divided: 1px;
odd-unit: 2;
percentage: 500%;
pixels: 500px;
conversion-metric-a: 30mm;
conversion-metric-b: 3cm;
conversion-imperial: 3in;
custom-unit: 420octocats;
custom-unit-cancelling: 18dogs;
mix-units: 2px;
invalid-units: 1px;
}
.units .fallback {
div-px-1: 10px;
div-px-2: 1px;
sub-px-1: 12.6px;
sub-cm-1: 9.666625cm;
mul-px-1: 19.6px;
mul-em-1: 19.6em;
mul-em-2: 196em;
mul-cm-1: 196cm;
add-px-1: 15.4px;
add-px-2: 393.35275591px;
mul-px-2: 140px;
mul-px-3: 140px;
}
*,
::before,
::after {
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
}
.radio_checked {
border-color: #fff;
}
div#apple {
color: blue;
}
div#banana {
color: blue;
}
div#cherry {
color: blue;
}
div#carrot {
color: blue;
}
div#potato {
color: blue;
}

View File

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 173 B

After

Width:  |  Height:  |  Size: 173 B

View File

@@ -1,3 +0,0 @@
@charset "UTF-8";
@import "import/import-charset-test";

View File

@@ -1,164 +0,0 @@
#yelow {
#short {
color: #fea;
}
#long {
color: #ffeeaa;
}
#rgba {
color: rgba(255, 238, 170, 0.1);
}
#argb {
color: argb(rgba(255, 238, 170, 0.1));
}
}
#blue {
#short {
color: #00f;
}
#long {
color: #0000ff;
}
#rgba {
color: rgba(0, 0, 255, 0.1);
}
#argb {
color: argb(rgba(0, 0, 255, 0.1));
}
}
#alpha #hsla {
color: hsla(11, 20%, 20%, 0.6);
}
#overflow {
.a { color: (#111111 - #444444); } // #000000
.b { color: (#eee + #fff); } // #ffffff
.c { color: (#aaa * 3); } // #ffffff
.d { color: (#00ee00 + #009900); } // #00ff00
.e { color: rgba(-99.9, 31.4159, 321, 0.42); }
}
#grey {
color: rgb(200, 200, 200);
}
#aa3333 {
color: rgb(66.66%, 20%, 20%);
}
#bb8080 {
color: hsl(0deg, 30%, 62%);
}
#ccff00 {
color: hsl(72deg, 100%, 50%);
}
.lightenblue {
color: lighten(blue, 10%);
}
.darkenblue {
color: darken(blue, 10%);
}
.unknowncolors {
color: blue2;
border: 2px solid superred;
}
.transparent {
color: transparent;
background-color: rgba(0, 0, 0, 0);
}
#alpha {
@colorvar: rgba(150, 200, 150, 0.7);
#fromvar {
opacity: alpha(@colorvar);
}
#short {
opacity: alpha(#aaa);
}
#long {
opacity: alpha(#bababa);
}
#rgba {
opacity: alpha(rgba(50, 120, 95, 0.2));
}
#hsl {
opacity: alpha(hsl(120, 100%, 50%));
}
}
#percentage {
color: red(rgb(100%, 0, 0));
border-color: rgba(100%, 0, 0, 50%);
}
#rrggbbaa {
test-1: #55FF5599;
test-2: #5F59;
test-3: lighten(#55FF5599, 10%);
test-4: fade(#5F59, 10%);
test-5: rgba(#55FF5599);
test-6: rgba(#5F59);
test-7: rgba(#5F59, 0.5);
test-8: rgba(var(--color-accent), 0.2);
test-9: rgb(var(--color-accent));
test-9: hsla(var(--color-accent));
test-10: color('#55FF5599');
test-11: hsla(#5F59);
test-12: hsla(#5F59, 0.5);
--semi-transparent-dark-background: #001e00ee;
--semi-transparent-dark-background-2: rgba(0, 30, 0, 238); // invalid opacity will be capped
}
.color-oklch-sub {
background: oklch(from #0000FF calc(l - 0.1) c h);
}
.color-oklch-add {
background: oklch(from #0000FF calc(l + 0.1) c h);
}
.color-oklch-mult {
background: oklch(from #0000FF calc(l * 0.1) c h);
}
.color-oklch-div {
background: oklch(from #0000FF calc(l / 2) c h);
}
.color-hsl-sub {
background: hsl(from #0000FF calc(h - 1) s l);
}
.color-hsl-add {
background: hsl(from #0000FF calc(h + 1) s l);
}
.color-hsl-mult {
background: hsl(from #0000FF calc(h * 1) s l);
}
.color-hsl-div {
background: hsl(from #0000FF calc(h / 2) s l);
}
.color-rgb-sub {
background: rgb(from #0000FF calc(r - 1) g b);
}
.color-rgb-add {
background: rgb(from #0000FF calc(r + 1) g b);
}
.color-rgb-mult {
background: rgb(from #0000FF calc(r * 1) g b);
}
.color-rgb-div {
background: rgb(from #0000FF calc(r / 2) g b);
}

View File

@@ -1,161 +0,0 @@
@a: 2;
@x: (@a * @a);
@y: (@x + 1);
@z: (@x * 2 + @y);
@var: -1;
.variables {
width: (@z + 1cm); // 14cm
}
.variable-dash {
@jumbotron-padding: 30px;
.q {
padding: @jumbotron-padding (@jumbotron-padding/2);
}
}
@b: @a * 10;
@c: #888;
@fonts: "Trebuchet MS", Verdana, sans-serif;
@f: @fonts;
@quotes: "~" "~";
@q: @quotes;
@onePixel: 1px;
.variables {
height: (@b + @x + 0px); // 24px
color: @c;
font-family: @f;
quotes: @q;
}
.redef {
@var: 0;
.inition {
@var: 4;
@var: 2;
three: @var;
@var: 3;
}
zero: @var;
}
@important-var: @c !important;
@important-var-two: @a !important;
.values {
minus-one: @var;
@a: 'Trebuchet';
@multi: 'A', B, C;
font-family: @a, @a, @a;
color: @c !important;
same-color: @important-var;
same-again: @important-var !important;
multi-important: @important-var @important-var, @important-var-two;
multi: something @multi, @a;
}
.variable-names {
.quoted {
@var: 'hello';
@name: 'var';
name: @@name;
}
.unquoted {
@var: 'hello';
@name: var;
name: @@name;
}
.color-keyword {
@red: 'hello';
@name: red;
name: @@name;
}
}
.alpha {
@var: 42;
filter: alpha(opacity=@var);
}
.polluteMixin() {
@a: 'pollution';
}
.test-rulePollution {
@a: 'no-pollution';
a: @a;
.polluteMixin();
a: @a;
}
.units {
width: @onePixel;
same-unit-as-previously: (@onePixel / @onePixel);
square-pixel-divided: (@onePixel * @onePixel / @onePixel);
odd-unit: unit((@onePixel * 4em / 2cm));
percentage: (10 * 50%);
pixels: (50px * 10);
conversion-metric-a: (20mm + 1cm);
conversion-metric-b: (1cm + 20mm);
conversion-imperial: (1in + 72pt + 6pc);
custom-unit: (42octocats * 10);
custom-unit-cancelling: (8cats * 9dogs / 4cats);
mix-units: (1px + 1em);
invalid-units: (1px * 1px);
.fallback {
@px: 14px;
@em: 1.4em;
@cm: 10cm;
div-px-1: (@px / @em);
div-px-2: ((@px / @em) / @cm);
sub-px-1: (@px - @em);
sub-cm-1: (@cm - (@px - @em));
mul-px-1: (@px * @em);
mul-em-1: (@em * @px);
mul-em-2: ((@em * @px) * @cm);
mul-cm-1: (@cm * (@em * @px));
add-px-1: (@px + @em);
add-px-2: ((@px + @em) + @cm);
mul-px-2: ((1 * @px) * @cm);
mul-px-3: ((@px * 1) * @cm);
}
}
*, ::before, ::after {
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
}
@a1: 1px;
@b2: 2px;
@c3: @a1 + @b2;
@radio-cls: radio;
@radio-cls-checked: @{radio-cls}_checked;
.@{radio-cls-checked} {
border-color: #fff;
}
@items:
// Fruit
apple,
banana,
cherry,
// Vegetables
carrot,
potato,
;
each(@items, {
div#@{value} {
color: blue;
}
})

View File

@@ -0,0 +1,7 @@
module.exports = {
language: {
less: {
"math": 0
}
}
};

View File

@@ -0,0 +1 @@
@charset "UTF-8";@media screen,print,handheld{body{font-size:12pt}}@media screen{.container{color:red;background:blue}}@media screen{.test{color:red;background:blue}}@media screen,print{body{margin:0}}@media screen{.container{color:red}.container .child{color:blue}}@media screen{.wrapper .inner{color:green}}@page{margin:2cm;size:A4}@supports (display: grid){.grid{display:grid}.flex{display:flex}}@media screen{body{color:black}}@layer base{body{margin:0}p{padding:0}}@media screen{.test{color:value}}@media screen and print{body{color:black}}

View File

@@ -0,0 +1,112 @@
// Test at-rule evaluation paths (eval, evalRoot, genCSS, accept, etc.)
// Test keywordList - @media with keyword list
@media screen, print, handheld {
body {
font-size: 12pt;
}
}
// Test declarationsBlock with mergeable=true (nested ruleset with declarations)
@media screen {
.container {
color: red;
background: blue;
}
}
// Test simpleBlock optimization with allRulesetDeclarations (single ruleset)
@media screen {
.test {
color: red;
background: blue;
}
}
// Test eval with value evaluation and keywordList conversion
@breakpoint: screen;
@media @breakpoint, print {
body {
margin: 0;
}
}
// Test evalRoot with ampersand handling
.container {
@media screen {
& {
color: red;
}
.child {
color: blue;
}
}
}
// Test evalRoot with mixed ampersands
.wrapper {
.inner {
@media screen {
& {
color: green;
}
}
}
}
// Test genCSS with value
@charset "UTF-8";
// Test genCSS with simpleBlock
@page {
margin: 2cm;
size: A4;
}
// Test genCSS with rules (non-simpleBlock)
@supports (display: grid) {
.grid {
display: grid;
}
.flex {
display: flex;
}
}
// Test isCharset
@charset "UTF-8";
// Test isRulesetLike (non-charset)
@media screen {
body {
color: black;
}
}
// Test compressed output (outputRuleset compressed path)
@layer base {
body {
margin: 0;
}
p {
padding: 0;
}
}
// Test variable/find/rulesets delegation
@media screen {
@var: value;
.test {
color: @var;
}
}
// Test eval with media bubbling
@media screen {
@media print {
body {
color: black;
}
}
}

View File

@@ -0,0 +1,8 @@
module.exports = {
language: {
less: {
compress: true
}
}
};

View File

@@ -0,0 +1 @@
@media screen{body{margin:0}p{padding:0}}@layer base{body{margin:0}p{padding:0}div{border:0}}@supports (display: grid){.grid{display:grid}.item{grid-column:1}}@page{margin:2cm;size:A4}@keyframes slide{from{transform:translateX(0)}to{transform:translateX(100px)}}

View File

@@ -0,0 +1,34 @@
// Tests for atrule.js coverage - compressed output path (lines 243-250)
// Also covers parser + genCSS + outputRuleset
// Compressed @media with rules
@media screen {
body { margin: 0; }
p { padding: 0; }
}
// Compressed @layer with multiple rules
@layer base {
body { margin: 0; }
p { padding: 0; }
div { border: 0; }
}
// Compressed @supports
@supports (display: grid) {
.grid { display: grid; }
.item { grid-column: 1; }
}
// Compressed @page
@page {
margin: 2cm;
size: A4;
}
// Compressed @keyframes
@keyframes slide {
from { transform: translateX(0); }
to { transform: translateX(100px); }
}

View File

@@ -0,0 +1,8 @@
module.exports = {
language: {
less: {
compress: true
}
}
};

View File

@@ -0,0 +1,8 @@
module.exports = {
language: {
less: {
"math": "strict",
"compress": true
}
}
};

View File

@@ -0,0 +1,2 @@
// Entry file for -all configuration
@import "../linenumbers.less";

View File

@@ -0,0 +1,8 @@
module.exports = {
language: {
less: {
"math": "strict",
"dumpLineNumbers": "all"
}
}
};

View File

@@ -0,0 +1,2 @@
// Entry file for -comments configuration
@import "../linenumbers.less";

View File

@@ -0,0 +1,8 @@
module.exports = {
language: {
less: {
"math": "strict",
"dumpLineNumbers": "comments"
}
}
};

View File

@@ -0,0 +1,2 @@
// Entry file for -mediaquery configuration
@import "../linenumbers.less";

View File

@@ -0,0 +1,8 @@
module.exports = {
language: {
less: {
"math": "strict",
"dumpLineNumbers": "mediaquery"
}
}
};

View File

@@ -0,0 +1,7 @@
module.exports = {
language: {
less: {
"plugin": "test/plugins/filemanager/"
}
}
};

View File

@@ -0,0 +1,13 @@
module.exports = {
language: {
less: {
globalVars: {
'my-color': 'red',
'base-color': '#111',
'the-border': '1px',
'red': '#842210'
},
banner: '/**\n * Test\n */\n'
}
}
};

View File

@@ -0,0 +1,7 @@
module.exports = {
language: {
less: {
"paths": ["../../data/"]
}
}
};

View File

@@ -0,0 +1,9 @@
module.exports = {
language: {
less: {
"paths": [
"../../data/"
]
}
}
};

View File

@@ -0,0 +1,9 @@
module.exports = {
language: {
less: {
math: 'strict',
strictUnits: true,
javascriptEnabled: true
}
}
};

View File

@@ -0,0 +1,7 @@
module.exports = {
language: {
less: {
"math": "always"
}
}
};

View File

@@ -0,0 +1,7 @@
module.exports = {
language: {
less: {
"math": "parens-division"
}
}
};

View File

@@ -0,0 +1,7 @@
module.exports = {
language: {
less: {
"math": "parens"
}
}
};

Some files were not shown because too many files have changed in this diff Show More