mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-17 00:26:41 +03:00
Normalize detection to jsdom by default, regex as fallback
Architecture simplified to two paths: - HTML files: jsdom with getComputedStyle (resolves linked CSS, cascade) - Non-HTML files: regex fallback (CSS, JSX, TSX, etc.) - URLs: Puppeteer (unchanged) - --fast flag forces regex-only for all files Removed --deep flag (jsdom is now the default). Removed static mode from browser script (always uses getComputedStyle — it's in a real browser). Anti-pattern definitions split into: - checkElementBorders() — shared element-level computed style checker - checkPageTypography() — shared page-level checker - REGEX_MATCHERS/REGEX_ANALYZERS — regex fallback for non-HTML Browser script simplified from 470 lines to 250. CLI script reduced from 810 lines to 440. Detection logic is now single-source for jsdom/puppeteer/browser. Fixtures now served via /fixtures/* route in dev server for proper CORS handling of linked stylesheets. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
32a54138bb
commit
37393f1793
File diff suppressed because it is too large
Load Diff
@@ -2,17 +2,10 @@
|
||||
* Anti-Pattern Browser Detector for Impeccable
|
||||
*
|
||||
* Drop this script into any page to visually highlight UI anti-patterns.
|
||||
* Uses getComputedStyle() and document.styleSheets for accurate detection.
|
||||
*
|
||||
* Two detection modes:
|
||||
* - "static" (default): regex on HTML source — same logic as the CLI script,
|
||||
* so fixture pages test exactly what the CLI tests.
|
||||
* - "computed": getComputedStyle() — catches CSS cascade, inherited styles.
|
||||
* More accurate but may diverge from CLI results.
|
||||
*
|
||||
* Set mode via data attribute on the script tag:
|
||||
* <script src="detect-antipatterns-browser.js" data-mode="computed"></script>
|
||||
*
|
||||
* Or call: window.impeccableScan({ mode: 'computed' })
|
||||
* Usage: <script src="detect-antipatterns-browser.js"></script>
|
||||
* Re-scan: window.impeccableScan()
|
||||
*/
|
||||
(function () {
|
||||
if (typeof window === 'undefined') return;
|
||||
@@ -20,290 +13,129 @@
|
||||
const LABEL_BG = 'oklch(55% 0.25 350)';
|
||||
const OUTLINE_COLOR = 'oklch(60% 0.25 350)';
|
||||
|
||||
// Read mode from script tag data attribute (default: static)
|
||||
const scriptTag = document.currentScript;
|
||||
const defaultMode = scriptTag?.dataset?.mode || 'static';
|
||||
const SAFE_TAGS = new Set([
|
||||
'blockquote', 'nav', 'a', 'input', 'textarea', 'select',
|
||||
'pre', 'code', 'span', 'th', 'td', 'tr', 'li', 'label',
|
||||
'button', 'hr', 'html', 'head', 'body', 'script', 'style',
|
||||
'link', 'meta', 'title', 'br', 'img', 'svg', 'path', 'circle',
|
||||
'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use',
|
||||
]);
|
||||
|
||||
const OVERUSED_FONTS = new Set([
|
||||
'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica',
|
||||
]);
|
||||
|
||||
const GENERIC_FONTS = new Set([
|
||||
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
|
||||
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
||||
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
|
||||
'inherit', 'initial', 'unset', 'revert',
|
||||
]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Static detection (mirrors CLI regex logic)
|
||||
// Detection (computed styles)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const SAFE_ELEMENTS_RE = /^(blockquote|nav|a|input|textarea|select|pre|code|span|th|td|tr|li|label|button|hr)$/i;
|
||||
|
||||
function hasRoundedClass(str) { return /\brounded(?:-\w+)?\b/.test(str); }
|
||||
|
||||
|
||||
/**
|
||||
* Scan <style> blocks for CSS rules with anti-pattern border properties.
|
||||
* Returns a Map of element → findings[] for elements matching those selectors.
|
||||
*/
|
||||
function scanStyleBlocks() {
|
||||
const elementFindings = new Map();
|
||||
const styleTags = document.querySelectorAll('style');
|
||||
|
||||
for (const styleTag of styleTags) {
|
||||
const css = styleTag.textContent;
|
||||
// Simple CSS rule parser: extract selector { ... } blocks
|
||||
const ruleRe = /([^{}]+)\{([^}]+)\}/g;
|
||||
let rule;
|
||||
while ((rule = ruleRe.exec(css)) !== null) {
|
||||
const selector = rule[1].trim();
|
||||
const body = rule[2];
|
||||
|
||||
const findings = [];
|
||||
let m;
|
||||
|
||||
// Check for border-radius in the same rule
|
||||
const ruleHasRadius = /border-radius/i.test(body);
|
||||
|
||||
// Collect border patterns from this rule (as templates — radius check deferred to element)
|
||||
const borderPatterns = [];
|
||||
|
||||
// Side borders: border-left/right shorthand
|
||||
const cssSide = /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi;
|
||||
while ((m = cssSide.exec(body)) !== null) {
|
||||
const n = parseInt(m[1], 10);
|
||||
const neutral = isNeutralInline(m[0]);
|
||||
borderPatterns.push({ n, text: m[0].trim(), direction: 'side', neutral });
|
||||
}
|
||||
|
||||
// Side borders: longhand
|
||||
const cssLong = /border-(?:left|right)-width\s*:\s*(\d+)px/gi;
|
||||
while ((m = cssLong.exec(body)) !== null) {
|
||||
borderPatterns.push({ n: parseInt(m[1], 10), text: m[0], direction: 'side', neutral: false });
|
||||
}
|
||||
|
||||
// Side borders: logical
|
||||
const cssLogical = /border-inline-(?:start|end)\s*:\s*(\d+)px\s+solid/gi;
|
||||
while ((m = cssLogical.exec(body)) !== null) {
|
||||
borderPatterns.push({ n: parseInt(m[1], 10), text: m[0], direction: 'side', neutral: false });
|
||||
}
|
||||
|
||||
// Side borders: logical longhand
|
||||
const cssLogLong = /border-inline-(?:start|end)-width\s*:\s*(\d+)px/gi;
|
||||
while ((m = cssLogLong.exec(body)) !== null) {
|
||||
borderPatterns.push({ n: parseInt(m[1], 10), text: m[0], direction: 'side', neutral: false });
|
||||
}
|
||||
|
||||
// Top/bottom borders
|
||||
const cssTB = /border-(?:top|bottom)\s*:\s*(\d+)px\s+solid[^;]*/gi;
|
||||
while ((m = cssTB.exec(body)) !== null) {
|
||||
borderPatterns.push({ n: parseInt(m[1], 10), text: m[0].trim(), direction: 'tb', neutral: false });
|
||||
}
|
||||
|
||||
if (borderPatterns.length === 0) continue;
|
||||
|
||||
// Map findings to matching DOM elements, using computed radius for context
|
||||
try {
|
||||
const els = document.querySelectorAll(selector);
|
||||
for (const el of els) {
|
||||
if (SAFE_ELEMENTS_RE.test(el.tagName.toLowerCase())) continue;
|
||||
const elRadius = ruleHasRadius || (parseFloat(getComputedStyle(el).borderRadius) || 0) > 0;
|
||||
|
||||
const findings = [];
|
||||
for (const bp of borderPatterns) {
|
||||
if (bp.direction === 'side') {
|
||||
if (bp.neutral) continue;
|
||||
if (elRadius && bp.n >= 1) {
|
||||
findings.push({ type: 'side-tab', detail: `${bp.text} + border-radius` });
|
||||
} else if (bp.n >= 3) {
|
||||
findings.push({ type: 'side-tab', detail: bp.text });
|
||||
}
|
||||
} else {
|
||||
// top/bottom: only with radius
|
||||
if (elRadius && bp.n >= 1) {
|
||||
findings.push({ type: 'border-accent-on-rounded', detail: `${bp.text} + border-radius` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (findings.length > 0) {
|
||||
const existing = elementFindings.get(el) || [];
|
||||
existing.push(...findings);
|
||||
elementFindings.set(el, existing);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Invalid selector, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return elementFindings;
|
||||
function isNeutralColor(color) {
|
||||
if (!color || color === 'transparent') return true;
|
||||
const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
||||
if (!m) return true;
|
||||
return (Math.max(+m[1], +m[2], +m[3]) - Math.min(+m[1], +m[2], +m[3])) < 30;
|
||||
}
|
||||
|
||||
/** Check if an inline CSS color value looks neutral (gray/white/black) */
|
||||
function isNeutralInline(cssText) {
|
||||
// Extract the color from "Npx solid #color" or "Npx solid rgb(...)"
|
||||
const colorMatch = cssText.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
|
||||
if (!colorMatch) return false;
|
||||
const color = colorMatch[1].toLowerCase();
|
||||
// Named grays
|
||||
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(color)) return true;
|
||||
// Hex grays: all channels within 30 of each other
|
||||
const hex = color.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
|
||||
if (hex) {
|
||||
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
|
||||
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
|
||||
}
|
||||
// Short hex
|
||||
const shex = color.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i);
|
||||
if (shex) {
|
||||
const [r, g, b] = [parseInt(shex[1] + shex[1], 16), parseInt(shex[2] + shex[2], 16), parseInt(shex[3] + shex[3], 16)];
|
||||
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function scanElementStatic(el) {
|
||||
const findings = [];
|
||||
function checkElementBorders(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
|
||||
// Get the raw class list and inline style as strings to regex against
|
||||
const classList = el.getAttribute('class') || '';
|
||||
const inlineStyle = el.getAttribute('style') || '';
|
||||
|
||||
const hasRounded = hasRoundedClass(classList);
|
||||
const isSafe = SAFE_ELEMENTS_RE.test(tag);
|
||||
|
||||
// Use computed style for border-radius — catches radius from CSS classes
|
||||
const computedRadius = parseFloat(getComputedStyle(el).borderRadius) || 0;
|
||||
const hasRadius = hasRounded || computedRadius > 0;
|
||||
|
||||
// --- Tailwind side borders: border-[lrse]-N ---
|
||||
const twSide = /\bborder-([lrse])-(\d+)\b/g;
|
||||
let m;
|
||||
while ((m = twSide.exec(classList)) !== null) {
|
||||
const n = parseInt(m[2], 10);
|
||||
if (hasRadius && n >= 1) {
|
||||
findings.push({ type: 'side-tab', detail: `${m[0]} + rounded` });
|
||||
} else if (n >= 4) {
|
||||
findings.push({ type: 'side-tab', detail: m[0] });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tailwind top/bottom borders: border-[tb]-N ---
|
||||
const twTB = /\bborder-([tb])-(\d+)\b/g;
|
||||
while ((m = twTB.exec(classList)) !== null) {
|
||||
const n = parseInt(m[2], 10);
|
||||
if (hasRadius && n >= 1) {
|
||||
findings.push({ type: 'border-accent-on-rounded', detail: `${m[0]} + rounded` });
|
||||
}
|
||||
}
|
||||
|
||||
// --- CSS shorthand: border-left/right: Npx solid ---
|
||||
if (!isSafe) {
|
||||
const cssSide = /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi;
|
||||
while ((m = cssSide.exec(inlineStyle)) !== null) {
|
||||
const n = parseInt(m[1], 10);
|
||||
if (isNeutralInline(m[0])) continue; // skip gray/structural borders
|
||||
if (hasRadius && n >= 1) {
|
||||
findings.push({ type: 'side-tab', detail: `${m[0].split(';')[0].trim()} + border-radius` });
|
||||
} else if (n >= 3) {
|
||||
findings.push({ type: 'side-tab', detail: m[0].split(';')[0].trim() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- CSS shorthand: border-top/bottom + border-radius ---
|
||||
const cssTB = /border-(?:top|bottom)\s*:\s*(\d+)px\s+solid[^;]*/gi;
|
||||
while ((m = cssTB.exec(inlineStyle)) !== null) {
|
||||
const n = parseInt(m[1], 10);
|
||||
if (hasRadius && n >= 1) {
|
||||
findings.push({ type: 'border-accent-on-rounded', detail: `${m[0].split(';')[0].trim()} + border-radius` });
|
||||
}
|
||||
}
|
||||
|
||||
// --- CSS longhand: border-left/right-width ---
|
||||
if (!isSafe) {
|
||||
const cssLong = /border-(?:left|right)-width\s*:\s*(\d+)px/gi;
|
||||
while ((m = cssLong.exec(inlineStyle)) !== null) {
|
||||
if (parseInt(m[1], 10) >= 3) {
|
||||
findings.push({ type: 'side-tab', detail: m[0] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- CSS logical: border-inline-start/end ---
|
||||
if (!isSafe) {
|
||||
const cssLogical = /border-inline-(?:start|end)\s*:\s*(\d+)px\s+solid/gi;
|
||||
while ((m = cssLogical.exec(inlineStyle)) !== null) {
|
||||
if (parseInt(m[1], 10) >= 3) {
|
||||
findings.push({ type: 'side-tab', detail: m[0] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Computed style detection (more accurate, for skill/production use)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const SAFE_TAGS_COMPUTED = new Set(['blockquote', 'nav', 'a', 'input', 'textarea', 'select', 'pre', 'code', 'span', 'th', 'td', 'tr', 'li', 'label', 'button', 'hr']);
|
||||
|
||||
function parseColor(color) {
|
||||
const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
|
||||
if (!m) return null;
|
||||
return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };
|
||||
}
|
||||
|
||||
function isTransparent(color) {
|
||||
const c = parseColor(color);
|
||||
return !c || c.a === 0;
|
||||
}
|
||||
|
||||
function isNeutral(color) {
|
||||
const c = parseColor(color);
|
||||
if (!c || c.a === 0) return true;
|
||||
return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) < 30;
|
||||
}
|
||||
|
||||
function scanElementComputed(el) {
|
||||
const findings = [];
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SAFE_TAGS_COMPUTED.has(tag)) return findings;
|
||||
if (SAFE_TAGS.has(tag)) return [];
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width < 20 || rect.height < 20) return findings;
|
||||
if (rect.width < 20 || rect.height < 20) return [];
|
||||
|
||||
const findings = [];
|
||||
const style = getComputedStyle(el);
|
||||
const sides = ['Top', 'Right', 'Bottom', 'Left'];
|
||||
const widths = {};
|
||||
const colors = {};
|
||||
const widths = {}, colors = {};
|
||||
for (const s of sides) {
|
||||
widths[s] = parseFloat(style[`border${s}Width`]) || 0;
|
||||
colors[s] = style[`border${s}Color`];
|
||||
colors[s] = style[`border${s}Color`] || '';
|
||||
}
|
||||
|
||||
const radius = parseFloat(style.borderRadius) || 0;
|
||||
|
||||
for (const side of sides) {
|
||||
const w = widths[side];
|
||||
if (w < 1 || isTransparent(colors[side])) continue;
|
||||
if (w < 1 || isNeutralColor(colors[side])) continue;
|
||||
|
||||
const otherSides = sides.filter(s => s !== side);
|
||||
const maxOther = Math.max(...otherSides.map(s => widths[s]));
|
||||
const isAccent = w >= 2 && (maxOther <= 1 || w >= maxOther * 2);
|
||||
if (!isAccent) continue;
|
||||
const others = sides.filter(s => s !== side);
|
||||
const maxOther = Math.max(...others.map(s => widths[s]));
|
||||
if (!(w >= 2 && (maxOther <= 1 || w >= maxOther * 2))) continue;
|
||||
|
||||
const sn = side.toLowerCase();
|
||||
const isSide = side === 'Left' || side === 'Right';
|
||||
|
||||
if (isSide) {
|
||||
if (radius > 0) {
|
||||
findings.push({ side, type: 'side-tab', detail: `border-${side.toLowerCase()}: ${w}px + border-radius: ${radius}px` });
|
||||
} else if (w >= 3 && !isNeutral(colors[side])) {
|
||||
findings.push({ side, type: 'side-tab', detail: `border-${side.toLowerCase()}: ${w}px (colored)` });
|
||||
} else if (w >= 4) {
|
||||
findings.push({ side, type: 'side-tab', detail: `border-${side.toLowerCase()}: ${w}px` });
|
||||
}
|
||||
if (radius > 0) findings.push({ type: 'side-tab', detail: `border-${sn}: ${w}px + border-radius: ${radius}px` });
|
||||
else if (w >= 3) findings.push({ type: 'side-tab', detail: `border-${sn}: ${w}px` });
|
||||
} else {
|
||||
if (radius > 0) {
|
||||
findings.push({ side, type: 'border-accent-on-rounded', detail: `border-${side.toLowerCase()}: ${w}px + border-radius: ${radius}px` });
|
||||
if (radius > 0 && w >= 2) findings.push({ type: 'border-accent-on-rounded', detail: `border-${sn}: ${w}px + border-radius: ${radius}px` });
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function checkTypography() {
|
||||
const findings = [];
|
||||
|
||||
// Collect fonts from stylesheets
|
||||
const fonts = new Set();
|
||||
const overusedFound = new Set();
|
||||
for (const sheet of document.styleSheets) {
|
||||
let rules;
|
||||
try { rules = sheet.cssRules || sheet.rules; } catch { continue; }
|
||||
if (!rules) continue;
|
||||
for (const rule of rules) {
|
||||
if (rule.type !== 1) continue;
|
||||
const ff = rule.style?.fontFamily;
|
||||
if (!ff) continue;
|
||||
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
||||
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
||||
if (primary) {
|
||||
fonts.add(primary);
|
||||
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Google Fonts links
|
||||
const html = document.documentElement.outerHTML;
|
||||
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
|
||||
let m;
|
||||
while ((m = gfRe.exec(html)) !== null) {
|
||||
for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) {
|
||||
fonts.add(f);
|
||||
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
|
||||
}
|
||||
}
|
||||
|
||||
for (const font of overusedFound) {
|
||||
findings.push({ type: 'overused-font', detail: `Primary font: ${font}` });
|
||||
}
|
||||
|
||||
if (fonts.size === 1 && document.querySelectorAll('*').length > 20) {
|
||||
findings.push({ type: 'single-font', detail: `Only font: ${[...fonts][0]}` });
|
||||
}
|
||||
|
||||
// Flat type hierarchy
|
||||
const sizes = new Set();
|
||||
for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) {
|
||||
const fs = parseFloat(getComputedStyle(el).fontSize);
|
||||
if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10);
|
||||
}
|
||||
if (sizes.size >= 3) {
|
||||
const sorted = [...sizes].sort((a, b) => a - b);
|
||||
const ratio = sorted[sorted.length - 1] / sorted[0];
|
||||
if (ratio < 2.0) {
|
||||
findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
@@ -312,12 +144,16 @@
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const overlays = [];
|
||||
const TYPE_LABELS = {
|
||||
'side-tab': 'side-tab',
|
||||
'border-accent-on-rounded': 'accent+rounded',
|
||||
'overused-font': 'overused font',
|
||||
'single-font': 'single font',
|
||||
'flat-type-hierarchy': 'flat hierarchy',
|
||||
};
|
||||
|
||||
function highlight(el, findings) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const scrollX = window.scrollX;
|
||||
const scrollY = window.scrollY;
|
||||
|
||||
const outline = document.createElement('div');
|
||||
outline.className = 'impeccable-overlay';
|
||||
Object.assign(outline.style, {
|
||||
@@ -335,22 +171,13 @@
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'impeccable-label';
|
||||
const text = findings.map(f => f.type === 'side-tab' ? 'side-tab' : 'accent+rounded').join(', ');
|
||||
label.textContent = text;
|
||||
label.textContent = findings.map(f => TYPE_LABELS[f.type] || f.type).join(', ');
|
||||
Object.assign(label.style, {
|
||||
position: 'absolute',
|
||||
top: '-20px',
|
||||
left: '0',
|
||||
background: LABEL_BG,
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontWeight: '600',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '3px',
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: '16px',
|
||||
letterSpacing: '0.02em',
|
||||
position: 'absolute', top: '-20px', left: '0',
|
||||
background: LABEL_BG, color: 'white',
|
||||
fontSize: '11px', fontFamily: 'system-ui, sans-serif', fontWeight: '600',
|
||||
padding: '2px 8px', borderRadius: '3px', whiteSpace: 'nowrap',
|
||||
lineHeight: '16px', letterSpacing: '0.02em',
|
||||
});
|
||||
outline.appendChild(label);
|
||||
|
||||
@@ -358,19 +185,11 @@
|
||||
tooltip.className = 'impeccable-tooltip';
|
||||
tooltip.innerHTML = findings.map(f => f.detail).join('<br>');
|
||||
Object.assign(tooltip.style, {
|
||||
position: 'absolute',
|
||||
bottom: '-28px',
|
||||
left: '0',
|
||||
background: 'rgba(0,0,0,0.85)',
|
||||
color: '#e5e5e5',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '3px',
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: '16px',
|
||||
display: 'none',
|
||||
zIndex: '100000',
|
||||
position: 'absolute', bottom: '-28px', left: '0',
|
||||
background: 'rgba(0,0,0,0.85)', color: '#e5e5e5',
|
||||
fontSize: '11px', fontFamily: 'ui-monospace, monospace',
|
||||
padding: '4px 8px', borderRadius: '3px', whiteSpace: 'nowrap',
|
||||
lineHeight: '16px', display: 'none', zIndex: '100000',
|
||||
});
|
||||
outline.appendChild(tooltip);
|
||||
|
||||
@@ -389,17 +208,49 @@
|
||||
overlays.push(outline);
|
||||
}
|
||||
|
||||
function showPageBanner(findings) {
|
||||
if (!findings.length) return;
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'impeccable-overlay';
|
||||
Object.assign(banner.style, {
|
||||
position: 'fixed', top: '0', left: '0', right: '0', zIndex: '100000',
|
||||
background: LABEL_BG, color: 'white',
|
||||
fontFamily: 'system-ui, sans-serif', fontSize: '13px',
|
||||
padding: '8px 16px', display: 'flex', flexWrap: 'wrap',
|
||||
gap: '12px', alignItems: 'center', pointerEvents: 'auto',
|
||||
});
|
||||
for (const f of findings) {
|
||||
const tag = document.createElement('span');
|
||||
tag.textContent = `${TYPE_LABELS[f.type] || f.type}: ${f.detail}`;
|
||||
Object.assign(tag.style, {
|
||||
background: 'rgba(255,255,255,0.15)', padding: '2px 8px',
|
||||
borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace',
|
||||
});
|
||||
banner.appendChild(tag);
|
||||
}
|
||||
const close = document.createElement('button');
|
||||
close.textContent = '\u00d7';
|
||||
Object.assign(close.style, {
|
||||
marginLeft: 'auto', background: 'none', border: 'none',
|
||||
color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px',
|
||||
});
|
||||
close.addEventListener('click', () => banner.remove());
|
||||
banner.appendChild(close);
|
||||
document.body.appendChild(banner);
|
||||
overlays.push(banner);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Console summary
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function printSummary(allFindings, mode) {
|
||||
function printSummary(allFindings) {
|
||||
if (allFindings.length === 0) {
|
||||
console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold');
|
||||
return;
|
||||
}
|
||||
console.group(
|
||||
`%c[impeccable] ${allFindings.length} anti-pattern${allFindings.length === 1 ? '' : 's'} found (${mode} mode)`,
|
||||
`%c[impeccable] ${allFindings.length} anti-pattern${allFindings.length === 1 ? '' : 's'} found`,
|
||||
'color: oklch(60% 0.25 350); font-weight: bold'
|
||||
);
|
||||
for (const { el, findings } of allFindings) {
|
||||
@@ -414,47 +265,40 @@
|
||||
// Main scan
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function scan(opts = {}) {
|
||||
const mode = opts.mode || defaultMode;
|
||||
const scanner = mode === 'computed' ? scanElementComputed : scanElementStatic;
|
||||
|
||||
// Remove previous overlays
|
||||
function scan() {
|
||||
for (const o of overlays) o.remove();
|
||||
overlays.length = 0;
|
||||
|
||||
// In static mode, pre-scan <style> blocks to find CSS-rule-based findings
|
||||
const styleBlockFindings = (mode === 'static') ? scanStyleBlocks() : new Map();
|
||||
|
||||
const allFindings = [];
|
||||
const elements = document.querySelectorAll('*');
|
||||
|
||||
for (const el of elements) {
|
||||
// Element-level border checks
|
||||
for (const el of document.querySelectorAll('*')) {
|
||||
if (el.classList.contains('impeccable-overlay') ||
|
||||
el.classList.contains('impeccable-label') ||
|
||||
el.classList.contains('impeccable-tooltip')) continue;
|
||||
|
||||
// Merge per-element findings with style-block findings
|
||||
const findings = scanner(el);
|
||||
const fromStyles = styleBlockFindings.get(el);
|
||||
if (fromStyles) findings.push(...fromStyles);
|
||||
|
||||
const findings = checkElementBorders(el);
|
||||
if (findings.length > 0) {
|
||||
highlight(el, findings);
|
||||
allFindings.push({ el, findings });
|
||||
}
|
||||
}
|
||||
|
||||
printSummary(allFindings, mode);
|
||||
// Page-level typography checks
|
||||
const typoFindings = checkTypography();
|
||||
if (typoFindings.length > 0) {
|
||||
showPageBanner(typoFindings);
|
||||
allFindings.push({ el: document.body, findings: typoFindings });
|
||||
}
|
||||
|
||||
printSummary(allFindings);
|
||||
return allFindings;
|
||||
}
|
||||
|
||||
// Run after DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => setTimeout(scan, 100));
|
||||
} else {
|
||||
setTimeout(scan, 100);
|
||||
}
|
||||
|
||||
// Expose for manual re-scan (supports mode override)
|
||||
window.impeccableScan = scan;
|
||||
})();
|
||||
|
||||
@@ -56,6 +56,21 @@ const server = serve({
|
||||
}
|
||||
return new Response("Not Found", { status: 404 });
|
||||
},
|
||||
// Test fixtures (for browser visual testing)
|
||||
"/fixtures/*": async (req) => {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname.includes('..')) return new Response("Bad Request", { status: 400 });
|
||||
const filePath = `./tests${url.pathname}`;
|
||||
const assetFile = file(filePath);
|
||||
if (await assetFile.exists()) {
|
||||
const ext = url.pathname.split('.').pop();
|
||||
const types = { html: 'text/html', css: 'text/css', js: 'application/javascript' };
|
||||
return new Response(assetFile, {
|
||||
headers: { "Content-Type": types[ext] || "application/octet-stream", "X-Content-Type-Options": "nosniff" }
|
||||
});
|
||||
}
|
||||
return new Response("Not Found", { status: 404 });
|
||||
},
|
||||
"/antipattern-images/*": async (req) => {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname.includes('..')) return new Response("Bad Request", { status: 400 });
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+169
-508
@@ -2,506 +2,213 @@ import { describe, test, expect } from 'bun:test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { detectAntiPatterns, ANTIPATTERNS, walkDir, SCANNABLE_EXTENSIONS } from '../source/skills/critique/scripts/detect-antipatterns.mjs';
|
||||
import {
|
||||
ANTIPATTERNS, checkElementBorders, isNeutralColor,
|
||||
detectHtml, detectText,
|
||||
walkDir, SCANNABLE_EXTENSIONS,
|
||||
} from '../source/skills/critique/scripts/detect-antipatterns.mjs';
|
||||
|
||||
const FIXTURES = path.join(import.meta.dir, 'fixtures', 'antipatterns');
|
||||
const SCRIPT = path.join(import.meta.dir, '..', 'source', 'skills', 'critique', 'scripts', 'detect-antipatterns.mjs');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core detection: Tailwind side-tab
|
||||
// Core: checkElementBorders (computed style simulation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectAntiPatterns — Tailwind side-tab', () => {
|
||||
test('detects border-l-4 (always, thick enough)', () => {
|
||||
const findings = detectAntiPatterns('<div class="border-l-4 border-blue-500">', 'test.html');
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0].antipattern).toBe('side-tab');
|
||||
expect(findings[0].snippet).toBe('border-l-4');
|
||||
describe('checkElementBorders', () => {
|
||||
function mockStyle(overrides) {
|
||||
return { borderTopWidth: '0', borderRightWidth: '0', borderBottomWidth: '0', borderLeftWidth: '0',
|
||||
borderTopColor: '', borderRightColor: '', borderBottomColor: '', borderLeftColor: '',
|
||||
borderRadius: '0', ...overrides };
|
||||
}
|
||||
|
||||
test('detects side-tab with radius', () => {
|
||||
const f = checkElementBorders('div', mockStyle({
|
||||
borderLeftWidth: '4', borderLeftColor: 'rgb(59, 130, 246)', borderRadius: '12',
|
||||
}));
|
||||
expect(f.length).toBe(1);
|
||||
expect(f[0].id).toBe('side-tab');
|
||||
});
|
||||
|
||||
test('detects border-e-8 (always, thick enough)', () => {
|
||||
const findings = detectAntiPatterns('<div class="border-e-8 border-red-500">', 'test.html');
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0].snippet).toBe('border-e-8');
|
||||
test('detects side-tab without radius (thick)', () => {
|
||||
const f = checkElementBorders('div', mockStyle({
|
||||
borderLeftWidth: '4', borderLeftColor: 'rgb(59, 130, 246)',
|
||||
}));
|
||||
expect(f.length).toBe(1);
|
||||
expect(f[0].id).toBe('side-tab');
|
||||
});
|
||||
|
||||
test('ignores border-r-2 without rounded (below threshold)', () => {
|
||||
const findings = detectAntiPatterns('<div class="border-r-2 border-red-400">', 'test.html');
|
||||
expect(findings).toHaveLength(0);
|
||||
test('skips side border below threshold without radius', () => {
|
||||
const f = checkElementBorders('div', mockStyle({
|
||||
borderLeftWidth: '2', borderLeftColor: 'rgb(59, 130, 246)',
|
||||
}));
|
||||
expect(f).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('detects border-r-2 WITH rounded (context-aware)', () => {
|
||||
const findings = detectAntiPatterns('<div class="border-r-2 border-red-400 rounded-lg">', 'test.html');
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0].snippet).toBe('border-r-2');
|
||||
test('detects border-accent-on-rounded (top)', () => {
|
||||
const f = checkElementBorders('div', mockStyle({
|
||||
borderTopWidth: '3', borderTopColor: 'rgb(139, 92, 246)', borderRadius: '12',
|
||||
}));
|
||||
expect(f.length).toBe(1);
|
||||
expect(f[0].id).toBe('border-accent-on-rounded');
|
||||
});
|
||||
|
||||
test('detects border-l-1 with rounded (even thin borders)', () => {
|
||||
const findings = detectAntiPatterns('<div class="border-l-1 border-blue-500 rounded-md">', 'test.html');
|
||||
expect(findings).toHaveLength(1);
|
||||
test('skips safe tags', () => {
|
||||
const f = checkElementBorders('blockquote', mockStyle({
|
||||
borderLeftWidth: '4', borderLeftColor: 'rgb(59, 130, 246)',
|
||||
}));
|
||||
expect(f).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('skips neutral colors', () => {
|
||||
const f = checkElementBorders('div', mockStyle({
|
||||
borderLeftWidth: '4', borderLeftColor: 'rgb(200, 200, 200)',
|
||||
}));
|
||||
expect(f).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('skips uniform borders (not accent)', () => {
|
||||
const f = checkElementBorders('div', mockStyle({
|
||||
borderTopWidth: '2', borderRightWidth: '2', borderBottomWidth: '2', borderLeftWidth: '2',
|
||||
borderTopColor: 'rgb(59, 130, 246)', borderRightColor: 'rgb(59, 130, 246)',
|
||||
borderBottomColor: 'rgb(59, 130, 246)', borderLeftColor: 'rgb(59, 130, 246)',
|
||||
}));
|
||||
expect(f).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isNeutralColor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('isNeutralColor', () => {
|
||||
test('gray is neutral', () => expect(isNeutralColor('rgb(200, 200, 200)')).toBe(true));
|
||||
test('blue is not neutral', () => expect(isNeutralColor('rgb(59, 130, 246)')).toBe(false));
|
||||
test('transparent is neutral', () => expect(isNeutralColor('transparent')).toBe(true));
|
||||
test('null is neutral', () => expect(isNeutralColor(null)).toBe(true));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regex fallback (detectText)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectText — Tailwind side-tab', () => {
|
||||
test('detects border-l-4 (thick, no rounded needed)', () => {
|
||||
const f = detectText('<div class="border-l-4 border-blue-500">', 'test.html');
|
||||
expect(f.some(r => r.antipattern === 'side-tab')).toBe(true);
|
||||
});
|
||||
|
||||
test('detects border-l-1 + rounded', () => {
|
||||
const f = detectText('<div class="border-l-1 border-blue-500 rounded-md">', 'test.html');
|
||||
expect(f.some(r => r.antipattern === 'side-tab')).toBe(true);
|
||||
});
|
||||
|
||||
test('ignores border-l-1 without rounded', () => {
|
||||
const findings = detectAntiPatterns('<div class="border-l-1 border-gray-300">', 'test.html');
|
||||
expect(findings).toHaveLength(0);
|
||||
const f = detectText('<div class="border-l-1 border-gray-300">', 'test.html');
|
||||
expect(f.filter(r => r.antipattern === 'side-tab')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('ignores border-l-0', () => {
|
||||
const findings = detectAntiPatterns('<div class="border-l-0">', 'test.html');
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('detects multiple on same line', () => {
|
||||
const findings = detectAntiPatterns('<div class="border-l-4 border-r-4">', 'test.html');
|
||||
expect(findings).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('does not flag border-t or border-b without rounded', () => {
|
||||
const findings = detectAntiPatterns('<div class="border-t-4 border-b-4">', 'test.html');
|
||||
expect(findings).toHaveLength(0);
|
||||
test('ignores border-t without rounded', () => {
|
||||
const f = detectText('<div class="border-t-4 border-b-4">', 'test.html');
|
||||
expect(f.filter(r => r.antipattern === 'border-accent-on-rounded')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context-aware detection: rounded corners
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectAntiPatterns — rounded context', () => {
|
||||
test('border-s-2 + rounded-xl triggers', () => {
|
||||
const findings = detectAntiPatterns('<div class="border-s-2 border-amber-500 rounded-xl">', 'test.html');
|
||||
expect(findings).toHaveLength(1);
|
||||
describe('detectText — CSS borders', () => {
|
||||
test('detects border-left shorthand', () => {
|
||||
const f = detectText('.card { border-left: 4px solid #3b82f6; }', 'test.css');
|
||||
expect(f.some(r => r.antipattern === 'side-tab')).toBe(true);
|
||||
});
|
||||
|
||||
test('border-l-3 + rounded triggers', () => {
|
||||
const findings = detectAntiPatterns('<div class="border-l-3 rounded bg-white">', 'test.html');
|
||||
expect(findings).toHaveLength(1);
|
||||
test('ignores neutral border', () => {
|
||||
const f = detectText('.card { border-left: 4px solid #e5e7eb; }', 'test.css');
|
||||
expect(f.filter(r => r.antipattern === 'side-tab')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('border-l-3 without rounded does not trigger', () => {
|
||||
const findings = detectAntiPatterns('<div class="border-l-3 bg-white">', 'test.html');
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Safe element exclusions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectAntiPatterns — safe elements', () => {
|
||||
test('skips blockquote', () => {
|
||||
const findings = detectAntiPatterns('<blockquote style="border-left: 4px solid #ccc;">', 'test.html');
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('skips nav link', () => {
|
||||
const findings = detectAntiPatterns('<a href="#" style="border-left: 3px solid blue;">', 'test.html');
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('skips input', () => {
|
||||
const findings = detectAntiPatterns('<input style="border-left: 3px solid red; border-radius: 6px;">', 'test.html');
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('skips code/pre', () => {
|
||||
const findings = detectAntiPatterns('<code style="border-left: 3px solid green;">', 'test.html');
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('skips span (code diff lines)', () => {
|
||||
const findings = detectAntiPatterns('<span style="border-left: 3px solid #ef4444;">', 'test.html');
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('does NOT skip div (still flags)', () => {
|
||||
const findings = detectAntiPatterns('<div style="border-left: 4px solid blue;">', 'test.html');
|
||||
expect(findings).toHaveLength(1);
|
||||
const f = detectText('<blockquote style="border-left: 4px solid #ccc;">', 'test.html');
|
||||
expect(f.filter(r => r.antipattern === 'side-tab')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core detection: CSS shorthand
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectAntiPatterns — CSS shorthand', () => {
|
||||
test('detects border-left: Npx solid', () => {
|
||||
const findings = detectAntiPatterns('.card { border-left: 4px solid #3b82f6; }', 'test.css');
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0].snippet).toContain('border-left');
|
||||
});
|
||||
|
||||
test('detects border-right: Npx solid', () => {
|
||||
const findings = detectAntiPatterns('.card { border-right: 5px solid purple; }', 'test.css');
|
||||
expect(findings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('ignores border-left: 2px solid (below threshold)', () => {
|
||||
const findings = detectAntiPatterns('.card { border-left: 2px solid blue; }', 'test.css');
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('ignores border-top: 4px solid', () => {
|
||||
const findings = detectAntiPatterns('.card { border-top: 4px solid blue; }', 'test.css');
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core detection: CSS longhand
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectAntiPatterns — CSS longhand', () => {
|
||||
test('detects border-left-width: Npx', () => {
|
||||
const findings = detectAntiPatterns('.card { border-left-width: 3px; }', 'test.css');
|
||||
expect(findings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('detects border-right-width: Npx', () => {
|
||||
const findings = detectAntiPatterns('.card { border-right-width: 6px; }', 'test.css');
|
||||
expect(findings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('ignores border-left-width: 2px', () => {
|
||||
const findings = detectAntiPatterns('.card { border-left-width: 2px; }', 'test.css');
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core detection: CSS logical properties
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectAntiPatterns — CSS logical properties', () => {
|
||||
test('detects border-inline-start: Npx solid', () => {
|
||||
const findings = detectAntiPatterns('.card { border-inline-start: 4px solid gold; }', 'test.css');
|
||||
expect(findings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('detects border-inline-end: Npx solid', () => {
|
||||
const findings = detectAntiPatterns('.card { border-inline-end: 3px solid pink; }', 'test.css');
|
||||
expect(findings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('detects border-inline-start-width: Npx', () => {
|
||||
const findings = detectAntiPatterns('.card { border-inline-start-width: 5px; }', 'test.css');
|
||||
expect(findings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('detects border-inline-end-width: Npx', () => {
|
||||
const findings = detectAntiPatterns('.card { border-inline-end-width: 4px; }', 'test.css');
|
||||
expect(findings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('ignores border-inline-start: 2px solid', () => {
|
||||
const findings = detectAntiPatterns('.card { border-inline-start: 2px solid blue; }', 'test.css');
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core detection: JSX inline styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectAntiPatterns — JSX inline styles', () => {
|
||||
test('detects borderLeft with px value', () => {
|
||||
const findings = detectAntiPatterns('borderLeft: "4px solid #3b82f6"', 'test.jsx');
|
||||
expect(findings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('detects borderRight with px value', () => {
|
||||
const findings = detectAntiPatterns("borderRight: '5px solid purple'", 'test.tsx');
|
||||
expect(findings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('ignores borderLeft: 2px (below threshold)', () => {
|
||||
const findings = detectAntiPatterns('borderLeft: "2px solid blue"', 'test.jsx');
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('ignores borderTop', () => {
|
||||
const findings = detectAntiPatterns('borderTop: "4px solid blue"', 'test.jsx');
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Top/bottom + rounded detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectAntiPatterns — border accent on rounded', () => {
|
||||
test('border-t-4 + rounded-lg triggers', () => {
|
||||
const findings = detectAntiPatterns('<div class="border-t-4 border-blue-500 rounded-lg">', 'test.html');
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0].antipattern).toBe('border-accent-on-rounded');
|
||||
});
|
||||
|
||||
test('border-b-2 + rounded-xl triggers', () => {
|
||||
const findings = detectAntiPatterns('<div class="border-b-2 border-purple-500 rounded-xl">', 'test.html');
|
||||
expect(findings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('border-t-1 + rounded triggers (even thin)', () => {
|
||||
const findings = detectAntiPatterns('<div class="border-t-1 border-emerald-500 rounded-md">', 'test.html');
|
||||
expect(findings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('border-t-4 WITHOUT rounded does not trigger', () => {
|
||||
const findings = detectAntiPatterns('<div class="border-t-4 border-blue-500">', 'test.html');
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('border-b-4 WITHOUT rounded does not trigger', () => {
|
||||
const findings = detectAntiPatterns('<div class="border-b-4 border-purple-500">', 'test.html');
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('CSS border-top + border-radius on same line triggers', () => {
|
||||
const findings = detectAntiPatterns('<div style="border-top: 4px solid blue; border-radius: 12px;">', 'test.html');
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0].antipattern).toBe('border-accent-on-rounded');
|
||||
});
|
||||
|
||||
test('CSS border-bottom + border-radius on same line triggers', () => {
|
||||
const findings = detectAntiPatterns('<div style="border-bottom: 3px solid purple; border-radius: 8px;">', 'test.html');
|
||||
expect(findings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('CSS border-top WITHOUT border-radius does not trigger', () => {
|
||||
const findings = detectAntiPatterns('.section { border-top: 4px solid blue; }', 'test.css');
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typography: overused fonts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectAntiPatterns — overused fonts', () => {
|
||||
test('detects Inter as primary font', () => {
|
||||
const findings = detectAntiPatterns("body { font-family: 'Inter', sans-serif; }", 'test.css');
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0].antipattern).toBe('overused-font');
|
||||
});
|
||||
|
||||
test('detects Roboto as primary font', () => {
|
||||
const findings = detectAntiPatterns('body { font-family: Roboto, sans-serif; }', 'test.css');
|
||||
expect(findings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('detects Open Sans', () => {
|
||||
const findings = detectAntiPatterns("body { font-family: 'Open Sans', sans-serif; }", 'test.css');
|
||||
expect(findings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('detects Google Fonts import for Inter', () => {
|
||||
const findings = detectAntiPatterns('<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700" rel="stylesheet">', 'test.html');
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0].snippet).toContain('Inter');
|
||||
describe('detectText — overused fonts', () => {
|
||||
test('detects Inter', () => {
|
||||
const f = detectText("body { font-family: 'Inter', sans-serif; }", 'test.css');
|
||||
expect(f.some(r => r.antipattern === 'overused-font')).toBe(true);
|
||||
});
|
||||
|
||||
test('does not flag distinctive fonts', () => {
|
||||
const findings = detectAntiPatterns("body { font-family: 'Instrument Sans', sans-serif; }", 'test.css');
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('does not flag Inter as fallback (not primary)', () => {
|
||||
const findings = detectAntiPatterns("body { font-family: 'Fraunces', 'Inter', sans-serif; }", 'test.css');
|
||||
expect(findings).toHaveLength(0);
|
||||
const f = detectText("body { font-family: 'Instrument Sans', sans-serif; }", 'test.css');
|
||||
expect(f.filter(r => r.antipattern === 'overused-font')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typography: single font
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectAntiPatterns — single font', () => {
|
||||
test('flags file with only one font', () => {
|
||||
const content = `<html><head><style>
|
||||
body { font-family: 'Poppins', sans-serif; }
|
||||
h1 { font-size: 2rem; }
|
||||
h2 { font-size: 1.5rem; }
|
||||
p { font-size: 1rem; }
|
||||
.card { padding: 1rem; }
|
||||
.hero { padding: 2rem; }
|
||||
.footer { padding: 1rem; }
|
||||
.nav { display: flex; }
|
||||
.sidebar { width: 200px; }
|
||||
.main { flex: 1; }
|
||||
.btn { padding: 0.5rem 1rem; }
|
||||
.input { border: 1px solid #ccc; }
|
||||
.label { font-weight: 500; }
|
||||
.icon { width: 24px; }
|
||||
.grid { display: grid; }
|
||||
.flex { display: flex; }
|
||||
.hidden { display: none; }
|
||||
.visible { display: block; }
|
||||
.text { color: #333; }
|
||||
</style></head><body></body></html>`;
|
||||
const findings = content.split('\n').length >= 20 ?
|
||||
detectAntiPatterns(content, 'test.html').filter(f => f.antipattern === 'single-font') : [];
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0].snippet).toContain('poppins');
|
||||
});
|
||||
|
||||
test('does not flag file with two fonts', () => {
|
||||
const content = `<html><head><style>
|
||||
body { font-family: 'Instrument Sans', sans-serif; }
|
||||
h1 { font-family: 'Fraunces', serif; font-size: 2rem; }
|
||||
h2 { font-size: 1.5rem; }
|
||||
p { font-size: 1rem; }
|
||||
.card { padding: 1rem; }
|
||||
.hero { padding: 2rem; }
|
||||
.footer { padding: 1rem; }
|
||||
.nav { display: flex; }
|
||||
.sidebar { width: 200px; }
|
||||
.main { flex: 1; }
|
||||
.btn { padding: 0.5rem 1rem; }
|
||||
.input { border: 1px solid #ccc; }
|
||||
.label { font-weight: 500; }
|
||||
.icon { width: 24px; }
|
||||
.grid { display: grid; }
|
||||
.flex { display: flex; }
|
||||
.hidden { display: none; }
|
||||
.visible { display: block; }
|
||||
.text { color: #333; }
|
||||
</style></head><body></body></html>`;
|
||||
const findings = detectAntiPatterns(content, 'test.html').filter(f => f.antipattern === 'single-font');
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('does not flag small files', () => {
|
||||
const findings = detectAntiPatterns("body { font-family: 'Poppins', sans-serif; }", 'test.css');
|
||||
const singleFont = findings.filter(f => f.antipattern === 'single-font');
|
||||
expect(singleFont).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typography: flat type hierarchy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectAntiPatterns — flat type hierarchy', () => {
|
||||
test('flags sizes that are too close together', () => {
|
||||
const content = `<style>
|
||||
h1 { font-size: 18px; }
|
||||
h2 { font-size: 16px; }
|
||||
h3 { font-size: 15px; }
|
||||
p { font-size: 14px; }
|
||||
.small { font-size: 13px; }
|
||||
</style>`;
|
||||
const findings = detectAntiPatterns(content, 'test.html').filter(f => f.antipattern === 'flat-type-hierarchy');
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0].snippet).toContain('ratio');
|
||||
describe('detectText — flat type hierarchy', () => {
|
||||
test('flags sizes too close together', () => {
|
||||
const f = detectText('h1{font-size:18px}h2{font-size:16px}h3{font-size:15px}p{font-size:14px}.s{font-size:13px}', 'test.css');
|
||||
expect(f.some(r => r.antipattern === 'flat-type-hierarchy')).toBe(true);
|
||||
});
|
||||
|
||||
test('passes good hierarchy', () => {
|
||||
const content = `<style>
|
||||
h1 { font-size: 48px; }
|
||||
h2 { font-size: 32px; }
|
||||
h3 { font-size: 24px; }
|
||||
p { font-size: 16px; }
|
||||
.small { font-size: 12px; }
|
||||
</style>`;
|
||||
const findings = detectAntiPatterns(content, 'test.html').filter(f => f.antipattern === 'flat-type-hierarchy');
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('handles rem units', () => {
|
||||
const content = `<style>
|
||||
h1 { font-size: 1.125rem; }
|
||||
h2 { font-size: 1rem; }
|
||||
p { font-size: 0.875rem; }
|
||||
</style>`;
|
||||
const findings = detectAntiPatterns(content, 'test.html').filter(f => f.antipattern === 'flat-type-hierarchy');
|
||||
expect(findings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('handles Tailwind text-* classes', () => {
|
||||
const content = '<div class="text-sm">small</div>\n<div class="text-base">base</div>\n<div class="text-lg">large</div>';
|
||||
const findings = detectAntiPatterns(content, 'test.html').filter(f => f.antipattern === 'flat-type-hierarchy');
|
||||
// 14px, 16px, 18px → ratio 1.3:1 → should flag
|
||||
expect(findings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('passes Tailwind with wide range', () => {
|
||||
const content = '<div class="text-sm">small</div>\n<div class="text-base">base</div>\n<div class="text-4xl">heading</div>';
|
||||
const findings = detectAntiPatterns(content, 'test.html').filter(f => f.antipattern === 'flat-type-hierarchy');
|
||||
// 14px, 16px, 36px → ratio 2.6:1 → should pass
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('ignores files with fewer than 3 sizes', () => {
|
||||
const content = '<style>\nh1 { font-size: 18px; }\np { font-size: 16px; }\n</style>';
|
||||
const findings = detectAntiPatterns(content, 'test.html').filter(f => f.antipattern === 'flat-type-hierarchy');
|
||||
expect(findings).toHaveLength(0);
|
||||
const f = detectText('h1{font-size:48px}h2{font-size:32px}p{font-size:16px}.s{font-size:12px}', 'test.css');
|
||||
expect(f.filter(r => r.antipattern === 'flat-type-hierarchy')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture files
|
||||
// jsdom detection (detectHtml)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('fixture file scanning', () => {
|
||||
test('should-flag.html detects side-tabs and accent borders', () => {
|
||||
const content = fs.readFileSync(path.join(FIXTURES, 'should-flag.html'), 'utf-8');
|
||||
const findings = detectAntiPatterns(content, 'should-flag.html');
|
||||
// Tailwind (5) + CSS (7) + top/bottom Tailwind (3) + top/bottom CSS (2)
|
||||
expect(findings.length).toBeGreaterThanOrEqual(13);
|
||||
expect(findings.some(f => f.antipattern === 'side-tab')).toBe(true);
|
||||
expect(findings.some(f => f.antipattern === 'border-accent-on-rounded')).toBe(true);
|
||||
describe('detectHtml — jsdom', () => {
|
||||
test('catches side-tab from inline style', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'should-flag.html'));
|
||||
expect(f.some(r => r.antipattern === 'side-tab')).toBe(true);
|
||||
});
|
||||
|
||||
test('should-pass.html has zero findings', () => {
|
||||
const content = fs.readFileSync(path.join(FIXTURES, 'should-pass.html'), 'utf-8');
|
||||
const findings = detectAntiPatterns(content, 'should-pass.html');
|
||||
expect(findings).toHaveLength(0);
|
||||
test('catches border-accent-on-rounded', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'should-flag.html'));
|
||||
expect(f.some(r => r.antipattern === 'border-accent-on-rounded')).toBe(true);
|
||||
});
|
||||
|
||||
test('typography-should-flag.html detects all three font issues', () => {
|
||||
const content = fs.readFileSync(path.join(FIXTURES, 'typography-should-flag.html'), 'utf-8');
|
||||
const findings = detectAntiPatterns(content, 'typography-should-flag.html');
|
||||
expect(findings.some(f => f.antipattern === 'overused-font')).toBe(true);
|
||||
expect(findings.some(f => f.antipattern === 'single-font')).toBe(true);
|
||||
expect(findings.some(f => f.antipattern === 'flat-type-hierarchy')).toBe(true);
|
||||
test('should-pass has zero border findings', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'should-pass.html'));
|
||||
const borderFindings = f.filter(r => r.antipattern === 'side-tab' || r.antipattern === 'border-accent-on-rounded');
|
||||
expect(borderFindings).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('typography-should-pass.html has zero findings', () => {
|
||||
const content = fs.readFileSync(path.join(FIXTURES, 'typography-should-pass.html'), 'utf-8');
|
||||
const findings = detectAntiPatterns(content, 'typography-should-pass.html');
|
||||
expect(findings).toHaveLength(0);
|
||||
test('catches side-tab from linked stylesheet', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'linked-stylesheet.html'));
|
||||
expect(f.some(r => r.antipattern === 'side-tab')).toBe(true);
|
||||
});
|
||||
|
||||
test('legitimate-borders.html has minimal false positives', () => {
|
||||
const content = fs.readFileSync(path.join(FIXTURES, 'legitimate-borders.html'), 'utf-8');
|
||||
const findings = detectAntiPatterns(content, 'legitimate-borders.html');
|
||||
// Alert banner (colored left border on div) is an acceptable true positive
|
||||
// Blockquotes, nav, inputs, code spans, timeline (gray) should be skipped
|
||||
expect(findings.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Finding structure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('finding structure', () => {
|
||||
test('finding has all required fields', () => {
|
||||
const findings = detectAntiPatterns('<div class="border-l-4 border-blue-500">', 'app.html');
|
||||
expect(findings).toHaveLength(1);
|
||||
const f = findings[0];
|
||||
expect(f.antipattern).toBe('side-tab');
|
||||
expect(f.name).toBe('Side-tab accent border');
|
||||
expect(f.description).toBeTypeOf('string');
|
||||
expect(f.file).toBe('app.html');
|
||||
expect(f.line).toBe(1);
|
||||
expect(f.snippet).toBe('border-l-4');
|
||||
test('catches border-accent-on-rounded from linked stylesheet', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'linked-stylesheet.html'));
|
||||
expect(f.some(r => r.antipattern === 'border-accent-on-rounded')).toBe(true);
|
||||
});
|
||||
|
||||
test('reports correct line numbers', () => {
|
||||
const content = 'line 1\nline 2\n<div class="border-l-4">\nline 4';
|
||||
const findings = detectAntiPatterns(content, 'test.html');
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0].line).toBe(3);
|
||||
test('does not flag clean card from linked stylesheet', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'linked-stylesheet.html'));
|
||||
const cleanFindings = f.filter(r => r.snippet?.includes('clean'));
|
||||
expect(cleanFindings).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('legitimate-borders has minimal false positives', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'legitimate-borders.html'));
|
||||
const borderFindings = f.filter(r => r.antipattern === 'side-tab' || r.antipattern === 'border-accent-on-rounded');
|
||||
// Alert banner is the only expected detection
|
||||
expect(borderFindings.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('typography-should-flag detects all three issues', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'typography-should-flag.html'));
|
||||
expect(f.some(r => r.antipattern === 'overused-font')).toBe(true);
|
||||
expect(f.some(r => r.antipattern === 'single-font')).toBe(true);
|
||||
expect(f.some(r => r.antipattern === 'flat-type-hierarchy')).toBe(true);
|
||||
});
|
||||
|
||||
test('typography-should-pass has zero findings', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'typography-should-pass.html'));
|
||||
expect(f).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -510,8 +217,8 @@ describe('finding structure', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('ANTIPATTERNS registry', () => {
|
||||
test('has at least two entries', () => {
|
||||
expect(ANTIPATTERNS.length).toBeGreaterThanOrEqual(2);
|
||||
test('has at least 5 entries', () => {
|
||||
expect(ANTIPATTERNS.length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
test('each entry has required fields', () => {
|
||||
@@ -519,56 +226,10 @@ describe('ANTIPATTERNS registry', () => {
|
||||
expect(ap.id).toBeTypeOf('string');
|
||||
expect(ap.name).toBeTypeOf('string');
|
||||
expect(ap.description).toBeTypeOf('string');
|
||||
const hasMatchers = ap.matchers && ap.matchers.length > 0;
|
||||
const hasAnalyzers = ap.analyzers && ap.analyzers.length > 0;
|
||||
expect(hasMatchers || hasAnalyzers).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Linked stylesheet detection (--deep mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('linked stylesheet detection', () => {
|
||||
test('regex mode MISSES anti-patterns from linked stylesheets', () => {
|
||||
const content = fs.readFileSync(path.join(FIXTURES, 'linked-stylesheet.html'), 'utf-8');
|
||||
const findings = detectAntiPatterns(content, path.join(FIXTURES, 'linked-stylesheet.html'));
|
||||
// Regex can't see into external-styles.css — finds nothing border-related
|
||||
const borderFindings = findings.filter(f => f.antipattern === 'side-tab' || f.antipattern === 'border-accent-on-rounded');
|
||||
expect(borderFindings).toHaveLength(0);
|
||||
});
|
||||
|
||||
// These tests require jsdom — skip if not available
|
||||
const hasJsdom = (() => { try { require('jsdom'); return true; } catch { return false; } })();
|
||||
const jsdomTest = hasJsdom ? test : test.skip;
|
||||
|
||||
jsdomTest('deep mode CATCHES side-tab from linked stylesheet', async () => {
|
||||
const { detectAntiPatternsDeep } = await import('../source/skills/critique/scripts/detect-antipatterns.mjs');
|
||||
const filePath = path.join(FIXTURES, 'linked-stylesheet.html');
|
||||
const findings = await detectAntiPatternsDeep(filePath);
|
||||
const sideTabs = findings.filter(f => f.antipattern === 'side-tab');
|
||||
expect(sideTabs.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
jsdomTest('deep mode CATCHES top accent from linked stylesheet', async () => {
|
||||
const { detectAntiPatternsDeep } = await import('../source/skills/critique/scripts/detect-antipatterns.mjs');
|
||||
const filePath = path.join(FIXTURES, 'linked-stylesheet.html');
|
||||
const findings = await detectAntiPatternsDeep(filePath);
|
||||
const accents = findings.filter(f => f.antipattern === 'border-accent-on-rounded');
|
||||
expect(accents.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
jsdomTest('deep mode does NOT flag clean card from linked stylesheet', async () => {
|
||||
const { detectAntiPatternsDeep } = await import('../source/skills/critique/scripts/detect-antipatterns.mjs');
|
||||
const filePath = path.join(FIXTURES, 'linked-stylesheet.html');
|
||||
const findings = await detectAntiPatternsDeep(filePath);
|
||||
// Should not flag the .external-clean card
|
||||
const cleanFindings = findings.filter(f => f.snippet && f.snippet.includes('clean'));
|
||||
expect(cleanFindings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// walkDir
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -580,9 +241,8 @@ describe('walkDir', () => {
|
||||
expect(files.every(f => SCANNABLE_EXTENSIONS.has(path.extname(f)))).toBe(true);
|
||||
});
|
||||
|
||||
test('returns empty array for nonexistent dir', () => {
|
||||
const files = walkDir('/nonexistent/path/12345');
|
||||
expect(files).toHaveLength(0);
|
||||
test('returns empty for nonexistent dir', () => {
|
||||
expect(walkDir('/nonexistent/path/12345')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -592,49 +252,50 @@ describe('walkDir', () => {
|
||||
|
||||
describe('CLI', () => {
|
||||
function run(...args) {
|
||||
const result = spawnSync('node', [SCRIPT, ...args], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
});
|
||||
const result = spawnSync('node', [SCRIPT, ...args], { encoding: 'utf-8', timeout: 15000 });
|
||||
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status };
|
||||
}
|
||||
|
||||
test('--help exits 0 and shows usage', () => {
|
||||
test('--help exits 0', () => {
|
||||
const { stdout, code } = run('--help');
|
||||
expect(code).toBe(0);
|
||||
expect(stdout).toContain('Usage:');
|
||||
});
|
||||
|
||||
test('clean file exits 0', () => {
|
||||
test('should-pass exits 0', () => {
|
||||
const { code } = run(path.join(FIXTURES, 'should-pass.html'));
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test('file with anti-patterns exits 2', () => {
|
||||
test('should-flag exits 2 with findings', () => {
|
||||
const { code, stderr } = run(path.join(FIXTURES, 'should-flag.html'));
|
||||
expect(code).toBe(2);
|
||||
expect(stderr).toContain('side-tab');
|
||||
});
|
||||
|
||||
test('--json outputs valid JSON array', () => {
|
||||
test('--json outputs valid JSON', () => {
|
||||
const { stderr, code } = run('--json', path.join(FIXTURES, 'should-flag.html'));
|
||||
expect(code).toBe(2);
|
||||
const parsed = JSON.parse(stderr.trim());
|
||||
expect(parsed).toBeArray();
|
||||
expect(parsed.length).toBeGreaterThan(0);
|
||||
expect(parsed[0].antipattern).toBe('side-tab');
|
||||
});
|
||||
|
||||
test('--json on clean file outputs empty array on stdout', () => {
|
||||
test('--json on clean file outputs empty array', () => {
|
||||
const { stdout, code } = run('--json', path.join(FIXTURES, 'should-pass.html'));
|
||||
expect(code).toBe(0);
|
||||
expect(JSON.parse(stdout.trim())).toEqual([]);
|
||||
});
|
||||
|
||||
test('scans directory recursively', () => {
|
||||
const { code, stderr } = run(FIXTURES);
|
||||
test('--fast mode works', () => {
|
||||
const { code } = run('--fast', path.join(FIXTURES, 'should-flag.html'));
|
||||
expect(code).toBe(2);
|
||||
expect(stderr).toContain('anti-pattern');
|
||||
});
|
||||
|
||||
test('linked stylesheet detected (jsdom default)', () => {
|
||||
const { code, stderr } = run(path.join(FIXTURES, 'linked-stylesheet.html'));
|
||||
expect(code).toBe(2);
|
||||
expect(stderr).toContain('side-tab');
|
||||
});
|
||||
|
||||
test('warns on nonexistent path', () => {
|
||||
|
||||
+1
-1
@@ -108,6 +108,6 @@
|
||||
<strong>Warning:</strong> Your trial expires in 3 days. <a href="#" style="color: #d97706;">Upgrade now</a>
|
||||
</div>
|
||||
</div>
|
||||
<script src="../../../public/js/detect-antipatterns-browser.js"></script>
|
||||
<script src="/js/detect-antipatterns-browser.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1
-1
@@ -43,6 +43,6 @@
|
||||
<p style="font-size: 0.875rem; color: #6b7280;">Uniform 1px border — should NOT flag.</p>
|
||||
</div>
|
||||
</div>
|
||||
<script src="../../../public/js/detect-antipatterns-browser.js"></script>
|
||||
<script src="/js/detect-antipatterns-browser.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1
-1
@@ -131,6 +131,6 @@
|
||||
<p style="font-size: 0.8125rem; color: #9ca3af;">Inline dark card with side-tab.</p>
|
||||
</div>
|
||||
</div>
|
||||
<script src="../../../public/js/detect-antipatterns-browser.js"></script>
|
||||
<script src="/js/detect-antipatterns-browser.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1
-1
@@ -79,6 +79,6 @@
|
||||
<p style="font-size: 0.875rem; color: #94a3b8; margin-top: 0.25rem;">Shadow only. Clean.</p>
|
||||
</div>
|
||||
</div>
|
||||
<script src="../../../public/js/detect-antipatterns-browser.js"></script>
|
||||
<script src="/js/detect-antipatterns-browser.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1
-1
@@ -34,6 +34,6 @@
|
||||
|
||||
<h3>A Subheading</h3>
|
||||
<p>Can you tell this is a subheading? Exactly.</p>
|
||||
<script src="../../../public/js/detect-antipatterns-browser.js"></script>
|
||||
<script src="/js/detect-antipatterns-browser.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1
-1
@@ -40,6 +40,6 @@
|
||||
<h3>Strong Size Hierarchy</h3>
|
||||
<p>Sizes range from 12px to 48px — a 4:1 ratio with clear visual steps.</p>
|
||||
<p class="caption">Caption text is clearly distinct from body.</p>
|
||||
<script src="../../../public/js/detect-antipatterns-browser.js"></script>
|
||||
<script src="/js/detect-antipatterns-browser.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user