detect-antipatterns.test.js

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