mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
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>
111 lines
4.9 KiB
JavaScript
111 lines
4.9 KiB
JavaScript
/**
|
||
* 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))}`);
|
||
});
|
||
});
|