Files
pbakaus_impeccable/tests/detect-antipatterns-browser.test.mjs
T
Paul BakausandClaude Opus 4.7 b9bf496e35 detector: body-text-viewport-edge rule + OKLCH/var-resolution + anchor-inherit FP fixes
New rule: body-text-viewport-edge flags body paragraphs that render flush
against the left/right viewport edges (no container padding). Tested via
the new tests/fixtures/antipatterns/body-text-viewport-edge.html fixture
(3 flag cases, 5 pass cases) and the test in detect-antipatterns-browser.

False-positive class fixes — all jsdom-mode only (real browsers resolve
the cascade correctly so these gates stay inert there). Five related
gaps that compounded into ~14× spurious contrast findings on Tailwind v4
pages with OKLCH color tokens:

  • OKLCH parser. jsdom returns the literal "oklch(...)" string from
    getComputedStyle; the detector now converts to sRGB via Björn
    Ottosson's matrices. Handles Tailwind v4's compact minified form
    "oklch(21.5%.02 50)" (no space after %).
  • var() resolution. resolveBackground + checkElementColors now
    accept the existing customPropMap and parse `var(--color-paper)`
    etc. as proper RGB via the new parseColorResolved helper.
  • bg-color before bg-image. The old order bailed on any gradient
    ancestor before checking for a solid background-color underneath,
    causing the body's decorative paper-grain gradient to be measured
    against instead of the page's actual `bg-paper` cream.
  • body/html-level gradient → white fallback. When the only opaque
    ancestor we can read is body/html with a gradient overlay (and
    jsdom can't decompose `background: var(--paper) gradient` to
    extract the solid color), return white instead of falling through
    to resolveGradientStops — which was picking up paper-grain noise
    colors and using them as the bg.
  • Anchor-inherit workaround for jsdom :link UA specificity.
    Tailwind v4's preflight declares `a { color: inherit }` (0,0,1).
    jsdom's UA stylesheet has `:link { color: blue }` at (0,1,1) and
    wins the cascade. Real Chrome wraps :link in :where() (0,0,0) so
    the page rule wins. When the page declares the inherit rule AND
    we see jsdom's default `rgb(0,0,238)` on an anchor, walk to the
    nearest non-anchor ancestor and use its color.
  • Alpha-fallback safety gate. When text has alpha<1 AND we couldn't
    find an opaque ancestor (effectiveBg null), skip the contrast
    finding. Covers any remaining FP class the deeper fixes miss.

Verified end-to-end against an Opus iter-1 artifact on Tailwind v4 with
14 cream/cream FPs + 2 blue-link UA FPs before; 0 findings after, while
the color.html fixture's 12 real low-contrast cases continue to flag
(verified via direct detectHtml calls).

cli/engine/detect-antipatterns-browser.js is the generated browser
distribution — regenerated from .mjs via scripts/build-browser-detector.js
(no manual edits to the generated file).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:14:47 -07:00

111 lines
4.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Puppeteer-backed fixture tests for browser-only detection rules.
*
* Some detection rules (cramped-padding, line-length, tight-leading,
* skipped-heading, justified-text, tiny-text, all-caps-body, wide-tracking,
* small-target) need real browser layout — they read getBoundingClientRect
* and getComputedStyle results that jsdom can't compute. Those rules can't
* be tested with the jsdom suite in detect-antipatterns-fixtures.test.mjs.
*
* This file uses detectUrl() (Puppeteer) to load fixtures in headless Chrome
* via a temporary static HTTP server, so the fixtures can use absolute
* <script src="/js/..."> paths just like in development.
*
* Run via Node's built-in test runner:
* node --test tests/detect-antipatterns-browser.test.mjs
*/
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { detectUrl } from '../cli/engine/detect-antipatterns.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const PORT = 8765;
const BASE = `http://localhost:${PORT}`;
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
};
let server;
before(async () => {
// Static server: maps /fixtures/* to tests/fixtures/* and
// /js/detect-antipatterns-browser.js to cli/engine/detect-antipatterns-browser.js
// (mirrors what Astro serves so fixtures can use absolute paths)
server = http.createServer((req, res) => {
let filePath;
if (req.url.startsWith('/fixtures/')) {
filePath = path.join(ROOT, 'tests', req.url);
} else if (req.url === '/js/detect-antipatterns-browser.js') {
filePath = path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js');
} else {
res.writeHead(404).end();
return;
}
try {
const body = fs.readFileSync(filePath);
const ext = path.extname(filePath);
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
res.end(body);
} catch {
res.writeHead(404).end();
}
});
await new Promise((resolve) => server.listen(PORT, resolve));
});
after(async () => {
await new Promise((resolve) => server.close(resolve));
});
describe('detectUrl — browser-only fixtures', () => {
// Only two rules genuinely need real browser layout (getBoundingClientRect):
// line-length → reads rect.width to compute chars-per-line
// cramped-padding → reads rect.width/height to filter small badges
// Everything else in the quality.html fixture runs in jsdom and is asserted
// by tests/detect-antipatterns-fixtures.test.mjs.
it('cramped-padding: flag column triggers all 8 cramped cases, pass column adds none', async () => {
const f = await detectUrl(`${BASE}/fixtures/antipatterns/cramped-padding.html`);
const cramped = f.filter(r => r.antipattern === 'cramped-padding');
// Flag column has 8 cases that should fire under the asymmetric
// proportional rule (vertical: max(4, fs×0.3), horizontal: max(8, fs×0.5)):
// 1. 14px body / 4px all sides — V fail
// 2. 14px body / 2px all sides — both fail
// 3. 16px body / 4px all sides — both fail
// 4. 14px body / 1px V / 16px H — V fail
// 5. 14px body / 12px V / 4px H — H fail
// 6. 24px heading / 8px all sides — H fail (improvement over old 8px floor)
// 7. 32px hero / 6px V / 16px H — V fail
// 8. 14px <pre> / 2px all sides — both fail
// Pass column has 12 cases (small pills, standard cards, code blocks,
// buttons, inputs, big text with proportional padding) — none should fire.
assert.equal(cramped.length, 8, `expected 8 cramped-padding findings, got ${cramped.length}`);
});
it('line-length: flag column triggers, pass column adds none', async () => {
const f = await detectUrl(`${BASE}/fixtures/antipatterns/quality.html`);
assert.equal(f.filter(r => r.antipattern === 'line-length').length, 1);
});
it('body-text-viewport-edge: 3 flag paragraphs/list-items, 0 pass cases', async () => {
const f = await detectUrl(`${BASE}/fixtures/antipatterns/body-text-viewport-edge.html`);
const edges = f.filter(r => r.antipattern === 'body-text-viewport-edge');
// Fixture has 3 escape-styled <p>/<li> paragraphs that bleed to
// the viewport edges. The pass column has 5 paragraphs that
// should not fire (centered container, inside nav, inside header,
// inside section with own background, short label < 40 chars).
assert.equal(edges.length, 3, `expected 3 body-text-viewport-edge findings, got ${edges.length}: ${JSON.stringify(edges.map(e => e.snippet))}`);
});
});