Skip to content

Commit d835c4e

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
1 parent cc8e8d4 commit d835c4e

1 file changed

Lines changed: 177 additions & 0 deletions

File tree

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

0 commit comments

Comments
 (0)