detect-antipatterns-fixtures.test.mjs

  1/**
  2 * Static HTML/CSS fixture tests for anti-pattern detection.
  3 * Run via Node's built-in test runner (not bun).
  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 '../cli/engine/detect-antipatterns.mjs';
 14
 15const __dirname = path.dirname(fileURLToPath(import.meta.url));
 16const FIXTURES = path.join(__dirname, 'fixtures', 'antipatterns');
 17
 18describe('detectHtml — static HTML/CSS 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('border-baseline: paired side-tab fixture flags only the positive column', async () => {
 31    const f = await detectHtml(path.join(FIXTURES, 'border-baseline.html'));
 32    const sideTabs = f.filter(r => r.antipattern === 'side-tab');
 33    const accents = f.filter(r => r.antipattern === 'border-accent-on-rounded');
 34    assert.equal(
 35      sideTabs.length,
 36      4,
 37      `expected 4 side-tab findings, got ${sideTabs.length}: ${sideTabs.map(r => r.snippet).join('; ')}`
 38    );
 39    assert.equal(
 40      accents.length,
 41      2,
 42      `expected 2 rounded accent findings, got ${accents.length}: ${accents.map(r => r.snippet).join('; ')}`
 43    );
 44  });
 45
 46  it('linked-stylesheet: catches borders, no false positives', async () => {
 47    const f = await detectHtml(path.join(FIXTURES, 'linked-stylesheet.html'));
 48    assert.ok(f.some(r => r.antipattern === 'side-tab'));
 49    assert.ok(f.some(r => r.antipattern === 'border-accent-on-rounded'));
 50    assert.equal(f.filter(r => r.snippet?.includes('clean')).length, 0);
 51    assert.equal(
 52      f.filter(r => r.antipattern !== 'side-tab' && r.antipattern !== 'border-accent-on-rounded').length,
 53      0,
 54      `expected only border findings, got: ${f.map(r => `${r.antipattern}:${r.snippet}`).join('; ')}`
 55    );
 56  });
 57
 58  it('partial-component: flags borders, skips page-level', async () => {
 59    const f = await detectHtml(path.join(FIXTURES, 'partial-component.html'));
 60    assert.ok(f.some(r => r.antipattern === 'side-tab'));
 61    assert.equal(f.filter(r => r.antipattern === 'flat-type-hierarchy').length, 0);
 62  });
 63
 64  it('color: flag column triggers all color rules, pass column adds none', async () => {
 65    const f = await detectHtml(path.join(FIXTURES, 'color.html'));
 66    // All five color rules must fire from the flag column
 67    assert.ok(f.some(r => r.antipattern === 'pure-black-white'), 'expected pure-black-white');
 68    assert.ok(f.some(r => r.antipattern === 'gray-on-color'), 'expected gray-on-color');
 69    assert.ok(f.some(r => r.antipattern === 'low-contrast'), 'expected low-contrast');
 70    assert.ok(f.some(r => r.antipattern === 'gradient-text'), 'expected gradient-text');
 71    assert.ok(f.some(r => r.antipattern === 'ai-color-palette'), 'expected ai-color-palette');
 72    assert.equal(
 73      f.some(r => r.antipattern === 'pure-black-white' && /#ffffff|#fff/i.test(r.snippet || '')),
 74      false,
 75      'pure white surfaces with dark text should remain allowed',
 76    );
 77    // Gradient-bg + gray text case (added with the gradient-fix patch)
 78    assert.ok(
 79      f.some(r => r.antipattern === 'low-contrast' && /#808080|#3b82f6|#8b5cf6/i.test(r.snippet || '')),
 80      'expected low-contrast finding for gray heading on blue/purple gradient',
 81    );
 82    assert.ok(
 83      f.some(r => r.antipattern === 'gray-on-color' && /gradient/i.test(r.snippet || '')),
 84      'expected gray-on-color finding referencing gradient',
 85    );
 86  });
 87
 88  it('color: white text on background-image url() ancestor is not flagged as low-contrast', async () => {
 89    const f = await detectHtml(path.join(FIXTURES, 'color.html'));
 90    // The pass column has white text on a div with background-image: url().
 91    // The detector can't know the image color, so it must not assume the body
 92    // bg and report a false low-contrast finding (#ffffff on #fafafa).
 93    const falsePositive = f.filter(r =>
 94      r.antipattern === 'low-contrast' &&
 95      /#ffffff on #fafafa/i.test(r.snippet || '')
 96    );
 97    assert.equal(
 98      falsePositive.length, 0,
 99      `expected no low-contrast from bg-image ancestor, got: ${falsePositive.map(r => r.snippet).join('; ')}`
100    );
101  });
102
103  it('color: Tailwind bg-black/N opacity modifiers are not flagged as pure-black-white', async () => {
104    const f = await detectHtml(path.join(FIXTURES, 'color.html'));
105    // The pass column has bg-black/3, hover:bg-black/5, bg-black/50 — none are pure black.
106    // Only the flag column's literal bg-black class should trigger pure-black-white.
107    const pureBlackFindings = f.filter(r => r.antipattern === 'pure-black-white');
108    const opacityFalsePositives = pureBlackFindings.filter(r =>
109      (r.snippet || '').includes('bg-black') &&
110      f.some(() => true) // check that bg-black/N class triggers are absent
111    );
112    // There should be exactly the flag-column hits (bg-black class + #000000 inline)
113    // and zero from the pass-column opacity variants.
114    // The pass-column elements have data-test attributes starting with "bg-black-"
115    // The Tailwind class check produces snippet "bg-black" — count those.
116    const twSnippets = pureBlackFindings.filter(r => (r.snippet || '') === 'bg-black');
117    assert.equal(
118      twSnippets.length, 1,
119      `expected exactly 1 Tailwind bg-black finding (flag column only), got ${twSnippets.length}: ${twSnippets.map(r => r.snippet).join('; ')}`
120    );
121  });
122
123  it('color: styled <a> and <button> with their own background get contrast checks', async () => {
124    // SAFE_TAGS skips <a> and <button> by default to avoid noise on inline links
125    // (text links inside paragraphs). When these elements are styled as buttons
126    // (own opaque background, padding, direct text), the contrast check must run.
127    // Mirrors a real bug from the landing-demo: a pill-style <a> with
128    // warm-charcoal text on near-black bg, ~2:1 contrast, was missed by both
129    // the CLI and browser overlay paths because <a> was categorically skipped.
130    const f = await detectHtml(path.join(FIXTURES, 'color.html'));
131    const pillBtnFlag = f.some(r =>
132      r.antipattern === 'low-contrast' &&
133      /#5b4f44/i.test(r.snippet || '') &&
134      /#1f1a15/i.test(r.snippet || '')
135    );
136    assert.ok(pillBtnFlag, 'expected low-contrast finding for styled <a> pill button');
137    const styledButtonFlag = f.some(r =>
138      r.antipattern === 'low-contrast' &&
139      /#6c7280/i.test(r.snippet || '') &&
140      /#374151/i.test(r.snippet || '')
141    );
142    assert.ok(styledButtonFlag, 'expected low-contrast finding for styled <button>');
143  });
144
145  it('color: inline <a> without own background remains skipped (no regression)', async () => {
146    // The exception for styled buttons must not regress to flagging plain
147    // inline text links — those would create noise on essentially every
148    // page on the web.
149    const f = await detectHtml(path.join(FIXTURES, 'color.html'));
150    const inlineLinkFalsePositive = f.some(r =>
151      r.antipattern === 'low-contrast' &&
152      /#aaaaaa/i.test(r.snippet || '')
153    );
154    assert.equal(
155      inlineLinkFalsePositive, false,
156      'inline <a> without own background must remain skipped'
157    );
158  });
159
160  it('color: styled <a> with good contrast does not flag', async () => {
161    // The detector exception must let the check run, but a properly contrasted
162    // styled button must obviously pass.
163    const f = await detectHtml(path.join(FIXTURES, 'color.html'));
164    const goodPillFalsePositive = f.some(r =>
165      r.antipattern === 'low-contrast' &&
166      /#f5f0e8/i.test(r.snippet || '') &&
167      /#141419/i.test(r.snippet || '')
168    );
169    assert.equal(
170      goodPillFalsePositive, false,
171      'styled <a> with high contrast must not flag'
172    );
173  });
174
175  it('color: emoji-only text is never flagged as low-contrast', async () => {
176    // Emojis render as multicolor glyphs regardless of CSS `color`, so the
177    // CSS text color is irrelevant for contrast. The fixture's emoji cards
178    // intentionally set text color to match the bg (which would trip the
179    // rule for any other text). The detector must skip emoji-only nodes.
180    const f = await detectHtml(path.join(FIXTURES, 'color.html'));
181    const emojiCardColorPairs = ['#ffe4e6 on #ffe4e6', '#1a1a1a on #1a1a1a'];
182    const matches = f.filter(r =>
183      (r.antipattern === 'low-contrast' || r.antipattern === 'gray-on-color') &&
184      emojiCardColorPairs.some(pair => (r.snippet || '').includes(pair))
185    );
186    assert.equal(
187      matches.length, 0,
188      `expected no contrast findings on emoji-only text, got: ${matches.map(r => r.snippet).join('; ')}`
189    );
190  });
191
192  it('legitimate-borders: zero findings', async () => {
193    const f = await detectHtml(path.join(FIXTURES, 'legitimate-borders.html'));
194    assert.equal(f.length, 0, `expected no findings, got: ${f.map(r => `${r.antipattern}:${r.snippet}`).join('; ')}`);
195  });
196
197  it('modern-color-borders: oklch/oklab/lch/lab side-tabs are flagged, neutrals pass', async () => {
198    // Regression for the isNeutralColor bug where any non-rgb() color format
199    // (oklch, oklab, lch, lab — which jsdom does NOT normalize to rgb) was
200    // misclassified as neutral, causing checkBorders() to silently skip
201    // every element with a modern-color side border.
202    //
203    // Also regression for the SAFE_TAGS/label bug: card-shaped <label>
204    // elements (clickable checklist rows with padding + radius + colored
205    // side border) used to be silently skipped because checkBorders'
206    // SAFE_TAGS gate excluded <label>. The fix narrows that gate so card-
207    // shaped labels are checked while plain inline form labels still pass.
208    const f = await detectHtml(path.join(FIXTURES, 'modern-color-borders.html'));
209    const sideTabs = f.filter(r => r.antipattern === 'side-tab');
210    // Twelve FLAG cases: oklch x3, oklab, lch, lab — all colored border-left
211    // with a non-zero border-radius — plus two card-shaped <label> cases
212    // (one oklch, one rgb), plus four var()-based cases (shorthand, mixed
213    // neutral+colored, border-right, and a card-shaped <label>). Each must
214    // produce exactly one side-tab.
215    assert.equal(
216      sideTabs.length, 12,
217      `expected 12 side-tab findings from the FLAG column, got ${sideTabs.length}: ${sideTabs.map(r => r.snippet).join('; ')}`
218    );
219    // Eleven findings must be border-left; exactly one is border-right
220    // (the #flag-var-right case). The fixture doesn't decorate top/bottom
221    // on any flag element.
222    const leftFindings = sideTabs.filter(r => /border-left/.test(r.snippet || ''));
223    const rightFindings = sideTabs.filter(r => /border-right/.test(r.snippet || ''));
224    assert.equal(leftFindings.length, 11, `expected 11 border-left findings, got ${leftFindings.length}`);
225    assert.equal(rightFindings.length, 1, `expected 1 border-right finding, got ${rightFindings.length}`);
226    // PASS column must contribute zero border findings of either flavor.
227    // There are 13 pass cases: 6 structural neutrals plus 4 labels (plain
228    // inline form label, label with a neutral gray border, label in a form
229    // row, and a label with a thin 1px colored left border), plus 3 var()
230    // pass cases (neutral-resolving var, thin var, uniform all-sides var).
231    // If any leaks through, the label exception or var() fallback is
232    // over-broad.
233    const borderAccent = f.filter(r => r.antipattern === 'border-accent-on-rounded');
234    assert.equal(
235      borderAccent.length, 0,
236      `expected 0 border-accent-on-rounded, got ${borderAccent.length}: ${borderAccent.map(r => r.snippet).join('; ')}`
237    );
238  });
239
240  it('typography-should-flag: detects all three issues', async () => {
241    const f = await detectHtml(path.join(FIXTURES, 'typography-should-flag.html'));
242    assert.ok(f.some(r => r.antipattern === 'overused-font'));
243    assert.ok(f.some(r => r.antipattern === 'single-font'));
244    assert.ok(f.some(r => r.antipattern === 'flat-type-hierarchy'));
245    assert.equal(
246      f.some(r => r.antipattern === 'low-contrast'),
247      false,
248      `typography fixture should not contain incidental contrast findings: ${f.map(r => `${r.antipattern}:${r.snippet}`).join('; ')}`
249    );
250  });
251
252  it('typography: side-by-side page has visible element-level flag cases', async () => {
253    const f = await detectHtml(path.join(FIXTURES, 'typography.html'));
254    const ids = new Set(f.map(r => r.antipattern));
255    for (const id of ['tight-leading', 'tiny-text', 'all-caps-body', 'wide-tracking', 'justified-text']) {
256      assert.ok(ids.has(id), `expected typography side-by-side fixture to include ${id}`);
257    }
258    assert.ok(ids.has('overused-font'), 'expected typography side-by-side fixture to include a page-level overused-font finding');
259  });
260
261  it('typography-should-pass: zero findings', async () => {
262    const f = await detectHtml(path.join(FIXTURES, 'typography-should-pass.html'));
263    assert.equal(f.length, 0);
264  });
265});
266
267describe('detectHtml — icon-tile-stack', () => {
268  // Two-column fixture convention: left col = should-flag, right col = should-pass.
269  // The rule's snippet embeds the heading text in quotes, e.g.
270  //   "80x80px icon tile above h3 \"Lightning Fast\"".
271  // The test extracts those quoted texts and matches them against the
272  // expected lists below.
273  const SHOULD_FLAG = [
274    'Lightning Fast',
275    'Secure Storage',
276    'Easy Setup',
277    'Powerful Analytics',
278    'Emoji Inline Icon',
279  ];
280  const SHOULD_PASS = [
281    'Sarah Chen',
282    'Article Headline',
283    'Inline Side By Side',
284    'Plain Heading No Icon',
285    'Tiny Icon Above Me',
286    'Huge Hero Image',
287  ];
288
289  it('icon-tile-stack: flags only the should-flag column', async () => {
290    const f = await detectHtml(path.join(FIXTURES, 'icon-tile-stack.html'));
291    const flagged = new Set();
292    for (const r of f) {
293      if (r.antipattern !== 'icon-tile-stack') continue;
294      const m = (r.snippet || '').match(/"([^"]+)"/);
295      if (m) flagged.add(m[1]);
296    }
297
298    for (const text of SHOULD_FLAG) {
299      assert.ok(flagged.has(text), `expected "${text}" to be flagged as icon-tile-stack`);
300    }
301    for (const text of SHOULD_PASS) {
302      assert.ok(!flagged.has(text), `"${text}" should NOT be flagged as icon-tile-stack`);
303    }
304  });
305});
306
307describe('detectHtml — quality (static-compatible rules)', () => {
308  // Six of the eight quality rules can run in static HTML/CSS because they only need
309  // computed CSS values (tight-leading, tiny-text, justified-text,
310  // all-caps-body, wide-tracking) or pure DOM walks (skipped-heading).
311  // The other two (line-length, cramped-padding) need real layout rects and
312  // live in tests/detect-antipatterns-browser.test.mjs (Puppeteer-backed).
313  it('quality: flag column triggers all 6 static-compatible quality rules', async () => {
314    const f = await detectHtml(path.join(FIXTURES, 'quality.html'));
315    assert.equal(f.filter(r => r.antipattern === 'tight-leading').length, 1);
316    assert.equal(f.filter(r => r.antipattern === 'tiny-text').length, 1);
317    assert.equal(f.filter(r => r.antipattern === 'justified-text').length, 1);
318    assert.equal(f.filter(r => r.antipattern === 'all-caps-body').length, 1);
319    assert.equal(f.filter(r => r.antipattern === 'wide-tracking').length, 1);
320    assert.equal(f.filter(r => r.antipattern === 'skipped-heading').length, 1);
321  });
322});
323
324describe('detectHtml — layout', () => {
325  it('layout: flag column triggers nested-cards, pass column adds none', async () => {
326    const f = await detectHtml(path.join(FIXTURES, 'layout.html'));
327    const nested = f.filter(r => r.antipattern === 'nested-cards');
328    assert.ok(nested.length >= 4, `expected ≥4 nested-cards findings, got ${nested.length}`);
329    // The page-level layout rules (monotonous-spacing, everything-centered)
330    // need Tailwind-via-CDN to render, which the static engine does not fetch.
331    // They're effectively dormant in this test environment regardless of the fixture
332    // contents — so all we can verify is that the pass column doesn't push
333    // them awake unexpectedly.
334    assert.equal(f.filter(r => r.antipattern === 'monotonous-spacing').length, 0);
335    assert.equal(f.filter(r => r.antipattern === 'everything-centered').length, 0);
336  });
337});
338
339describe('detectHtml — italic-serif-display', () => {
340  // Two-column fixture: left col flag, right col pass. Snippet embeds the
341  // heading text in quotes so the test can extract it via /"([^"]+)"/.
342  const SHOULD_FLAG = [
343    'Fraunces 88px italic',
344    'Recoleta 64px italic',
345    'Playfair 72px italic',
346    'Unknown Serif Generic Fallback',
347  ];
348  const SHOULD_PASS = [
349    'Sans Italic Display',
350    'Roman Serif Display',
351    'Italic Serif Pull Quote',
352    // The italic <em> inside the roman h1 is intentionally not detected in v1.
353    // The h1's own text "Inline Em Inside Roman" must not appear flagged.
354    'Inline Em Inside Roman',
355    'Italic Serif at 32px',
356    'h1 Sans-Serif Roman',
357  ];
358
359  it('italic-serif-display: flags only the should-flag column', async () => {
360    const f = await detectHtml(path.join(FIXTURES, 'italic-serif-display.html'));
361    const flagged = new Set();
362    for (const r of f) {
363      if (r.antipattern !== 'italic-serif-display') continue;
364      const m = (r.snippet || '').match(/"([^"]+)"/);
365      if (m) flagged.add(m[1]);
366    }
367
368    for (const text of SHOULD_FLAG) {
369      assert.ok(flagged.has(text), `expected "${text}" to be flagged as italic-serif-display`);
370    }
371    for (const text of SHOULD_PASS) {
372      assert.ok(!flagged.has(text), `"${text}" should NOT be flagged as italic-serif-display`);
373    }
374  });
375});
376
377describe('detectHtml — hero-eyebrow-chip', () => {
378  const SHOULD_FLAG = [
379    'Eyebrow Above Hero',
380    'Span Eyebrow Above Hero',
381    'Pill Chip Above Hero',
382    'Already Uppercase Text',
383    // The rule no longer gates on heading font size (modern hero h1s
384    // use clamp() / vw / var() that static HTML/CSS cannot resolve), and the
385    // eyebrow text ceiling moved 30 → 60 chars. Both shapes now flag.
386    'Body-Sized Heading Below Eyebrow',
387    'Long Uppercase Sentence Above Hero',
388  ];
389  const SHOULD_PASS = [
390    'Eyebrow With Normal Tracking',
391    'Uppercase Caption Far From Hero',
392    'Hero With No Eyebrow',
393    'Heading Above Heading',
394  ];
395
396  it('hero-eyebrow-chip: flags only the should-flag column', async () => {
397    const f = await detectHtml(path.join(FIXTURES, 'hero-eyebrow-chip.html'));
398    const flagged = new Set();
399    for (const r of f) {
400      if (r.antipattern !== 'hero-eyebrow-chip') continue;
401      // Snippet shape: ... above h1 "Heading Text"
402      const matches = [...(r.snippet || '').matchAll(/"([^"]+)"/g)];
403      // Last quoted token is the heading text
404      if (matches.length) flagged.add(matches[matches.length - 1][1]);
405    }
406
407    for (const text of SHOULD_FLAG) {
408      assert.ok(flagged.has(text), `expected "${text}" to be flagged as hero-eyebrow-chip`);
409    }
410    for (const text of SHOULD_PASS) {
411      assert.ok(!flagged.has(text), `"${text}" should NOT be flagged as hero-eyebrow-chip`);
412    }
413  });
414});
415
416describe('detectHtml — repeated-section-kickers', () => {
417  const SHOULD_FLAG = [
418    'The Future Is Admitted',
419    'A Private Rehearsal',
420    'Reviewed, Not Sold',
421    'Touch the Future',
422  ];
423  const SHOULD_PASS = [
424    'Breadcrumb Before Heading',
425    'Form Heading Is Separate',
426    'Step Indicator',
427    'Figure Caption Label',
428    'Normal Case Kicker',
429    'Intentional Brand Label',
430  ];
431
432  it('repeated-section-kickers: flags only repeated section scaffolding', async () => {
433    const f = await detectHtml(path.join(FIXTURES, 'repeated-section-kickers.html'));
434    const flagged = new Set();
435    for (const r of f) {
436      if (r.antipattern !== 'repeated-section-kickers') continue;
437      assert.equal(r.severity, 'advisory');
438      const matches = [...(r.snippet || '').matchAll(/"([^"]+)"/g)];
439      if (matches.length) flagged.add(matches[matches.length - 1][1]);
440    }
441
442    for (const text of SHOULD_FLAG) {
443      assert.ok(flagged.has(text), `expected "${text}" to be flagged as repeated-section-kickers`);
444    }
445    for (const text of SHOULD_PASS) {
446      assert.ok(!flagged.has(text), `"${text}" should NOT be flagged as repeated-section-kickers`);
447    }
448  });
449});
450
451describe('detectHtml — motion', () => {
452  // The static CSS engine applies class-based fixture styles, so it catches all
453  // flag-column layout-transition cases without relying on browser layout.
454  it('motion: flag column triggers both motion rules, pass column adds none', async () => {
455    const f = await detectHtml(path.join(FIXTURES, 'motion.html'));
456    assert.equal(f.filter(r => r.antipattern === 'bounce-easing').length, 2);
457    assert.equal(f.filter(r => r.antipattern === 'layout-transition').length, 8);
458  });
459});
460
461describe('detectHtml — dark glow', () => {
462  // Calibrated static baseline — see motion test note above.
463  it('glow: flag column triggers dark-glow, pass column adds none', async () => {
464    const f = await detectHtml(path.join(FIXTURES, 'glow.html'));
465    assert.equal(f.filter(r => r.antipattern === 'dark-glow').length, 1);
466  });
467});