detect-antipatterns.test.js

   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});