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