1import { describe, test, expect } from 'bun:test';
2import fs from 'fs';
3import os from 'os';
4import path from 'path';
5import { spawnSync } from 'child_process';
6import {
7 ANTIPATTERNS, checkElementBorders, checkElementMotion, checkElementGlow, isNeutralColor, isFullPage,
8 detectText, detectHtml, extractStyleBlocks, extractCSSinJS,
9 walkDir, SCANNABLE_EXTENSIONS,
10 buildImportGraph, resolveImport,
11 detectFrameworkConfig, isPortListening, FRAMEWORK_CONFIGS,
12} from '../cli/engine/detect-antipatterns.mjs';
13
14const FIXTURES = path.join(import.meta.dir, 'fixtures', 'antipatterns');
15const SCRIPT = path.join(import.meta.dir, '..', 'cli', 'engine', 'detect-antipatterns.mjs');
16const BENCH_SCRIPT = path.join(import.meta.dir, '..', 'scripts', 'benchmark-detector.mjs');
17
18function writeStaticFixture(files) {
19 const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-static-'));
20 for (const [name, contents] of Object.entries(files)) {
21 const fullPath = path.join(dir, name);
22 fs.mkdirSync(path.dirname(fullPath), { recursive: true });
23 fs.writeFileSync(fullPath, contents);
24 }
25 return { dir, file: path.join(dir, 'index.html') };
26}
27
28async function withStaticFixture(files, callback) {
29 const fixture = writeStaticFixture(files);
30 try {
31 return await callback(fixture);
32 } finally {
33 fs.rmSync(fixture.dir, { recursive: true, force: true });
34 }
35}
36
37function findingIds(findings) {
38 return findings.map(f => f.antipattern);
39}
40
41
42// ---------------------------------------------------------------------------
43// Core: checkElementBorders (computed style simulation)
44// ---------------------------------------------------------------------------
45
46describe('checkElementBorders', () => {
47 function mockStyle(overrides) {
48 return { borderTopWidth: '0', borderRightWidth: '0', borderBottomWidth: '0', borderLeftWidth: '0',
49 borderTopColor: '', borderRightColor: '', borderBottomColor: '', borderLeftColor: '',
50 borderRadius: '0', ...overrides };
51 }
52
53 test('detects side-tab with radius', () => {
54 const f = checkElementBorders('div', mockStyle({
55 borderLeftWidth: '4', borderLeftColor: 'rgb(59, 130, 246)', borderRadius: '12',
56 }));
57 expect(f.length).toBe(1);
58 expect(f[0].id).toBe('side-tab');
59 });
60
61 test('detects side-tab without radius (thick)', () => {
62 const f = checkElementBorders('div', mockStyle({
63 borderLeftWidth: '4', borderLeftColor: 'rgb(59, 130, 246)',
64 }));
65 expect(f.length).toBe(1);
66 expect(f[0].id).toBe('side-tab');
67 });
68
69 test('skips side border below threshold without radius', () => {
70 const f = checkElementBorders('div', mockStyle({
71 borderLeftWidth: '2', borderLeftColor: 'rgb(59, 130, 246)',
72 }));
73 expect(f).toHaveLength(0);
74 });
75
76 test('detects border-accent-on-rounded (top)', () => {
77 const f = checkElementBorders('div', mockStyle({
78 borderTopWidth: '3', borderTopColor: 'rgb(139, 92, 246)', borderRadius: '12',
79 }));
80 expect(f.length).toBe(1);
81 expect(f[0].id).toBe('border-accent-on-rounded');
82 });
83
84 test('skips safe tags', () => {
85 const f = checkElementBorders('blockquote', mockStyle({
86 borderLeftWidth: '4', borderLeftColor: 'rgb(59, 130, 246)',
87 }));
88 expect(f).toHaveLength(0);
89 });
90
91 test('skips neutral colors', () => {
92 const f = checkElementBorders('div', mockStyle({
93 borderLeftWidth: '4', borderLeftColor: 'rgb(200, 200, 200)',
94 }));
95 expect(f).toHaveLength(0);
96 });
97
98 test('skips uniform borders (not accent)', () => {
99 const f = checkElementBorders('div', mockStyle({
100 borderTopWidth: '2', borderRightWidth: '2', borderBottomWidth: '2', borderLeftWidth: '2',
101 borderTopColor: 'rgb(59, 130, 246)', borderRightColor: 'rgb(59, 130, 246)',
102 borderBottomColor: 'rgb(59, 130, 246)', borderLeftColor: 'rgb(59, 130, 246)',
103 }));
104 expect(f).toHaveLength(0);
105 });
106});
107
108// ---------------------------------------------------------------------------
109// isNeutralColor
110// ---------------------------------------------------------------------------
111
112describe('isNeutralColor', () => {
113 test('gray is neutral', () => expect(isNeutralColor('rgb(200, 200, 200)')).toBe(true));
114 test('blue is not neutral', () => expect(isNeutralColor('rgb(59, 130, 246)')).toBe(false));
115 test('transparent is neutral', () => expect(isNeutralColor('transparent')).toBe(true));
116 test('null is neutral', () => expect(isNeutralColor(null)).toBe(true));
117});
118
119// ---------------------------------------------------------------------------
120// Regex fallback (detectText)
121// ---------------------------------------------------------------------------
122
123describe('detectText — Tailwind side-tab', () => {
124 test('detects border-l-4 (thick, no rounded needed)', () => {
125 const f = detectText('<div class="border-l-4 border-blue-500">', 'test.html');
126 expect(f.some(r => r.antipattern === 'side-tab')).toBe(true);
127 });
128
129 test('detects border-l-1 + rounded', () => {
130 const f = detectText('<div class="border-l-1 border-blue-500 rounded-md">', 'test.html');
131 expect(f.some(r => r.antipattern === 'side-tab')).toBe(true);
132 });
133
134 test('ignores border-l-1 without rounded', () => {
135 const f = detectText('<div class="border-l-1 border-gray-300">', 'test.html');
136 expect(f.filter(r => r.antipattern === 'side-tab')).toHaveLength(0);
137 });
138
139 test('ignores border-t without rounded', () => {
140 const f = detectText('<div class="border-t-4 border-b-4">', 'test.html');
141 expect(f.filter(r => r.antipattern === 'border-accent-on-rounded')).toHaveLength(0);
142 });
143});
144
145describe('detectText — CSS borders', () => {
146 test('detects border-left shorthand', () => {
147 const f = detectText('.card { border-left: 4px solid #3b82f6; }', 'test.css');
148 expect(f.some(r => r.antipattern === 'side-tab')).toBe(true);
149 });
150
151 test('ignores neutral border', () => {
152 const f = detectText('.card { border-left: 4px solid #e5e7eb; }', 'test.css');
153 expect(f.filter(r => r.antipattern === 'side-tab')).toHaveLength(0);
154 });
155
156 test('skips blockquote', () => {
157 const f = detectText('<blockquote style="border-left: 4px solid #ccc;">', 'test.html');
158 expect(f.filter(r => r.antipattern === 'side-tab')).toHaveLength(0);
159 });
160});
161
162describe('detectText — overused fonts', () => {
163 test('detects Inter', () => {
164 const f = detectText("body { font-family: 'Inter', sans-serif; }", 'test.css');
165 expect(f.some(r => r.antipattern === 'overused-font')).toBe(true);
166 });
167
168 test('detects Fraunces (current AI-default monoculture)', () => {
169 const f = detectText("h1 { font-family: 'Fraunces', Georgia, serif; }", 'test.css');
170 expect(f.some(r => r.antipattern === 'overused-font')).toBe(true);
171 });
172
173 test('detects Geist (Vercel-default monoculture)', () => {
174 const f = detectText("body { font-family: 'Geist', sans-serif; }", 'test.css');
175 expect(f.some(r => r.antipattern === 'overused-font')).toBe(true);
176 });
177
178 test('does not flag distinctive fonts', () => {
179 const f = detectText("body { font-family: 'Karla', sans-serif; }", 'test.css');
180 expect(f.filter(r => r.antipattern === 'overused-font')).toHaveLength(0);
181 });
182});
183
184describe('detectText — flat type hierarchy', () => {
185 test('flags sizes too close together', () => {
186 const page = '<!DOCTYPE html><html><style>h1{font-size:18px}h2{font-size:16px}h3{font-size:15px}p{font-size:14px}.s{font-size:13px}</style></html>';
187 const f = detectText(page, 'test.html');
188 expect(f.some(r => r.antipattern === 'flat-type-hierarchy')).toBe(true);
189 });
190
191 test('passes good hierarchy', () => {
192 const page = '<!DOCTYPE html><html><style>h1{font-size:48px}h2{font-size:32px}p{font-size:16px}.s{font-size:12px}</style></html>';
193 const f = detectText(page, 'test.html');
194 expect(f.filter(r => r.antipattern === 'flat-type-hierarchy')).toHaveLength(0);
195 });
196});
197
198// Static HTML/CSS fixture tests moved to detect-antipatterns-fixtures.test.mjs (run via node --test)
199
200// ---------------------------------------------------------------------------
201// Full page vs partial detection
202// ---------------------------------------------------------------------------
203
204describe('isFullPage', () => {
205 test('detects DOCTYPE', () => expect(isFullPage('<!DOCTYPE html><html>')).toBe(true));
206 test('detects <html>', () => expect(isFullPage('<html><head></head>')).toBe(true));
207 test('detects <head>', () => expect(isFullPage('<head><meta charset="UTF-8"></head>')).toBe(true));
208 test('rejects component/partial', () => expect(isFullPage('<div class="card">content</div>')).toBe(false));
209 test('rejects JSX', () => expect(isFullPage('export default function Card() { return <div>hi</div> }')).toBe(false));
210});
211
212describe('partials skip page-level checks', () => {
213 test('regex: partial with flat hierarchy is not flagged', () => {
214 const partial = '<div style="font-size: 14px">text</div>\n<div style="font-size: 16px">text</div>\n<div style="font-size: 15px">text</div>';
215 const f = detectText(partial, 'card.tsx');
216 expect(f.filter(r => r.antipattern === 'flat-type-hierarchy')).toHaveLength(0);
217 });
218
219 test('regex: partial with single overused font is not flagged for single-font', () => {
220 const partial = `<div style="font-family: 'Inter', sans-serif; font-size: 14px">text</div>\n`.repeat(25);
221 const f = detectText(partial, 'card.tsx');
222 expect(f.filter(r => r.antipattern === 'single-font')).toHaveLength(0);
223 });
224
225 test('regex: partial still flags border anti-patterns', () => {
226 const partial = '<div class="border-l-4 border-blue-500 rounded-lg">card</div>';
227 const f = detectText(partial, 'card.tsx');
228 expect(f.some(r => r.antipattern === 'side-tab')).toBe(true);
229 });
230
231 test('regex: full page with flat hierarchy IS flagged', () => {
232 const page = '<!DOCTYPE html><html><head></head><body>\n' +
233 '<h1 style="font-size: 18px">h1</h1>\n<h2 style="font-size: 16px">h2</h2>\n' +
234 '<p style="font-size: 14px">p</p>\n<span style="font-size: 15px">s</span>\n' +
235 '<small style="font-size: 13px">sm</small>\n</body></html>';
236 const f = detectText(page, 'index.html');
237 expect(f.some(r => r.antipattern === 'flat-type-hierarchy')).toBe(true);
238 });
239});
240
241// ---------------------------------------------------------------------------
242// Layout anti-patterns
243// ---------------------------------------------------------------------------
244
245describe('detectHtml — layout', () => {
246 test('detects monotonous spacing via regex', () => {
247 // A page where every padding/margin is 16px
248 const html = '<!DOCTYPE html><html><body>' +
249 '<div style="padding: 16px; margin-bottom: 16px;"><p style="margin-bottom: 16px;">a</p></div>'.repeat(5) +
250 '</body></html>';
251 const f = detectText(html, 'test.html');
252 expect(f.some(r => r.antipattern === 'monotonous-spacing')).toBe(true);
253 });
254
255 test('detects everything centered via regex', () => {
256 const html = `<!DOCTYPE html><html><body>
257<h1 style="text-align: center;">Title</h1>
258<p style="text-align: center;">Paragraph one more text here</p>
259<p style="text-align: center;">Paragraph two more text here</p>
260<p style="text-align: center;">Paragraph three more text here</p>
261<p style="text-align: center;">Paragraph four more text here</p>
262<p style="text-align: center;">Paragraph five more text here</p>
263<p style="text-align: center;">Paragraph six more text here</p>
264</body></html>`;
265 const f = detectText(html, 'test.html');
266 expect(f.some(r => r.antipattern === 'everything-centered')).toBe(true);
267 });
268
269});
270
271// ---------------------------------------------------------------------------
272// Motion anti-patterns
273// ---------------------------------------------------------------------------
274
275describe('checkElementMotion', () => {
276 function mockStyle(overrides) {
277 return { transitionProperty: '', animationName: 'none', animationTimingFunction: '', transitionTimingFunction: '', ...overrides };
278 }
279
280 test('detects bounce animation name', () => {
281 const f = checkElementMotion('div', mockStyle({ animationName: 'bounce' }));
282 expect(f.some(r => r.id === 'bounce-easing')).toBe(true);
283 });
284
285 test('detects elastic animation name', () => {
286 const f = checkElementMotion('div', mockStyle({ animationName: 'elastic-in' }));
287 expect(f.some(r => r.id === 'bounce-easing')).toBe(true);
288 });
289
290 test('detects overshoot cubic-bezier in animation timing', () => {
291 const f = checkElementMotion('div', mockStyle({
292 animationTimingFunction: 'cubic-bezier(0.68, -0.55, 0.265, 1.55)',
293 }));
294 expect(f.some(r => r.id === 'bounce-easing')).toBe(true);
295 });
296
297 test('detects overshoot cubic-bezier in transition timing', () => {
298 const f = checkElementMotion('div', mockStyle({
299 transitionTimingFunction: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
300 }));
301 expect(f.some(r => r.id === 'bounce-easing')).toBe(true);
302 });
303
304 test('passes standard ease-out-quart', () => {
305 const f = checkElementMotion('div', mockStyle({
306 transitionTimingFunction: 'cubic-bezier(0.25, 1, 0.5, 1)',
307 }));
308 expect(f.filter(r => r.id === 'bounce-easing')).toHaveLength(0);
309 });
310
311 test('passes standard ease', () => {
312 const f = checkElementMotion('div', mockStyle({
313 transitionTimingFunction: 'cubic-bezier(0.25, 0.1, 0.25, 1.0)',
314 }));
315 expect(f.filter(r => r.id === 'bounce-easing')).toHaveLength(0);
316 });
317
318 test('detects width transition', () => {
319 const f = checkElementMotion('div', mockStyle({ transitionProperty: 'width' }));
320 expect(f.some(r => r.id === 'layout-transition')).toBe(true);
321 });
322
323 test('detects height transition', () => {
324 const f = checkElementMotion('div', mockStyle({ transitionProperty: 'height' }));
325 expect(f.some(r => r.id === 'layout-transition')).toBe(true);
326 });
327
328 test('detects padding transition', () => {
329 const f = checkElementMotion('div', mockStyle({ transitionProperty: 'padding' }));
330 expect(f.some(r => r.id === 'layout-transition')).toBe(true);
331 });
332
333 test('detects margin transition', () => {
334 const f = checkElementMotion('div', mockStyle({ transitionProperty: 'margin' }));
335 expect(f.some(r => r.id === 'layout-transition')).toBe(true);
336 });
337
338 test('detects max-height transition', () => {
339 const f = checkElementMotion('div', mockStyle({ transitionProperty: 'max-height' }));
340 expect(f.some(r => r.id === 'layout-transition')).toBe(true);
341 });
342
343 test('detects layout prop among mixed transitions', () => {
344 const f = checkElementMotion('div', mockStyle({ transitionProperty: 'opacity, width, color' }));
345 expect(f.some(r => r.id === 'layout-transition')).toBe(true);
346 });
347
348 test('passes transform transition', () => {
349 const f = checkElementMotion('div', mockStyle({ transitionProperty: 'transform' }));
350 expect(f.filter(r => r.id === 'layout-transition')).toHaveLength(0);
351 });
352
353 test('passes opacity transition', () => {
354 const f = checkElementMotion('div', mockStyle({ transitionProperty: 'opacity' }));
355 expect(f.filter(r => r.id === 'layout-transition')).toHaveLength(0);
356 });
357
358 test('skips transition: all', () => {
359 const f = checkElementMotion('div', mockStyle({ transitionProperty: 'all' }));
360 expect(f.filter(r => r.id === 'layout-transition')).toHaveLength(0);
361 });
362
363 test('skips safe tags', () => {
364 const f = checkElementMotion('button', mockStyle({
365 animationName: 'bounce', transitionProperty: 'width',
366 }));
367 expect(f).toHaveLength(0);
368 });
369});
370
371describe('detectText — motion', () => {
372 test('detects animate-bounce Tailwind class', () => {
373 const f = detectText('<div class="animate-bounce">loading</div>', 'test.html');
374 expect(f.some(r => r.antipattern === 'bounce-easing')).toBe(true);
375 });
376
377 test('detects animation: bounce CSS', () => {
378 const f = detectText('.icon { animation: bounce 1s infinite; }', 'test.css');
379 expect(f.some(r => r.antipattern === 'bounce-easing')).toBe(true);
380 });
381
382 test('detects animation-name: elastic', () => {
383 const f = detectText('.card { animation-name: elastic; }', 'test.css');
384 expect(f.some(r => r.antipattern === 'bounce-easing')).toBe(true);
385 });
386
387 test('detects overshoot cubic-bezier', () => {
388 const f = detectText('.btn { transition: transform 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55); }', 'test.css');
389 expect(f.some(r => r.antipattern === 'bounce-easing')).toBe(true);
390 });
391
392 test('passes standard cubic-bezier', () => {
393 const f = detectText('.btn { transition: transform 0.4s cubic-bezier(0.25, 1, 0.5, 1); }', 'test.css');
394 expect(f.filter(r => r.antipattern === 'bounce-easing')).toHaveLength(0);
395 });
396
397 test('detects transition: width', () => {
398 const f = detectText('.sidebar { transition: width 0.3s ease; }', 'test.css');
399 expect(f.some(r => r.antipattern === 'layout-transition')).toBe(true);
400 });
401
402 test('detects transition: height', () => {
403 const f = detectText('.panel { transition: height 0.4s ease-out; }', 'test.css');
404 expect(f.some(r => r.antipattern === 'layout-transition')).toBe(true);
405 });
406
407 test('detects transition: max-height', () => {
408 const f = detectText('.accordion { transition: max-height 0.5s ease; }', 'test.css');
409 expect(f.some(r => r.antipattern === 'layout-transition')).toBe(true);
410 });
411
412 test('detects transition-property: width', () => {
413 const f = detectText('.box { transition-property: width; transition-duration: 0.3s; }', 'test.css');
414 expect(f.some(r => r.antipattern === 'layout-transition')).toBe(true);
415 });
416
417 test('skips transition: all', () => {
418 const f = detectText('.card { transition: all 0.3s ease; }', 'test.css');
419 expect(f.filter(r => r.antipattern === 'layout-transition')).toHaveLength(0);
420 });
421
422 test('skips transition: transform', () => {
423 const f = detectText('.card { transition: transform 0.3s ease; }', 'test.css');
424 expect(f.filter(r => r.antipattern === 'layout-transition')).toHaveLength(0);
425 });
426
427 test('skips transition: opacity', () => {
428 const f = detectText('.btn { transition: opacity 0.2s ease; }', 'test.css');
429 expect(f.filter(r => r.antipattern === 'layout-transition')).toHaveLength(0);
430 });
431});
432
433// ---------------------------------------------------------------------------
434// Dark glow anti-pattern
435// ---------------------------------------------------------------------------
436
437describe('checkElementGlow', () => {
438 function mockStyle(overrides) {
439 return { boxShadow: 'none', backgroundColor: '', ...overrides };
440 }
441
442 // Dark bg = luminance < 0.1 (e.g. #111827 = gray-900)
443 const darkBg = { r: 17, g: 24, b: 39 }; // #111827
444 const lightBg = { r: 249, g: 250, b: 251 }; // #f9fafb
445 const mediumBg = { r: 107, g: 114, b: 128 }; // #6b7280
446
447 test('detects blue glow on dark background', () => {
448 const f = checkElementGlow('div', mockStyle({
449 boxShadow: 'rgba(59, 130, 246, 0.4) 0px 0px 20px 0px',
450 }), darkBg);
451 expect(f.some(r => r.id === 'dark-glow')).toBe(true);
452 });
453
454 test('detects purple glow on dark background', () => {
455 const f = checkElementGlow('div', mockStyle({
456 boxShadow: 'rgba(139, 92, 246, 0.35) 0px 0px 25px 0px',
457 }), darkBg);
458 expect(f.some(r => r.id === 'dark-glow')).toBe(true);
459 });
460
461 test('detects glow in multi-shadow', () => {
462 const f = checkElementGlow('div', mockStyle({
463 boxShadow: 'rgba(0, 0, 0, 0.3) 0px 4px 6px 0px, rgba(168, 85, 247, 0.3) 0px 0px 30px 0px',
464 }), darkBg);
465 expect(f.some(r => r.id === 'dark-glow')).toBe(true);
466 });
467
468 test('passes gray shadow on dark background', () => {
469 const f = checkElementGlow('div', mockStyle({
470 boxShadow: 'rgba(0, 0, 0, 0.4) 0px 4px 12px 0px',
471 }), darkBg);
472 expect(f.filter(r => r.id === 'dark-glow')).toHaveLength(0);
473 });
474
475 test('passes colored shadow on light background', () => {
476 const f = checkElementGlow('div', mockStyle({
477 boxShadow: 'rgba(59, 130, 246, 0.4) 0px 0px 20px 0px',
478 }), lightBg);
479 expect(f.filter(r => r.id === 'dark-glow')).toHaveLength(0);
480 });
481
482 test('passes colored shadow on medium gray background', () => {
483 const f = checkElementGlow('div', mockStyle({
484 boxShadow: 'rgba(59, 130, 246, 0.5) 0px 0px 20px 0px',
485 }), mediumBg);
486 expect(f.filter(r => r.id === 'dark-glow')).toHaveLength(0);
487 });
488
489 test('passes focus ring (spread only, no blur)', () => {
490 const f = checkElementGlow('div', mockStyle({
491 boxShadow: 'rgba(59, 130, 246, 0.5) 0px 0px 0px 3px',
492 }), darkBg);
493 expect(f.filter(r => r.id === 'dark-glow')).toHaveLength(0);
494 });
495
496 test('passes subtle shadow (blur < 5px)', () => {
497 const f = checkElementGlow('div', mockStyle({
498 boxShadow: 'rgba(59, 130, 246, 0.2) 0px 1px 3px 0px',
499 }), darkBg);
500 expect(f.filter(r => r.id === 'dark-glow')).toHaveLength(0);
501 });
502
503 test('passes no shadow', () => {
504 const f = checkElementGlow('div', mockStyle({ boxShadow: 'none' }), darkBg);
505 expect(f.filter(r => r.id === 'dark-glow')).toHaveLength(0);
506 });
507
508 test('detects glow on buttons (not skipped by safe tags)', () => {
509 const f = checkElementGlow('button', mockStyle({
510 boxShadow: 'rgba(59, 130, 246, 0.4) 0px 0px 20px 0px',
511 }), darkBg);
512 expect(f.some(r => r.id === 'dark-glow')).toBe(true);
513 });
514});
515
516describe('detectText — dark glow', () => {
517 test('detects colored box-shadow glow on dark background', () => {
518 const html = '<!DOCTYPE html><html><body style="background: #111827;"><div style="box-shadow: 0 0 20px rgba(59, 130, 246, 0.4);">glow</div></body></html>';
519 const f = detectText(html, 'test.html');
520 expect(f.some(r => r.antipattern === 'dark-glow')).toBe(true);
521 });
522
523 test('skips gray shadow on dark background', () => {
524 const html = '<!DOCTYPE html><html><body style="background: #111827;"><div style="box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);">shadow</div></body></html>';
525 const f = detectText(html, 'test.html');
526 expect(f.filter(r => r.antipattern === 'dark-glow')).toHaveLength(0);
527 });
528
529 test('skips colored shadow on light page', () => {
530 const html = '<!DOCTYPE html><html><body style="background: #f9fafb;"><div style="box-shadow: 0 0 20px rgba(59, 130, 246, 0.4);">glow</div></body></html>';
531 const f = detectText(html, 'test.html');
532 expect(f.filter(r => r.antipattern === 'dark-glow')).toHaveLength(0);
533 });
534});
535
536// ---------------------------------------------------------------------------
537// Static HTML/CSS engine
538// ---------------------------------------------------------------------------
539
540describe('detectHtml — static HTML/CSS engine', () => {
541 test('inlines local linked stylesheets', async () => {
542 const f = await detectHtml(path.join(FIXTURES, 'linked-stylesheet.html'));
543 expect(findingIds(f)).toContain('side-tab');
544 });
545
546 test('flattens @layer, resolves CSS variables and fallbacks, and skips unsupported selectors', async () => {
547 await withStaticFixture({
548 'index.html': `<!DOCTYPE html>
549 <html>
550 <head>
551 <style>
552 @layer components {
553 :root { --accent: #3b82f6; --fallback-accent: var(--missing-accent, #a855f7); }
554 .layer-side { border-left: 5px solid var(--accent); border-radius: 8px; }
555 .layer-top { border-top: 4px solid var(--fallback-accent); border-radius: 8px; }
556 .ignored:future-only(foo) { border-left: 20px solid #ef4444; }
557 }
558 </style>
559 </head>
560 <body>
561 <div class="layer-side">Layer variable side tab</div>
562 <div class="layer-top">Fallback variable top accent</div>
563 </body>
564 </html>`,
565 }, async ({ file }) => {
566 const profile = [];
567 const f = await detectHtml(file, { profile });
568 const ids = findingIds(f);
569 expect(ids).toContain('side-tab');
570 expect(ids).toContain('border-accent-on-rounded');
571 expect(profile.some(e => e.engine === 'static-html' && e.ruleId === 'unsupported-selector')).toBe(true);
572 });
573 });
574
575 test('honors specificity, source order, !important, and inline style precedence', async () => {
576 await withStaticFixture({
577 'index.html': `<!DOCTYPE html>
578 <html>
579 <head>
580 <style>
581 .specificity-pass { border-left: 5px solid #3b82f6; border-radius: 8px; }
582 div.specificity-pass { border-left-color: #d1d5db; }
583 .source-order-flag { border-left: 5px solid #d1d5db; border-radius: 8px; }
584 .source-order-flag { border-left-color: #ef4444; }
585 .important-pass { border-left: 5px solid #d1d5db !important; border-radius: 8px; }
586 .important-pass { border-left-color: #3b82f6; }
587 </style>
588 </head>
589 <body>
590 <div class="specificity-pass">Specificity neutral pass</div>
591 <div class="source-order-flag">Source order chromatic flag</div>
592 <div class="important-pass">Important neutral pass</div>
593 <div style="border-left: 5px solid #06b6d4; border-radius: 8px;">Inline chromatic flag</div>
594 </body>
595 </html>`,
596 }, async ({ file }) => {
597 const f = await detectHtml(file);
598 expect(findingIds(f).filter(id => id === 'side-tab')).toHaveLength(2);
599 });
600 });
601
602 test('expands background, border, font, transition, and animation shorthands', async () => {
603 await withStaticFixture({
604 'index.html': `<!DOCTYPE html>
605 <html>
606 <head>
607 <style>
608 .font-short {
609 font: italic 700 11px/1.05 Arial, sans-serif;
610 }
611 .background-short {
612 background: #000;
613 color: #111;
614 font-size: 16px;
615 }
616 .border-short {
617 border: 1px solid #d1d5db;
618 border-left: 5px solid #3b82f6;
619 border-radius: 8px;
620 }
621 .motion-short {
622 transition: width 250ms cubic-bezier(.68,-.55,.27,1.55);
623 animation: bounce 1s cubic-bezier(.68,-.55,.27,1.55) infinite;
624 }
625 </style>
626 </head>
627 <body>
628 <p class="font-short">This tiny paragraph is long enough to trigger both the static font shorthand size and line-height checks.</p>
629 <button class="background-short">Low contrast button text</button>
630 <div class="border-short">Border shorthand side tab</div>
631 <div class="motion-short">Motion shorthand easing</div>
632 </body>
633 </html>`,
634 }, async ({ file }) => {
635 const ids = findingIds(await detectHtml(file));
636 expect(ids).toContain('tiny-text');
637 expect(ids).toContain('tight-leading');
638 expect(ids).toContain('low-contrast');
639 expect(ids).toContain('side-tab');
640 expect(ids).toContain('bounce-easing');
641 expect(ids).toContain('layout-transition');
642 });
643 });
644});
645
646
647// ---------------------------------------------------------------------------
648// ANTIPATTERNS registry
649// ---------------------------------------------------------------------------
650
651describe('ANTIPATTERNS registry', () => {
652 test('has at least 5 entries', () => {
653 expect(ANTIPATTERNS.length).toBeGreaterThanOrEqual(5);
654 });
655
656 test('each entry has required fields', () => {
657 for (const ap of ANTIPATTERNS) {
658 expect(ap.id).toBeTypeOf('string');
659 expect(ap.name).toBeTypeOf('string');
660 expect(ap.description).toBeTypeOf('string');
661 }
662 });
663});
664
665// ---------------------------------------------------------------------------
666// walkDir
667// ---------------------------------------------------------------------------
668
669describe('walkDir', () => {
670 test('finds scannable files', () => {
671 const files = walkDir(FIXTURES);
672 expect(files.length).toBeGreaterThanOrEqual(3);
673 expect(files.every(f => SCANNABLE_EXTENSIONS.has(path.extname(f)))).toBe(true);
674 });
675
676 test('returns empty for nonexistent dir', () => {
677 expect(walkDir('/nonexistent/path/12345')).toHaveLength(0);
678 });
679});
680
681// ---------------------------------------------------------------------------
682// CLI integration
683// ---------------------------------------------------------------------------
684
685describe('CLI', () => {
686 function run(...args) {
687 const result = spawnSync('node', [SCRIPT, ...args], { encoding: 'utf-8', timeout: 15000 });
688 return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status };
689 }
690
691 test('--help exits 0', () => {
692 const { stdout, code } = run('--help');
693 expect(code).toBe(0);
694 expect(stdout).toContain('Usage:');
695 });
696
697 test('detect subcommand is not treated as a scan target', () => {
698 const { stderr, code } = run('detect', '--json', path.join(FIXTURES, 'should-pass.html'));
699 expect(code).toBe(0);
700 expect(stderr).not.toContain('cannot access detect');
701 });
702
703 test('should-pass exits 0', () => {
704 const { code } = run(path.join(FIXTURES, 'should-pass.html'));
705 expect(code).toBe(0);
706 });
707
708 test('should-flag exits 2 with findings', () => {
709 const { code, stderr } = run(path.join(FIXTURES, 'should-flag.html'));
710 expect(code).toBe(2);
711 expect(stderr).toContain('side-tab');
712 });
713
714 test('--json outputs valid JSON', () => {
715 const { stdout, code } = run('--json', path.join(FIXTURES, 'should-flag.html'));
716 expect(code).toBe(2);
717 const parsed = JSON.parse(stdout.trim());
718 expect(parsed).toBeArray();
719 expect(parsed.length).toBeGreaterThan(0);
720 });
721
722 test('-json alias outputs valid JSON', () => {
723 const { stdout, stderr, code } = run('-json', path.join(FIXTURES, 'should-flag.html'));
724 expect(code).toBe(2);
725 expect(stderr).not.toContain('cannot access -json');
726 const parsed = JSON.parse(stdout.trim());
727 expect(parsed).toBeArray();
728 expect(parsed.length).toBeGreaterThan(0);
729 });
730
731 test('--json on clean file outputs empty array', () => {
732 const { stdout, code } = run('--json', path.join(FIXTURES, 'should-pass.html'));
733 expect(code).toBe(0);
734 expect(JSON.parse(stdout.trim())).toEqual([]);
735 });
736
737 test('--fast mode works', () => {
738 const { code } = run('--fast', path.join(FIXTURES, 'should-flag.html'));
739 expect(code).toBe(2);
740 });
741
742 test('linked stylesheet detected (static HTML/CSS default)', () => {
743 const { code, stderr } = run(path.join(FIXTURES, 'linked-stylesheet.html'));
744 expect(code).toBe(2);
745 expect(stderr).toContain('side-tab');
746 });
747
748 test('warns on nonexistent path', () => {
749 const { stderr } = run('/nonexistent/file/xyz.html');
750 expect(stderr).toContain('Warning');
751 });
752});
753
754// ---------------------------------------------------------------------------
755// Detector benchmark smoke test
756// ---------------------------------------------------------------------------
757
758describe('benchmark-detector', () => {
759 test('--quick --json emits timing schema', () => {
760 const result = spawnSync('node', [BENCH_SCRIPT, '--quick', '--json'], {
761 encoding: 'utf-8',
762 timeout: 30000,
763 });
764 expect(result.status).toBe(0);
765 const parsed = JSON.parse(result.stdout.trim());
766 expect(parsed.version).toBe(1);
767 expect(parsed.quick).toBe(true);
768 expect(parsed.browser).toBe(false);
769 expect(parsed.cases).toBeArray();
770 expect(parsed.cases.length).toBeGreaterThan(0);
771 expect(parsed.summary).toBeArray();
772 expect(parsed.summary.length).toBeGreaterThan(0);
773
774 const okCase = parsed.cases.find(c => c.status === 'ok');
775 expect(okCase).toBeTruthy();
776 expect(okCase).toHaveProperty('totalMs');
777 expect(okCase).toHaveProperty('findings');
778 expect(okCase.profile).toBeArray();
779
780 const row = parsed.summary[0];
781 for (const key of ['engine', 'phase', 'ruleId', 'target', 'calls', 'totalMs', 'avgMs', 'p50', 'p95', 'findings']) {
782 expect(row).toHaveProperty(key);
783 }
784 });
785});
786
787// ---------------------------------------------------------------------------
788// Tier 1: Vue/Svelte <style> block extraction
789// ---------------------------------------------------------------------------
790
791describe('extractStyleBlocks', () => {
792 test('extracts single <style> block from Vue SFC', () => {
793 const vue = `<template><div>hi</div></template>
794<style scoped>
795.card { border-left: 4px solid blue; }
796</style>`;
797 const blocks = extractStyleBlocks(vue, '.vue');
798 expect(blocks.length).toBe(1);
799 expect(blocks[0].content).toContain('border-left: 4px solid blue');
800 expect(blocks[0].startLine).toBeGreaterThan(1);
801 });
802
803 test('extracts multiple <style> blocks', () => {
804 const vue = `<template><div>hi</div></template>
805<style>
806.a { color: red; }
807</style>
808<style scoped>
809.b { color: blue; }
810</style>`;
811 const blocks = extractStyleBlocks(vue, '.vue');
812 expect(blocks.length).toBe(2);
813 });
814
815 test('extracts <style> from Svelte', () => {
816 const svelte = `<div>hi</div>
817<style>
818.sidebar { border-right: 4px solid #8b5cf6; }
819</style>`;
820 const blocks = extractStyleBlocks(svelte, '.svelte');
821 expect(blocks.length).toBe(1);
822 expect(blocks[0].content).toContain('border-right: 4px solid');
823 });
824
825 test('returns empty for non-Vue/Svelte files', () => {
826 const jsx = 'export function Card() { return <div>hi</div>; }';
827 expect(extractStyleBlocks(jsx, '.jsx')).toHaveLength(0);
828 expect(extractStyleBlocks(jsx, '.tsx')).toHaveLength(0);
829 });
830
831 test('returns empty when no <style> blocks exist', () => {
832 const vue = '<template><div>hi</div></template><script>export default {}</script>';
833 expect(extractStyleBlocks(vue, '.vue')).toHaveLength(0);
834 });
835});
836
837// ---------------------------------------------------------------------------
838// Tier 1: CSS-in-JS extraction
839// ---------------------------------------------------------------------------
840
841describe('extractCSSinJS', () => {
842 test('extracts styled-components template literal', () => {
843 const tsx = "const Card = styled.div`\n border-left: 4px solid blue;\n padding: 16px;\n`;";
844 const blocks = extractCSSinJS(tsx, '.tsx');
845 expect(blocks.length).toBeGreaterThanOrEqual(1);
846 expect(blocks.some(b => b.content.includes('border-left: 4px solid'))).toBe(true);
847 });
848
849 test('extracts styled(Component) template literal', () => {
850 const tsx = "const Box = styled(BaseBox)`\n border-right: 5px solid #8b5cf6;\n`;";
851 const blocks = extractCSSinJS(tsx, '.tsx');
852 expect(blocks.length).toBeGreaterThanOrEqual(1);
853 expect(blocks.some(b => b.content.includes('border-right: 5px solid'))).toBe(true);
854 });
855
856 test('extracts emotion css template literal', () => {
857 const tsx = "const style = css`\n animation: bounce 1s infinite;\n`;";
858 const blocks = extractCSSinJS(tsx, '.tsx');
859 expect(blocks.length).toBeGreaterThanOrEqual(1);
860 expect(blocks.some(b => b.content.includes('animation: bounce'))).toBe(true);
861 });
862
863 test('returns empty for non-JS files', () => {
864 expect(extractCSSinJS('.card { color: red; }', '.css')).toHaveLength(0);
865 expect(extractCSSinJS('<div>hi</div>', '.html')).toHaveLength(0);
866 });
867
868 test('returns empty when no CSS-in-JS patterns exist', () => {
869 const tsx = "function Card() { return <div className='p-4'>hi</div>; }";
870 expect(extractCSSinJS(tsx, '.tsx')).toHaveLength(0);
871 });
872});
873
874// ---------------------------------------------------------------------------
875// Tier 1: detectText on Vue/Svelte files (style blocks + template classes)
876// ---------------------------------------------------------------------------
877
878describe('detectText -- Vue SFC', () => {
879 test('detects side-tab in <style> block', () => {
880 const vue = `<template><div class="card">hi</div></template>
881<style scoped>
882.card { border-left: 4px solid #3b82f6; border-radius: 12px; }
883</style>`;
884 const f = detectText(vue, 'Card.vue');
885 expect(f.some(r => r.antipattern === 'side-tab')).toBe(true);
886 });
887
888 test('detects overused font in <style> block', () => {
889 const vue = `<template><div>hi</div></template>
890<style>
891body { font-family: 'Inter', sans-serif; }
892</style>`;
893 const f = detectText(vue, 'App.vue');
894 expect(f.some(r => r.antipattern === 'overused-font')).toBe(true);
895 });
896
897 test('detects bounce animation in <style> block', () => {
898 const vue = `<template><div>hi</div></template>
899<style>
900.item { animation: bounce 1s infinite; }
901</style>`;
902 const f = detectText(vue, 'Card.vue');
903 expect(f.some(r => r.antipattern === 'bounce-easing')).toBe(true);
904 });
905
906 test('detects gradient-text in <style> block', () => {
907 const vue = `<template><div>hi</div></template>
908<style>
909h1 { background: linear-gradient(to right, purple, cyan); -webkit-background-clip: text; background-clip: text; }
910</style>`;
911 const f = detectText(vue, 'Hero.vue');
912 expect(f.some(r => r.antipattern === 'gradient-text')).toBe(true);
913 });
914
915 test('detects Tailwind anti-patterns in <template>', () => {
916 const vue = `<template>
917 <div class="border-l-4 border-blue-500 rounded-lg">card</div>
918</template>`;
919 const f = detectText(vue, 'Card.vue');
920 expect(f.some(r => r.antipattern === 'side-tab')).toBe(true);
921 });
922});
923
924describe('detectText -- Svelte', () => {
925 test('detects side-tab in <style> block', () => {
926 const svelte = `<div>hi</div>
927<style>
928.sidebar { border-right: 4px solid #8b5cf6; border-radius: 16px; }
929</style>`;
930 const f = detectText(svelte, 'Sidebar.svelte');
931 expect(f.some(r => r.antipattern === 'side-tab')).toBe(true);
932 });
933
934 test('detects overused font in <style> block', () => {
935 const svelte = `<div>hi</div>
936<style>
937.app { font-family: 'Roboto', sans-serif; }
938</style>`;
939 const f = detectText(svelte, 'App.svelte');
940 expect(f.some(r => r.antipattern === 'overused-font')).toBe(true);
941 });
942
943 test('detects layout transition in <style> block', () => {
944 const svelte = `<div>hi</div>
945<style>
946.panel { transition: height 0.4s ease; }
947</style>`;
948 const f = detectText(svelte, 'Panel.svelte');
949 expect(f.some(r => r.antipattern === 'layout-transition')).toBe(true);
950 });
951});
952
953// ---------------------------------------------------------------------------
954// Tier 1: detectText on CSS-in-JS files
955// ---------------------------------------------------------------------------
956
957describe('detectText -- CSS-in-JS', () => {
958 test('detects side-tab in styled-components', () => {
959 const tsx = "const Card = styled.div`\n border-left: 4px solid #3b82f6;\n border-radius: 12px;\n`;";
960 const f = detectText(tsx, 'Card.tsx');
961 expect(f.some(r => r.antipattern === 'side-tab')).toBe(true);
962 });
963
964 test('detects bounce in emotion css', () => {
965 const tsx = "const style = css`\n animation: bounce 1s infinite;\n`;";
966 const f = detectText(tsx, 'anim.ts');
967 expect(f.some(r => r.antipattern === 'bounce-easing')).toBe(true);
968 });
969
970 test('detects overused font in styled-components', () => {
971 const tsx = "const Wrapper = styled.main`\n font-family: 'Inter', sans-serif;\n`;";
972 const f = detectText(tsx, 'Layout.tsx');
973 expect(f.some(r => r.antipattern === 'overused-font')).toBe(true);
974 });
975
976 test('detects gradient-text in styled-components', () => {
977 const tsx = "const Title = styled.h1`\n background: linear-gradient(to right, purple, cyan);\n -webkit-background-clip: text;\n background-clip: text;\n`;";
978 const f = detectText(tsx, 'Hero.tsx');
979 expect(f.some(r => r.antipattern === 'gradient-text')).toBe(true);
980 });
981
982 test('detects pure-black-white in styled-components', () => {
983 const tsx = "const Dark = styled.section`\n background-color: #000000;\n`;";
984 const f = detectText(tsx, 'Dark.tsx');
985 expect(f.some(r => r.antipattern === 'pure-black-white')).toBe(true);
986 });
987
988 test('does not false-positive on clean CSS-in-JS', () => {
989 const tsx = "const Card = styled.div`\n border-radius: 12px;\n padding: 24px;\n`;";
990 const f = detectText(tsx, 'Card.tsx');
991 expect(f.filter(r => r.antipattern === 'side-tab')).toHaveLength(0);
992 });
993});
994
995// ---------------------------------------------------------------------------
996// Tier 1: Fixture file integration tests (CLI)
997// ---------------------------------------------------------------------------
998
999describe('CLI -- framework fixtures', () => {
1000 function run(...args) {
1001 const result = spawnSync('node', [SCRIPT, ...args], { encoding: 'utf-8', timeout: 15000 });
1002 return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status };
1003 }
1004
1005 test('jsx-should-flag catches anti-patterns', () => {
1006 const { code, stderr } = run(path.join(FIXTURES, 'jsx-should-flag.jsx'));
1007 expect(code).toBe(2);
1008 expect(stderr).toContain('side-tab');
1009 });
1010
1011 test('jsx-should-pass is clean', () => {
1012 const { code } = run(path.join(FIXTURES, 'jsx-should-pass.jsx'));
1013 expect(code).toBe(0);
1014 });
1015
1016 test('vue-should-flag catches anti-patterns', () => {
1017 const { code, stderr } = run(path.join(FIXTURES, 'vue-should-flag.vue'));
1018 expect(code).toBe(2);
1019 expect(stderr).toContain('side-tab');
1020 });
1021
1022 test('vue-should-pass is clean', () => {
1023 const { code } = run(path.join(FIXTURES, 'vue-should-pass.vue'));
1024 expect(code).toBe(0);
1025 });
1026
1027 test('svelte-should-flag catches anti-patterns', () => {
1028 const { code, stderr } = run(path.join(FIXTURES, 'svelte-should-flag.svelte'));
1029 expect(code).toBe(2);
1030 expect(stderr).toContain('side-tab');
1031 });
1032
1033 test('svelte-should-pass is clean', () => {
1034 const { code } = run(path.join(FIXTURES, 'svelte-should-pass.svelte'));
1035 expect(code).toBe(0);
1036 });
1037
1038 test('cssinjs-should-flag catches anti-patterns', () => {
1039 const { code, stderr } = run(path.join(FIXTURES, 'cssinjs-should-flag.tsx'));
1040 expect(code).toBe(2);
1041 expect(stderr).toContain('side-tab');
1042 });
1043
1044 test('cssinjs-should-pass is clean', () => {
1045 const { code } = run(path.join(FIXTURES, 'cssinjs-should-pass.tsx'));
1046 expect(code).toBe(0);
1047 });
1048});
1049
1050// ---------------------------------------------------------------------------
1051// Realistic Next.js project fixtures
1052// ---------------------------------------------------------------------------
1053
1054describe('CLI -- Next.js + Tailwind project', () => {
1055 const dir = path.join(FIXTURES, 'framework-next-tailwind');
1056 let stderr;
1057
1058 function run(...args) {
1059 const result = spawnSync('node', [SCRIPT, ...args], { encoding: 'utf-8', timeout: 15000 });
1060 return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status };
1061 }
1062
1063 test('finds all expected anti-pattern types', () => {
1064 const result = run(dir);
1065 stderr = result.stderr;
1066 expect(result.code).toBe(2);
1067 for (const ap of ['side-tab', 'gradient-text', 'ai-color-palette', 'overused-font', 'bounce-easing', 'pure-black-white']) {
1068 expect(stderr).toContain(ap);
1069 }
1070 });
1071
1072 test('FeatureCard: side-tab + ai-color-palette + bounce-easing', () => {
1073 const { stderr } = run(path.join(dir, 'components', 'FeatureCard.tsx'));
1074 expect(stderr).toContain('side-tab');
1075 expect(stderr).toContain('border-l-4');
1076 expect(stderr).toContain('ai-color-palette');
1077 expect(stderr).toContain('text-purple-600');
1078 expect(stderr).toContain('bounce-easing');
1079 expect(stderr).toContain('animate-bounce');
1080 });
1081
1082 test('PricingCard: pure-black-white + gradient-text + ai-color-palette', () => {
1083 const { stderr } = run(path.join(dir, 'components', 'PricingCard.tsx'));
1084 expect(stderr).toContain('pure-black-white');
1085 expect(stderr).toContain('bg-black');
1086 expect(stderr).toContain('gradient-text');
1087 expect(stderr).toContain('bg-clip-text');
1088 expect(stderr).toContain('ai-color-palette');
1089 });
1090
1091 test('globals.css: overused Inter font', () => {
1092 const { stderr } = run(path.join(dir, 'app', 'globals.css'));
1093 expect(stderr).toContain('overused-font');
1094 expect(stderr).toContain('Inter');
1095 });
1096
1097 test('page.tsx: gradient-text + ai-color-palette', () => {
1098 const { stderr } = run(path.join(dir, 'app', 'page.tsx'));
1099 expect(stderr).toContain('gradient-text');
1100 expect(stderr).toContain('ai-color-palette');
1101 });
1102
1103 test('directory scan shows import context for components', () => {
1104 const { stderr } = run(dir);
1105 expect(stderr).toContain('imported by page.tsx');
1106 });
1107
1108 test('--json produces clean JSON without framework message', () => {
1109 const { stdout, code } = run('--json', dir);
1110 expect(code).toBe(2);
1111 const parsed = JSON.parse(stdout.trim());
1112 expect(parsed).toBeArray();
1113 expect(parsed.length).toBeGreaterThanOrEqual(6);
1114 });
1115});
1116
1117describe('CLI -- Next.js + CSS Modules project', () => {
1118 function run(...args) {
1119 const result = spawnSync('node', [SCRIPT, ...args], { encoding: 'utf-8', timeout: 15000 });
1120 return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status };
1121 }
1122
1123 const dir = path.join(FIXTURES, 'framework-next-modules');
1124
1125 test('finds all expected anti-pattern types', () => {
1126 const { code, stderr } = run(dir);
1127 expect(code).toBe(2);
1128 for (const ap of ['side-tab', 'overused-font', 'pure-black-white', 'layout-transition', 'gradient-text']) {
1129 expect(stderr).toContain(ap);
1130 }
1131 });
1132
1133 test('StatsCard.module.css: side-tab + overused-font + layout-transition', () => {
1134 const { stderr } = run(path.join(dir, 'components', 'StatsCard.module.css'));
1135 expect(stderr).toContain('side-tab');
1136 expect(stderr).toContain('border-left: 4px solid #6366f1');
1137 expect(stderr).toContain('overused-font');
1138 expect(stderr).toContain('Inter');
1139 expect(stderr).toContain('layout-transition');
1140 expect(stderr).toContain('transition: width');
1141 });
1142
1143 test('Sidebar.module.css: side-tab border accent', () => {
1144 const { stderr } = run(path.join(dir, 'components', 'Sidebar.module.css'));
1145 expect(stderr).toContain('side-tab');
1146 expect(stderr).toContain('border-right: 3px solid');
1147 });
1148
1149 test('globals.css: overused Roboto + pure-black-white', () => {
1150 const { stderr } = run(path.join(dir, 'app', 'globals.css'));
1151 expect(stderr).toContain('overused-font');
1152 expect(stderr).toContain('Roboto');
1153 expect(stderr).toContain('pure-black-white');
1154 expect(stderr).toContain('#000000');
1155 });
1156
1157 test('page.module.css: gradient-text across lines', () => {
1158 const { stderr } = run(path.join(dir, 'app', 'page.module.css'));
1159 expect(stderr).toContain('gradient-text');
1160 expect(stderr).toContain('background-clip: text');
1161 });
1162
1163 test('directory scan shows import context for CSS modules', () => {
1164 const { stderr } = run(dir);
1165 expect(stderr).toContain('imported by StatsCard.tsx');
1166 expect(stderr).toContain('imported by Sidebar.tsx');
1167 expect(stderr).toContain('imported by layout.tsx');
1168 });
1169});
1170
1171describe('CLI -- Next.js + CSS-in-JS (styled-components) project', () => {
1172 function run(...args) {
1173 const result = spawnSync('node', [SCRIPT, ...args], { encoding: 'utf-8', timeout: 15000 });
1174 return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status };
1175 }
1176
1177 const dir = path.join(FIXTURES, 'framework-next-cssinjs');
1178
1179 test('finds all expected anti-pattern types', () => {
1180 const { code, stderr } = run(dir);
1181 expect(code).toBe(2);
1182 for (const ap of ['side-tab', 'gradient-text', 'overused-font', 'bounce-easing', 'pure-black-white', 'layout-transition']) {
1183 expect(stderr).toContain(ap);
1184 }
1185 });
1186
1187 test('FeatureGrid.tsx: side-tab + bounce-easing + layout-transition', () => {
1188 const { stderr } = run(path.join(dir, 'components', 'FeatureGrid.tsx'));
1189 expect(stderr).toContain('side-tab');
1190 expect(stderr).toContain('border-left: 4px solid');
1191 expect(stderr).toContain('bounce-easing');
1192 expect(stderr).toContain('animation: bounce');
1193 expect(stderr).toContain('layout-transition');
1194 expect(stderr).toContain('transition: width');
1195 });
1196
1197 test('Hero.tsx: gradient-text + overused Montserrat font', () => {
1198 const { stderr } = run(path.join(dir, 'components', 'Hero.tsx'));
1199 expect(stderr).toContain('gradient-text');
1200 expect(stderr).toContain('background-clip: text');
1201 expect(stderr).toContain('overused-font');
1202 expect(stderr).toContain('Montserrat');
1203 });
1204
1205 test('GlobalStyle.tsx: overused Inter + pure-black-white', () => {
1206 const { stderr } = run(path.join(dir, 'components', 'GlobalStyle.tsx'));
1207 expect(stderr).toContain('overused-font');
1208 expect(stderr).toContain('Inter');
1209 expect(stderr).toContain('pure-black-white');
1210 expect(stderr).toContain('#000000');
1211 });
1212
1213 test('Testimonials.tsx: side-tab + gradient-text in styled blockquote', () => {
1214 const { stderr } = run(path.join(dir, 'components', 'Testimonials.tsx'));
1215 expect(stderr).toContain('side-tab');
1216 expect(stderr).toContain('border-left: 4px solid');
1217 expect(stderr).toContain('gradient-text');
1218 });
1219
1220 test('directory scan shows import context for components', () => {
1221 const { stderr } = run(dir);
1222 expect(stderr).toContain('imported by index.tsx');
1223 expect(stderr).toContain('imported by _app.tsx');
1224 });
1225
1226 test('--json produces clean JSON without framework message', () => {
1227 const { stdout, code } = run('--json', dir);
1228 expect(code).toBe(2);
1229 const parsed = JSON.parse(stdout.trim());
1230 expect(parsed).toBeArray();
1231 expect(parsed.length).toBeGreaterThanOrEqual(6);
1232 // Verify importedBy is present in JSON
1233 const featureGridFindings = parsed.filter(f => f.file?.includes('FeatureGrid'));
1234 expect(featureGridFindings.length).toBeGreaterThan(0);
1235 expect(featureGridFindings[0].importedBy).toContain('index.tsx');
1236 });
1237});
1238
1239// ---------------------------------------------------------------------------
1240// Tier 2: Import graph
1241// ---------------------------------------------------------------------------
1242
1243describe('buildImportGraph', () => {
1244 const MF = path.join(FIXTURES, 'multifile');
1245
1246 test('resolves ES import from tsx to tsx', () => {
1247 const graph = buildImportGraph([
1248 path.join(MF, 'App.tsx'),
1249 path.join(MF, 'Card.tsx'),
1250 path.join(MF, 'styles.css'),
1251 ]);
1252 const appImports = graph.get(path.join(MF, 'App.tsx'));
1253 expect(appImports).toBeDefined();
1254 expect(appImports.has(path.join(MF, 'Card.tsx'))).toBe(true);
1255 expect(appImports.has(path.join(MF, 'styles.css'))).toBe(true);
1256 });
1257
1258 test('resolves extensionless imports', () => {
1259 const graph = buildImportGraph([
1260 path.join(MF, 'App.tsx'),
1261 path.join(MF, 'Card.tsx'),
1262 ]);
1263 const appImports = graph.get(path.join(MF, 'App.tsx'));
1264 expect(appImports.has(path.join(MF, 'Card.tsx'))).toBe(true);
1265 });
1266
1267 test('resolves CSS @import', () => {
1268 const graph = buildImportGraph([
1269 path.join(MF, 'theme.scss'),
1270 path.join(MF, 'variables.scss'),
1271 ]);
1272 const themeImports = graph.get(path.join(MF, 'theme.scss'));
1273 expect(themeImports).toBeDefined();
1274 expect(themeImports.has(path.join(MF, 'variables.scss'))).toBe(true);
1275 });
1276
1277 test('ignores bare/node_modules imports', () => {
1278 const graph = buildImportGraph([
1279 path.join(MF, 'App.tsx'),
1280 ]);
1281 const appImports = graph.get(path.join(MF, 'App.tsx'));
1282 // Should not contain 'react' or 'styled-components'
1283 for (const imp of appImports) {
1284 expect(imp).toContain(MF);
1285 }
1286 });
1287});
1288
1289describe('resolveImport', () => {
1290 const MF = path.join(FIXTURES, 'multifile');
1291
1292 test('resolves relative path with extension', () => {
1293 const fileSet = new Set([path.join(MF, 'Card.tsx')]);
1294 const result = resolveImport('./Card.tsx', MF, fileSet);
1295 expect(result).toBe(path.join(MF, 'Card.tsx'));
1296 });
1297
1298 test('resolves extensionless import by trying extensions', () => {
1299 const fileSet = new Set([path.join(MF, 'Card.tsx')]);
1300 const result = resolveImport('./Card', MF, fileSet);
1301 expect(result).toBe(path.join(MF, 'Card.tsx'));
1302 });
1303
1304 test('returns null for bare specifiers', () => {
1305 const fileSet = new Set([path.join(MF, 'Card.tsx')]);
1306 expect(resolveImport('react', MF, fileSet)).toBeNull();
1307 expect(resolveImport('styled-components', MF, fileSet)).toBeNull();
1308 });
1309
1310 test('returns null for unresolvable imports', () => {
1311 const fileSet = new Set([path.join(MF, 'Card.tsx')]);
1312 expect(resolveImport('./Unknown', MF, fileSet)).toBeNull();
1313 });
1314});
1315
1316// ---------------------------------------------------------------------------
1317// Tier 2: Multi-file directory scan
1318// ---------------------------------------------------------------------------
1319
1320describe('CLI -- multi-file scan', () => {
1321 function run(...args) {
1322 const result = spawnSync('node', [SCRIPT, ...args], { encoding: 'utf-8', timeout: 15000 });
1323 return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status };
1324 }
1325
1326 test('scanning multifile/ directory finds findings across files', () => {
1327 const { code, stderr } = run(path.join(FIXTURES, 'multifile'));
1328 expect(code).toBe(2);
1329 expect(stderr).toContain('side-tab');
1330 });
1331
1332 test('--json multi-file scan includes import context', () => {
1333 const { stdout, code } = run('--json', path.join(FIXTURES, 'multifile'));
1334 expect(code).toBe(2);
1335 const parsed = JSON.parse(stdout.trim());
1336 expect(parsed.length).toBeGreaterThan(0);
1337 // Findings from Card.tsx should mention being imported by App.tsx
1338 const cardFindings = parsed.filter(f => f.file?.includes('Card.tsx'));
1339 expect(cardFindings.length).toBeGreaterThan(0);
1340 expect(cardFindings.some(f => f.importedBy?.includes('App.tsx'))).toBe(true);
1341 });
1342});
1343
1344// ---------------------------------------------------------------------------
1345// Tier 3: Framework config detection
1346// ---------------------------------------------------------------------------
1347
1348describe('detectFrameworkConfig', () => {
1349 test('detects next.config.mjs and returns Next.js with default port', () => {
1350 const result = detectFrameworkConfig(path.join(FIXTURES, 'framework-next-tailwind'));
1351 expect(result).not.toBeNull();
1352 expect(result.name).toBe('Next.js');
1353 expect(result.port).toBe(3000);
1354 });
1355
1356 test('detects next.config.js (pages router)', () => {
1357 const result = detectFrameworkConfig(path.join(FIXTURES, 'framework-next-cssinjs'));
1358 expect(result).not.toBeNull();
1359 expect(result.name).toBe('Next.js');
1360 });
1361
1362 test('parses custom port from vite.config.ts', () => {
1363 const result = detectFrameworkConfig(path.join(FIXTURES, 'framework-vite'));
1364 expect(result).not.toBeNull();
1365 expect(result.name).toBe('Vite');
1366 expect(result.port).toBe(8080);
1367 });
1368
1369 test('returns null for directory without framework config', () => {
1370 const result = detectFrameworkConfig(path.join(FIXTURES, 'multifile'));
1371 expect(result).toBeNull();
1372 });
1373
1374 test('returns null for nonexistent directory', () => {
1375 const result = detectFrameworkConfig('/nonexistent/path/12345');
1376 expect(result).toBeNull();
1377 });
1378});
1379
1380describe('isPortListening', () => {
1381 test('returns { listening: false } for unlikely port', async () => {
1382 const result = await isPortListening(59999);
1383 expect(result.listening).toBe(false);
1384 });
1385});
1386
1387describe('FRAMEWORK_CONFIGS', () => {
1388 test('covers major frameworks', () => {
1389 const names = FRAMEWORK_CONFIGS.map(c => c.name);
1390 expect(names).toContain('Next.js');
1391 expect(names).toContain('Vite');
1392 expect(names).toContain('SvelteKit');
1393 expect(names).toContain('Nuxt');
1394 expect(names).toContain('Astro');
1395 });
1396
1397 test('each config has required fields', () => {
1398 for (const cfg of FRAMEWORK_CONFIGS) {
1399 expect(cfg.name).toBeTypeOf('string');
1400 expect(cfg.defaultPort).toBeTypeOf('number');
1401 expect(cfg.files).toBeArray();
1402 expect(cfg.files.length).toBeGreaterThan(0);
1403 }
1404 });
1405});
1406
1407describe('CLI -- dev server suggestion', () => {
1408 function run(...args) {
1409 const result = spawnSync('node', [SCRIPT, ...args], { encoding: 'utf-8', timeout: 15000 });
1410 return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status };
1411 }
1412
1413 test('suggests URL scan when Next.js config found', () => {
1414 const { stderr } = run(path.join(FIXTURES, 'framework-next-tailwind'));
1415 expect(stderr).toContain('Next.js');
1416 expect(stderr).toContain('3000');
1417 });
1418
1419 test('suggests URL scan when Vite config found', () => {
1420 const { stderr } = run(path.join(FIXTURES, 'framework-vite'));
1421 expect(stderr).toContain('Vite');
1422 expect(stderr).toContain('8080');
1423 });
1424});