1/**
2 * Puppeteer-backed fixture tests for browser-only detection rules.
3 *
4 * Some detection rules (cramped-padding, line-length, tight-leading,
5 * skipped-heading, justified-text, tiny-text, all-caps-body, wide-tracking,
6 * small-target) need real browser layout — they read getBoundingClientRect
7 * and getComputedStyle results that jsdom can't compute. Those rules can't
8 * be tested with the jsdom suite in detect-antipatterns-fixtures.test.mjs.
9 *
10 * This file uses detectUrl() (Puppeteer) to load fixtures in headless Chrome
11 * via a temporary static HTTP server, so the fixtures can use absolute
12 * <script src="/js/..."> paths just like in development.
13 *
14 * Run via Node's built-in test runner:
15 * node --test tests/detect-antipatterns-browser.test.mjs
16 */
17import { describe, it, before, after } from 'node:test';
18import assert from 'node:assert/strict';
19import http from 'node:http';
20import fs from 'node:fs';
21import path from 'node:path';
22import { fileURLToPath } from 'node:url';
23import { detectUrl } from '../src/detect-antipatterns.mjs';
24
25const __dirname = path.dirname(fileURLToPath(import.meta.url));
26const ROOT = path.resolve(__dirname, '..');
27const PORT = 8765;
28const BASE = `http://localhost:${PORT}`;
29
30const MIME = {
31 '.html': 'text/html; charset=utf-8',
32 '.js': 'text/javascript; charset=utf-8',
33 '.css': 'text/css; charset=utf-8',
34 '.svg': 'image/svg+xml',
35 '.png': 'image/png',
36 '.jpg': 'image/jpeg',
37};
38
39let server;
40
41before(async () => {
42 // Static server: maps /fixtures/* to tests/fixtures/* and /js/* to public/js/*
43 // (mirrors the routes in server/index.js so fixtures can use absolute paths)
44 server = http.createServer((req, res) => {
45 let filePath;
46 if (req.url.startsWith('/fixtures/')) {
47 filePath = path.join(ROOT, 'tests', req.url);
48 } else if (req.url === '/js/detect-antipatterns-browser.js') {
49 filePath = path.join(ROOT, 'src/detect-antipatterns-browser.js');
50 } else {
51 res.writeHead(404).end();
52 return;
53 }
54 try {
55 const body = fs.readFileSync(filePath);
56 const ext = path.extname(filePath);
57 res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
58 res.end(body);
59 } catch {
60 res.writeHead(404).end();
61 }
62 });
63 await new Promise((resolve) => server.listen(PORT, resolve));
64});
65
66after(async () => {
67 await new Promise((resolve) => server.close(resolve));
68});
69
70describe('detectUrl — browser-only fixtures', () => {
71 // Only two rules genuinely need real browser layout (getBoundingClientRect):
72 // line-length → reads rect.width to compute chars-per-line
73 // cramped-padding → reads rect.width/height to filter small badges
74 // Everything else in the quality.html fixture runs in jsdom and is asserted
75 // by tests/detect-antipatterns-fixtures.test.mjs.
76
77 it('cramped-padding: flag column triggers all 8 cramped cases, pass column adds none', async () => {
78 const f = await detectUrl(`${BASE}/fixtures/antipatterns/cramped-padding.html`);
79 const cramped = f.filter(r => r.antipattern === 'cramped-padding');
80 // Flag column has 8 cases that should fire under the asymmetric
81 // proportional rule (vertical: max(4, fs×0.3), horizontal: max(8, fs×0.5)):
82 // 1. 14px body / 4px all sides — V fail
83 // 2. 14px body / 2px all sides — both fail
84 // 3. 16px body / 4px all sides — both fail
85 // 4. 14px body / 1px V / 16px H — V fail
86 // 5. 14px body / 12px V / 4px H — H fail
87 // 6. 24px heading / 8px all sides — H fail (improvement over old 8px floor)
88 // 7. 32px hero / 6px V / 16px H — V fail
89 // 8. 14px <pre> / 2px all sides — both fail
90 // Pass column has 12 cases (small pills, standard cards, code blocks,
91 // buttons, inputs, big text with proportional padding) — none should fire.
92 assert.equal(cramped.length, 8, `expected 8 cramped-padding findings, got ${cramped.length}`);
93 });
94
95 it('line-length: flag column triggers, pass column adds none', async () => {
96 const f = await detectUrl(`${BASE}/fixtures/antipatterns/quality.html`);
97 assert.equal(f.filter(r => r.antipattern === 'line-length').length, 1);
98 });
99});