detect-antipatterns-fixtures.test.mjs

  1/**
  2 * jsdom fixture tests for anti-pattern detection.
  3 * Run via Node's built-in test runner (not bun) to avoid jsdom resource limits.
  4 *
  5 * Usage: node --test tests/detect-antipatterns-fixtures.test.mjs
  6 */
  7import { describe, it } from 'node:test';
  8import assert from 'node:assert/strict';
  9import path from 'path';
 10import { fileURLToPath } from 'url';
 11import {
 12  detectHtml,
 13} from '../src/detect-antipatterns.mjs';
 14
 15const __dirname = path.dirname(fileURLToPath(import.meta.url));
 16const FIXTURES = path.join(__dirname, 'fixtures', 'antipatterns');
 17
 18describe('detectHtml — jsdom fixtures', () => {
 19  it('should-flag: catches border anti-patterns', async () => {
 20    const f = await detectHtml(path.join(FIXTURES, 'should-flag.html'));
 21    assert.ok(f.some(r => r.antipattern === 'side-tab'));
 22    assert.ok(f.some(r => r.antipattern === 'border-accent-on-rounded'));
 23  });
 24
 25  it('should-pass: zero border findings', async () => {
 26    const f = await detectHtml(path.join(FIXTURES, 'should-pass.html'));
 27    assert.equal(f.filter(r => r.antipattern === 'side-tab' || r.antipattern === 'border-accent-on-rounded').length, 0);
 28  });
 29
 30  it('linked-stylesheet: catches borders, no false positives', async () => {
 31    const f = await detectHtml(path.join(FIXTURES, 'linked-stylesheet.html'));
 32    assert.ok(f.some(r => r.antipattern === 'side-tab'));
 33    assert.ok(f.some(r => r.antipattern === 'border-accent-on-rounded'));
 34    assert.equal(f.filter(r => r.snippet?.includes('clean')).length, 0);
 35  });
 36
 37  it('partial-component: flags borders, skips page-level', async () => {
 38    const f = await detectHtml(path.join(FIXTURES, 'partial-component.html'));
 39    assert.ok(f.some(r => r.antipattern === 'side-tab'));
 40    assert.equal(f.filter(r => r.antipattern === 'flat-type-hierarchy').length, 0);
 41  });
 42
 43  it('color: flag column triggers all color rules, pass column adds none', async () => {
 44    const f = await detectHtml(path.join(FIXTURES, 'color.html'));
 45    // All five color rules must fire from the flag column
 46    assert.ok(f.some(r => r.antipattern === 'pure-black-white'), 'expected pure-black-white');
 47    assert.ok(f.some(r => r.antipattern === 'gray-on-color'), 'expected gray-on-color');
 48    assert.ok(f.some(r => r.antipattern === 'low-contrast'), 'expected low-contrast');
 49    assert.ok(f.some(r => r.antipattern === 'gradient-text'), 'expected gradient-text');
 50    assert.ok(f.some(r => r.antipattern === 'ai-color-palette'), 'expected ai-color-palette');
 51    // Gradient-bg + gray text case (added with the gradient-fix patch)
 52    assert.ok(
 53      f.some(r => r.antipattern === 'low-contrast' && /#808080|#3b82f6|#8b5cf6/i.test(r.snippet || '')),
 54      'expected low-contrast finding for gray heading on blue/purple gradient',
 55    );
 56    assert.ok(
 57      f.some(r => r.antipattern === 'gray-on-color' && /gradient/i.test(r.snippet || '')),
 58      'expected gray-on-color finding referencing gradient',
 59    );
 60  });
 61
 62  it('color: white text on background-image url() ancestor is not flagged as low-contrast', async () => {
 63    const f = await detectHtml(path.join(FIXTURES, 'color.html'));
 64    // The pass column has white text on a div with background-image: url().
 65    // The detector can't know the image color, so it must not assume the body
 66    // bg and report a false low-contrast finding (#ffffff on #fafafa).
 67    const falsePositive = f.filter(r =>
 68      r.antipattern === 'low-contrast' &&
 69      /#ffffff on #fafafa/i.test(r.snippet || '')
 70    );
 71    assert.equal(
 72      falsePositive.length, 0,
 73      `expected no low-contrast from bg-image ancestor, got: ${falsePositive.map(r => r.snippet).join('; ')}`
 74    );
 75  });
 76
 77  it('color: Tailwind bg-black/N opacity modifiers are not flagged as pure-black-white', async () => {
 78    const f = await detectHtml(path.join(FIXTURES, 'color.html'));
 79    // The pass column has bg-black/3, hover:bg-black/5, bg-black/50 — none are pure black.
 80    // Only the flag column's literal bg-black class should trigger pure-black-white.
 81    const pureBlackFindings = f.filter(r => r.antipattern === 'pure-black-white');
 82    const opacityFalsePositives = pureBlackFindings.filter(r =>
 83      (r.snippet || '').includes('bg-black') &&
 84      f.some(() => true) // check that bg-black/N class triggers are absent
 85    );
 86    // There should be exactly the flag-column hits (bg-black class + #000000 inline)
 87    // and zero from the pass-column opacity variants.
 88    // The pass-column elements have data-test attributes starting with "bg-black-"
 89    // The Tailwind class check produces snippet "bg-black" — count those.
 90    const twSnippets = pureBlackFindings.filter(r => (r.snippet || '') === 'bg-black');
 91    assert.equal(
 92      twSnippets.length, 1,
 93      `expected exactly 1 Tailwind bg-black finding (flag column only), got ${twSnippets.length}: ${twSnippets.map(r => r.snippet).join('; ')}`
 94    );
 95  });
 96
 97  it('color: emoji-only text is never flagged as low-contrast', async () => {
 98    // Emojis render as multicolor glyphs regardless of CSS `color`, so the
 99    // CSS text color is irrelevant for contrast. The fixture's emoji cards
100    // intentionally set text color to match the bg (which would trip the
101    // rule for any other text). The detector must skip emoji-only nodes.
102    const f = await detectHtml(path.join(FIXTURES, 'color.html'));
103    const emojiCardColorPairs = ['#ffe4e6 on #ffe4e6', '#1a1a1a on #1a1a1a'];
104    const matches = f.filter(r =>
105      (r.antipattern === 'low-contrast' || r.antipattern === 'gray-on-color') &&
106      emojiCardColorPairs.some(pair => (r.snippet || '').includes(pair))
107    );
108    assert.equal(
109      matches.length, 0,
110      `expected no contrast findings on emoji-only text, got: ${matches.map(r => r.snippet).join('; ')}`
111    );
112  });
113
114  it('legitimate-borders: minimal false positives', async () => {
115    const f = await detectHtml(path.join(FIXTURES, 'legitimate-borders.html'));
116    const borderFindings = f.filter(r => r.antipattern === 'side-tab' || r.antipattern === 'border-accent-on-rounded');
117    assert.ok(borderFindings.length <= 1);
118  });
119
120  it('modern-color-borders: oklch/oklab/lch/lab side-tabs are flagged, neutrals pass', async () => {
121    // Regression for the isNeutralColor bug where any non-rgb() color format
122    // (oklch, oklab, lch, lab — which jsdom does NOT normalize to rgb) was
123    // misclassified as neutral, causing checkBorders() to silently skip
124    // every element with a modern-color side border.
125    //
126    // Also regression for the SAFE_TAGS/label bug: card-shaped <label>
127    // elements (clickable checklist rows with padding + radius + colored
128    // side border) used to be silently skipped because checkBorders'
129    // SAFE_TAGS gate excluded <label>. The fix narrows that gate so card-
130    // shaped labels are checked while plain inline form labels still pass.
131    const f = await detectHtml(path.join(FIXTURES, 'modern-color-borders.html'));
132    const sideTabs = f.filter(r => r.antipattern === 'side-tab');
133    // Twelve FLAG cases: oklch x3, oklab, lch, lab — all colored border-left
134    // with a non-zero border-radius — plus two card-shaped <label> cases
135    // (one oklch, one rgb), plus four var()-based cases (shorthand, mixed
136    // neutral+colored, border-right, and a card-shaped <label>). Each must
137    // produce exactly one side-tab.
138    assert.equal(
139      sideTabs.length, 12,
140      `expected 12 side-tab findings from the FLAG column, got ${sideTabs.length}: ${sideTabs.map(r => r.snippet).join('; ')}`
141    );
142    // Eleven findings must be border-left; exactly one is border-right
143    // (the #flag-var-right case). The fixture doesn't decorate top/bottom
144    // on any flag element.
145    const leftFindings = sideTabs.filter(r => /border-left/.test(r.snippet || ''));
146    const rightFindings = sideTabs.filter(r => /border-right/.test(r.snippet || ''));
147    assert.equal(leftFindings.length, 11, `expected 11 border-left findings, got ${leftFindings.length}`);
148    assert.equal(rightFindings.length, 1, `expected 1 border-right finding, got ${rightFindings.length}`);
149    // PASS column must contribute zero border findings of either flavor.
150    // There are 13 pass cases: 6 structural neutrals plus 4 labels (plain
151    // inline form label, label with a neutral gray border, label in a form
152    // row, and a label with a thin 1px colored left border), plus 3 var()
153    // pass cases (neutral-resolving var, thin var, uniform all-sides var).
154    // If any leaks through, the label exception or var() fallback is
155    // over-broad.
156    const borderAccent = f.filter(r => r.antipattern === 'border-accent-on-rounded');
157    assert.equal(
158      borderAccent.length, 0,
159      `expected 0 border-accent-on-rounded, got ${borderAccent.length}: ${borderAccent.map(r => r.snippet).join('; ')}`
160    );
161  });
162
163  it('typography-should-flag: detects all three issues', async () => {
164    const f = await detectHtml(path.join(FIXTURES, 'typography-should-flag.html'));
165    assert.ok(f.some(r => r.antipattern === 'overused-font'));
166    assert.ok(f.some(r => r.antipattern === 'single-font'));
167    assert.ok(f.some(r => r.antipattern === 'flat-type-hierarchy'));
168  });
169
170  it('typography-should-pass: zero findings', async () => {
171    const f = await detectHtml(path.join(FIXTURES, 'typography-should-pass.html'));
172    assert.equal(f.length, 0);
173  });
174});
175
176describe('detectHtml — icon-tile-stack', () => {
177  // Two-column fixture convention: left col = should-flag, right col = should-pass.
178  // The rule's snippet embeds the heading text in quotes, e.g.
179  //   "80x80px icon tile above h3 \"Lightning Fast\"".
180  // The test extracts those quoted texts and matches them against the
181  // expected lists below.
182  const SHOULD_FLAG = [
183    'Lightning Fast',
184    'Secure Storage',
185    'Easy Setup',
186    'Powerful Analytics',
187    'Emoji Inline Icon',
188  ];
189  const SHOULD_PASS = [
190    'Sarah Chen',
191    'Article Headline',
192    'Inline Side By Side',
193    'Plain Heading No Icon',
194    'Tiny Icon Above Me',
195    'Huge Hero Image',
196  ];
197
198  it('icon-tile-stack: flags only the should-flag column', async () => {
199    const f = await detectHtml(path.join(FIXTURES, 'icon-tile-stack.html'));
200    const flagged = new Set();
201    for (const r of f) {
202      if (r.antipattern !== 'icon-tile-stack') continue;
203      const m = (r.snippet || '').match(/"([^"]+)"/);
204      if (m) flagged.add(m[1]);
205    }
206
207    for (const text of SHOULD_FLAG) {
208      assert.ok(flagged.has(text), `expected "${text}" to be flagged as icon-tile-stack`);
209    }
210    for (const text of SHOULD_PASS) {
211      assert.ok(!flagged.has(text), `"${text}" should NOT be flagged as icon-tile-stack`);
212    }
213  });
214});
215
216describe('detectHtml — quality (jsdom-compatible rules)', () => {
217  // Six of the eight quality rules can run in jsdom because they only need
218  // computed CSS values (tight-leading, tiny-text, justified-text,
219  // all-caps-body, wide-tracking) or pure DOM walks (skipped-heading).
220  // The other two (line-length, cramped-padding) need real layout rects and
221  // live in tests/detect-antipatterns-browser.test.mjs (Puppeteer-backed).
222  it('quality: flag column triggers all 6 jsdom-compatible quality rules', async () => {
223    const f = await detectHtml(path.join(FIXTURES, 'quality.html'));
224    assert.equal(f.filter(r => r.antipattern === 'tight-leading').length, 1);
225    assert.equal(f.filter(r => r.antipattern === 'tiny-text').length, 1);
226    assert.equal(f.filter(r => r.antipattern === 'justified-text').length, 1);
227    assert.equal(f.filter(r => r.antipattern === 'all-caps-body').length, 1);
228    assert.equal(f.filter(r => r.antipattern === 'wide-tracking').length, 1);
229    assert.equal(f.filter(r => r.antipattern === 'skipped-heading').length, 1);
230  });
231});
232
233describe('detectHtml — layout', () => {
234  it('layout: flag column triggers nested-cards, pass column adds none', async () => {
235    const f = await detectHtml(path.join(FIXTURES, 'layout.html'));
236    const nested = f.filter(r => r.antipattern === 'nested-cards');
237    assert.ok(nested.length >= 4, `expected ≥4 nested-cards findings, got ${nested.length}`);
238    // The page-level layout rules (monotonous-spacing, everything-centered)
239    // need Tailwind-via-CDN to render, which jsdom doesn't execute. They're
240    // effectively dormant in this test environment regardless of the fixture
241    // contents — so all we can verify is that the pass column doesn't push
242    // them awake unexpectedly.
243    assert.equal(f.filter(r => r.antipattern === 'monotonous-spacing').length, 0);
244    assert.equal(f.filter(r => r.antipattern === 'everything-centered').length, 0);
245  });
246});
247
248describe('detectHtml — motion', () => {
249  // jsdom doesn't fully apply class-based styles, so the absolute finding counts
250  // are lower than what a real browser would see. The hardcoded counts below are
251  // the calibrated jsdom baseline — if a future change pushes them up, that's a
252  // pass-column false positive; if down, the rule or fixture has regressed.
253  it('motion: flag column triggers both motion rules, pass column adds none', async () => {
254    const f = await detectHtml(path.join(FIXTURES, 'motion.html'));
255    assert.equal(f.filter(r => r.antipattern === 'bounce-easing').length, 2);
256    assert.equal(f.filter(r => r.antipattern === 'layout-transition').length, 2);
257  });
258});
259
260describe('detectHtml — dark glow', () => {
261  // Calibrated jsdom baseline — see motion test note above.
262  it('glow: flag column triggers dark-glow, pass column adds none', async () => {
263    const f = await detectHtml(path.join(FIXTURES, 'glow.html'));
264    assert.equal(f.filter(r => r.antipattern === 'dark-glow').length, 1);
265  });
266});