mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-21 18:47:02 +03:00
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>
306 lines
12 KiB
JavaScript
306 lines
12 KiB
JavaScript
import { describe, test, expect } from 'bun:test';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { spawnSync } from 'child_process';
|
|
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: checkElementBorders (computed style simulation)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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 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('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-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('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 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-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);
|
|
});
|
|
});
|
|
|
|
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('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('skips blockquote', () => {
|
|
const f = detectText('<blockquote style="border-left: 4px solid #ccc;">', 'test.html');
|
|
expect(f.filter(r => r.antipattern === 'side-tab')).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
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 f = detectText("body { font-family: 'Instrument Sans', sans-serif; }", 'test.css');
|
|
expect(f.filter(r => r.antipattern === 'overused-font')).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
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 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);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// jsdom detection (detectHtml)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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('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('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('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('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('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);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ANTIPATTERNS registry
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('ANTIPATTERNS registry', () => {
|
|
test('has at least 5 entries', () => {
|
|
expect(ANTIPATTERNS.length).toBeGreaterThanOrEqual(5);
|
|
});
|
|
|
|
test('each entry has required fields', () => {
|
|
for (const ap of ANTIPATTERNS) {
|
|
expect(ap.id).toBeTypeOf('string');
|
|
expect(ap.name).toBeTypeOf('string');
|
|
expect(ap.description).toBeTypeOf('string');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// walkDir
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('walkDir', () => {
|
|
test('finds scannable files', () => {
|
|
const files = walkDir(FIXTURES);
|
|
expect(files.length).toBeGreaterThanOrEqual(3);
|
|
expect(files.every(f => SCANNABLE_EXTENSIONS.has(path.extname(f)))).toBe(true);
|
|
});
|
|
|
|
test('returns empty for nonexistent dir', () => {
|
|
expect(walkDir('/nonexistent/path/12345')).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CLI integration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('CLI', () => {
|
|
function run(...args) {
|
|
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', () => {
|
|
const { stdout, code } = run('--help');
|
|
expect(code).toBe(0);
|
|
expect(stdout).toContain('Usage:');
|
|
});
|
|
|
|
test('should-pass exits 0', () => {
|
|
const { code } = run(path.join(FIXTURES, 'should-pass.html'));
|
|
expect(code).toBe(0);
|
|
});
|
|
|
|
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', () => {
|
|
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);
|
|
});
|
|
|
|
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('--fast mode works', () => {
|
|
const { code } = run('--fast', path.join(FIXTURES, 'should-flag.html'));
|
|
expect(code).toBe(2);
|
|
});
|
|
|
|
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', () => {
|
|
const { stderr } = run('/nonexistent/file/xyz.html');
|
|
expect(stderr).toContain('Warning');
|
|
});
|
|
});
|