Skip to content

Commit 6917175

Browse files
committed
test(WebServer): add CSS unused selector scanner
- Scan all CSS files for class and ID selectors - Verify each selector is referenced in HTML templates or JS sources - Supports whitelist for browser pseudo-elements Signed-off-by: VIFEX <vifextech@foxmail.com>
1 parent cc8e8d4 commit 6917175

1 file changed

Lines changed: 176 additions & 0 deletions

File tree

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/**
2+
* Tests for detecting unreferenced CSS selectors.
3+
*
4+
* Scans all CSS files and checks that every class/ID selector
5+
* is referenced in at least one HTML template or JS source file.
6+
*/
7+
const fs = require('fs');
8+
const path = require('path');
9+
const { describe, it, assertTrue } = require('./framework');
10+
11+
module.exports = function () {
12+
const ROOT = path.join(__dirname, '..', '..');
13+
const CSS_DIR = path.join(ROOT, 'static', 'css');
14+
const TEMPLATE_DIR = path.join(ROOT, 'templates');
15+
const JS_DIR = path.join(ROOT, 'static', 'js');
16+
17+
/* ===========================
18+
FILE COLLECTION
19+
=========================== */
20+
21+
function collectFiles(dir, ext) {
22+
const results = [];
23+
if (!fs.existsSync(dir)) return results;
24+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
25+
const full = path.join(dir, entry.name);
26+
if (entry.isDirectory()) {
27+
results.push(...collectFiles(full, ext));
28+
} else if (entry.name.endsWith(ext)) {
29+
results.push(full);
30+
}
31+
}
32+
return results;
33+
}
34+
35+
/* ===========================
36+
CSS SELECTOR EXTRACTION
37+
=========================== */
38+
39+
/**
40+
* Extract all class names and IDs from a CSS file.
41+
* Returns { classes: Set<string>, ids: Set<string> }
42+
*/
43+
function extractSelectors(cssContent) {
44+
const classes = new Set();
45+
const ids = new Set();
46+
47+
// Remove comments
48+
const cleaned = cssContent.replace(/\/\*[\s\S]*?\*\//g, '');
49+
50+
// Remove @keyframes blocks (their names are not selectors)
51+
const noKeyframes = cleaned.replace(
52+
/@keyframes\s+[\w-]+\s*\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g,
53+
'',
54+
);
55+
56+
// Remove @media / @supports wrappers (keep inner content)
57+
// We just need to extract selectors from rule blocks
58+
59+
// Match selectors before { ... }
60+
const ruleRegex = /([^{}@]+)\{/g;
61+
let match;
62+
while ((match = ruleRegex.exec(noKeyframes)) !== null) {
63+
const selectorGroup = match[1].trim();
64+
if (!selectorGroup || selectorGroup.startsWith('@')) continue;
65+
66+
// Split comma-separated selectors
67+
for (const selector of selectorGroup.split(',')) {
68+
const trimmed = selector.trim();
69+
70+
// Extract class names: .foo-bar
71+
const classMatches = trimmed.match(/\.([a-zA-Z_][\w-]*)/g);
72+
if (classMatches) {
73+
for (const c of classMatches) {
74+
classes.add(c.slice(1)); // remove leading dot
75+
}
76+
}
77+
78+
// Extract IDs: #foo-bar
79+
const idMatches = trimmed.match(/#([a-zA-Z_][\w-]*)/g);
80+
if (idMatches) {
81+
for (const id of idMatches) {
82+
ids.add(id.slice(1)); // remove leading #
83+
}
84+
}
85+
}
86+
}
87+
88+
return { classes, ids };
89+
}
90+
91+
/* ===========================
92+
REFERENCE SCANNING
93+
=========================== */
94+
95+
function buildReferenceCorpus() {
96+
const htmlFiles = collectFiles(TEMPLATE_DIR, '.html');
97+
const jsFiles = collectFiles(JS_DIR, '.js');
98+
99+
let corpus = '';
100+
for (const f of [...htmlFiles, ...jsFiles]) {
101+
corpus += fs.readFileSync(f, 'utf-8') + '\n';
102+
}
103+
return corpus;
104+
}
105+
106+
function isReferenced(name, corpus) {
107+
// Direct string match — covers class="foo", classList.add('foo'),
108+
// getElementById('foo'), querySelector('.foo' / '#foo'), etc.
109+
return corpus.includes(name);
110+
}
111+
112+
/* ===========================
113+
KNOWN EXCEPTIONS
114+
=========================== */
115+
116+
// Pseudo-element / state suffixes that CSS generates but aren't in source
117+
// e.g. .tab:hover — "tab" is the real class, ":hover" is pseudo
118+
// These are classes that only appear as part of pseudo-selectors or
119+
// are injected by third-party libraries / browser defaults.
120+
const WHITELIST = new Set([
121+
// Codicon font classes (loaded from external font, referenced by prefix)
122+
// Browser/OS scrollbar styling
123+
'webkit-scrollbar',
124+
'webkit-scrollbar-thumb',
125+
'webkit-scrollbar-track',
126+
]);
127+
128+
/* ===========================
129+
TESTS
130+
=========================== */
131+
132+
const cssFiles = collectFiles(CSS_DIR, '.css');
133+
const corpus = buildReferenceCorpus();
134+
135+
// Also include CSS files themselves as reference (cross-file references like var())
136+
let cssCorpus = '';
137+
for (const f of cssFiles) {
138+
cssCorpus += fs.readFileSync(f, 'utf-8') + '\n';
139+
}
140+
141+
describe('CSS Unused Selectors', () => {
142+
for (const cssFile of cssFiles) {
143+
const basename = path.basename(cssFile);
144+
const content = fs.readFileSync(cssFile, 'utf-8');
145+
const { classes, ids } = extractSelectors(content);
146+
147+
it(`${basename}: all class selectors are referenced`, () => {
148+
const unreferenced = [];
149+
for (const cls of classes) {
150+
if (WHITELIST.has(cls)) continue;
151+
if (!isReferenced(cls, corpus) && !isReferenced(cls, cssCorpus)) {
152+
unreferenced.push(`.${cls}`);
153+
}
154+
}
155+
assertTrue(
156+
unreferenced.length === 0,
157+
`Unreferenced classes in ${basename}:\n ${unreferenced.join('\n ')}`,
158+
);
159+
});
160+
161+
it(`${basename}: all ID selectors are referenced`, () => {
162+
const unreferenced = [];
163+
for (const id of ids) {
164+
if (WHITELIST.has(id)) continue;
165+
if (!isReferenced(id, corpus) && !isReferenced(id, cssCorpus)) {
166+
unreferenced.push(`#${id}`);
167+
}
168+
}
169+
assertTrue(
170+
unreferenced.length === 0,
171+
`Unreferenced IDs in ${basename}:\n ${unreferenced.join('\n ')}`,
172+
);
173+
});
174+
}
175+
});
176+
};

0 commit comments

Comments
 (0)