detect-antipatterns-browser.test.mjs

 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});