', '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('
', '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('
', '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 = ``;
- 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 = ``;
- 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 = ``;
- 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 = ``;
- const findings = detectAntiPatterns(content, 'test.html').filter(f => f.antipattern === 'flat-type-hierarchy');
- expect(findings).toHaveLength(0);
- });
-
- test('handles rem units', () => {
- const content = ``;
- const findings = detectAntiPatterns(content, 'test.html').filter(f => f.antipattern === 'flat-type-hierarchy');
- expect(findings).toHaveLength(1);
- });
-
- test('handles Tailwind text-* classes', () => {
- const content = '
small
\n
base
\n
large
';
- 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 = '
small
\n
base
\n
heading
';
- 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 = '';
- 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('
', '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
\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', () => {
diff --git a/tests/fixtures/antipatterns/legitimate-borders.html b/tests/fixtures/antipatterns/legitimate-borders.html
index 115b8ede0..4b89da377 100644
--- a/tests/fixtures/antipatterns/legitimate-borders.html
+++ b/tests/fixtures/antipatterns/legitimate-borders.html
@@ -108,6 +108,6 @@
Warning: Your trial expires in 3 days.
Upgrade now
-
+