mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
* Fix flat type hierarchy false positives Use rendered semantic roles and dominant size frequency, align the adjacent-step guidance, and abstain in source-only scans.\n\nAI assistance: prepared with Codex under maintainer direction. * Fix static hidden typography filtering Honor the hidden attribute in the static wrapper and use raw browser findings in regression coverage. AI assistance: prepared with Codex under maintainer direction. * Align typography sampling with painted content Count visibly painted aria-hidden text and exclude content-visibility hidden subtrees in both static and browser scans. AI assistance: prepared with Codex under maintainer direction.
1654 lines
76 KiB
JavaScript
1654 lines
76 KiB
JavaScript
/**
|
||
* Static HTML/CSS fixture tests for anti-pattern detection.
|
||
* Run via Node's built-in test runner (not bun).
|
||
*
|
||
* Usage: node --test tests/detect-antipatterns-fixtures.test.mjs
|
||
*/
|
||
import { describe, it } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import fs from 'node:fs';
|
||
import os from 'node:os';
|
||
import path from 'path';
|
||
import { fileURLToPath } from 'url';
|
||
import {
|
||
detectHtml,
|
||
detectText,
|
||
formatFindings,
|
||
normalizeDesignSystem,
|
||
} from '../cli/engine/detect-antipatterns.mjs';
|
||
import { checkEmDashOveruse } from '../cli/engine/rules/checks.mjs';
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
const FIXTURES = path.join(__dirname, 'fixtures', 'antipatterns');
|
||
|
||
function isolatedFixtureCases(name) {
|
||
const source = fs.readFileSync(path.join(FIXTURES, name), 'utf8');
|
||
const style = source.match(/<style>([\s\S]*?)<\/style>/i)?.[1] || '';
|
||
const cases = [];
|
||
for (const match of source.matchAll(/<article\b([^>]*)>([\s\S]*?)<\/article>/gi)) {
|
||
const attrs = match[1];
|
||
const caseName = attrs.match(/\bdata-case="([^"]+)"/i)?.[1];
|
||
const expect = attrs.match(/\bdata-expect="(flag|pass)"/i)?.[1];
|
||
if (!caseName || !expect) continue;
|
||
cases.push({
|
||
caseName,
|
||
expect,
|
||
html: `<!DOCTYPE html><html><head><style>${style}</style></head><body><article${attrs}>${match[2]}</article></body></html>`,
|
||
});
|
||
}
|
||
return cases;
|
||
}
|
||
|
||
describe('flat-type-hierarchy — role and usage fixture (issue #619)', () => {
|
||
it('flags compressed document roles and passes dense UI/chrome shapes', async () => {
|
||
const cases = isolatedFixtureCases('flat-type-hierarchy.html');
|
||
assert.equal(cases.filter(item => item.expect === 'flag').length, 5);
|
||
assert.equal(cases.filter(item => item.expect === 'pass').length, 6);
|
||
|
||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-flat-type-'));
|
||
try {
|
||
for (const [index, item] of cases.entries()) {
|
||
const file = path.join(tempDir, `case-${index}.html`);
|
||
fs.writeFileSync(file, item.html);
|
||
const findings = await detectHtml(file);
|
||
const flat = findings.filter(finding => finding.antipattern === 'flat-type-hierarchy');
|
||
if (item.expect === 'flag') {
|
||
assert.equal(flat.length, 1, `expected "${item.caseName}" to flag: ${JSON.stringify(findings)}`);
|
||
} else {
|
||
assert.equal(flat.length, 0, `expected "${item.caseName}" to pass: ${JSON.stringify(flat)}`);
|
||
}
|
||
}
|
||
} finally {
|
||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('detectText - Astro structural CSS fixtures', () => {
|
||
const SHOULD_FLAG = [
|
||
'Kinpaku Edge',
|
||
'Patina Edge',
|
||
'Accent Edge',
|
||
'Signal Blue Edge',
|
||
'Chromatic Hex Edge',
|
||
'Named Red Edge',
|
||
'Chromatic Rgb Edge',
|
||
'Chromatic Oklch Edge',
|
||
// `inset` may follow the offsets/color. Requiring it first missed the same
|
||
// stripe written the other legal way.
|
||
'Trailing Inset Edge',
|
||
'Trailing Inset Token Edge',
|
||
'Inset Named Token Edge',
|
||
// Only the two offsets are required; blur/spread default to 0.
|
||
'Two Length Edge',
|
||
'Important Edge',
|
||
'Cascade Override Edge',
|
||
'Color First Edge',
|
||
'Color First Var Edge',
|
||
'Two Length Trailing Inset Edge',
|
||
];
|
||
const SHOULD_PASS = [
|
||
'Neutral Shadow Token',
|
||
'Current Color Edge',
|
||
'Selected State Edge',
|
||
'Hairline Edge',
|
||
'Thick Fill Edge',
|
||
'Blurred Edge',
|
||
'Narrow Artwork',
|
||
// Authored CSS spells neutrals as hex and keywords. isNeutralColor only
|
||
// parses the computed function forms and reports everything else as
|
||
// chromatic, so routing these through it flagged plain black and gray
|
||
// hairlines as the "colored stripe" AI tell.
|
||
'Black Hex Edge',
|
||
'Black Named Edge',
|
||
'Gray Hex Edge',
|
||
'Dimgray Named Edge',
|
||
'Black Rgb Edge',
|
||
'Shorthand Neutral Hex Edge',
|
||
// Commented-out CSS is not a live rule.
|
||
'Commented Out Edge',
|
||
// Trailing `inset` still respects the neutral-color exemption.
|
||
'Trailing Inset Neutral Edge',
|
||
// The short form still respects the neutral and blur exclusions.
|
||
'Two Length Neutral Edge',
|
||
'Space Rgb Neutral Edge',
|
||
'Cascade Cancelled Edge',
|
||
'Two Length Blurred Edge',
|
||
];
|
||
|
||
it('Astro style blocks flag unresolved chromatic inset stripes only', () => {
|
||
const filePath = path.join(FIXTURES, 'astro-inset-shadow-stripe.astro');
|
||
const source = fs.readFileSync(filePath, 'utf8');
|
||
const findings = detectText(source, filePath).filter(r => r.antipattern === 'side-tab');
|
||
const snippets = findings.map(r => r.snippet || '').join(' | ');
|
||
for (const heading of SHOULD_FLAG) {
|
||
assert.match(snippets, new RegExp(`data-case=${JSON.stringify(heading)}`), `expected "${heading}" to flag`);
|
||
}
|
||
for (const heading of SHOULD_PASS) {
|
||
assert.doesNotMatch(snippets, new RegExp(`data-case=${JSON.stringify(heading)}`), `"${heading}" should pass`);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('detectText — pseudo-element stripe fixtures (issue #394)', () => {
|
||
// The side-tab silhouette drawn as an absolutely-positioned ::before/::after
|
||
// bar instead of a border. The scanner already ran on full HTML pages via
|
||
// checkHtmlPatterns; these pin the standalone-stylesheet and component
|
||
// style-block paths, which used to pass this construction clean.
|
||
const SHOULD_FLAG = [
|
||
'Inset Shorthand Left Edge',
|
||
'Longhand Left Edge',
|
||
'Bottom Edge',
|
||
'Full Height Right Edge',
|
||
];
|
||
const SHOULD_PASS = [
|
||
'Neutral Divider',
|
||
'Wide Panel',
|
||
'Static Underline',
|
||
'Hairline Divider',
|
||
'Hover Underline',
|
||
'Floating Badge',
|
||
// Commented-out CSS is not a live rule.
|
||
'Commented Out Stripe',
|
||
];
|
||
|
||
// The 1-based line a case's selector sits on in a fixture file, so the
|
||
// reported finding line can be checked against the actual source.
|
||
const selectorLine = (source, caseName) => {
|
||
const idx = source.split('\n').findIndex(l => l.includes(`data-case="${caseName}"`));
|
||
assert.notEqual(idx, -1, `fixture is missing case "${caseName}"`);
|
||
return idx + 1;
|
||
};
|
||
|
||
it('standalone .css files flag chromatic pseudo-element stripes only', () => {
|
||
const filePath = path.join(FIXTURES, 'pseudo-stripe.css');
|
||
const source = fs.readFileSync(filePath, 'utf8');
|
||
const findings = detectText(source, filePath).filter(r => r.antipattern === 'side-tab');
|
||
const snippets = findings.map(r => r.snippet || '').join(' | ');
|
||
for (const heading of SHOULD_FLAG) {
|
||
assert.match(snippets, new RegExp(`data-case=${JSON.stringify(heading)}`), `expected "${heading}" to flag`);
|
||
}
|
||
for (const heading of SHOULD_PASS) {
|
||
assert.doesNotMatch(snippets, new RegExp(`data-case=${JSON.stringify(heading)}`), `"${heading}" should pass`);
|
||
}
|
||
// Every finding must carry the selector's real source line, so
|
||
// line-scoped inline ignores (impeccable-disable-line and
|
||
// impeccable-disable-next-line) can match it.
|
||
for (const f of findings) {
|
||
const caseName = (f.snippet.match(/data-case="([^"]+)"/) || [])[1];
|
||
assert.equal(
|
||
f.line, selectorLine(source, caseName),
|
||
`finding for "${caseName}" reports line ${f.line}, selector sits on line ${selectorLine(source, caseName)}`,
|
||
);
|
||
}
|
||
});
|
||
|
||
it('component style blocks flag pseudo-element stripes at their source line', () => {
|
||
const filePath = path.join(FIXTURES, 'pseudo-stripe.vue');
|
||
const source = fs.readFileSync(filePath, 'utf8');
|
||
const findings = detectText(source, filePath).filter(r => r.antipattern === 'side-tab');
|
||
const snippets = findings.map(r => r.snippet || '').join(' | ');
|
||
assert.match(snippets, /data-case="Component Left Edge"/, 'expected the component stripe to flag');
|
||
assert.doesNotMatch(snippets, /data-case="Component Neutral Divider"/, 'neutral divider should pass');
|
||
const stripe = findings.find(r => /data-case="Component Left Edge"/.test(r.snippet || ''));
|
||
assert.equal(
|
||
stripe.line, selectorLine(source, 'Component Left Edge'),
|
||
'style-block finding must map back to the whole-file line, not the block-local one',
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('detectHtml — static HTML/CSS fixtures', () => {
|
||
it('should-flag: catches border anti-patterns', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'should-flag.html'));
|
||
assert.ok(f.some(r => r.antipattern === 'side-tab'));
|
||
assert.ok(f.some(r => r.antipattern === 'border-accent-on-rounded'));
|
||
});
|
||
|
||
it('should-pass: zero border findings', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'should-pass.html'));
|
||
assert.equal(f.filter(r => r.antipattern === 'side-tab' || r.antipattern === 'border-accent-on-rounded').length, 0);
|
||
});
|
||
|
||
it('border-baseline: paired side-tab fixture flags only the positive column', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'border-baseline.html'));
|
||
const sideTabs = f.filter(r => r.antipattern === 'side-tab');
|
||
const accents = f.filter(r => r.antipattern === 'border-accent-on-rounded');
|
||
assert.equal(
|
||
sideTabs.length,
|
||
6,
|
||
`expected 6 side-tab findings, got ${sideTabs.length}: ${sideTabs.map(r => r.snippet).join('; ')}`
|
||
);
|
||
assert.equal(
|
||
accents.length,
|
||
2,
|
||
`expected 2 rounded accent findings, got ${accents.length}: ${accents.map(r => r.snippet).join('; ')}`
|
||
);
|
||
});
|
||
|
||
it('linked-stylesheet: catches borders, no false positives', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'linked-stylesheet.html'));
|
||
assert.ok(f.some(r => r.antipattern === 'side-tab'));
|
||
assert.ok(f.some(r => r.antipattern === 'border-accent-on-rounded'));
|
||
assert.equal(f.filter(r => r.snippet?.includes('clean')).length, 0);
|
||
assert.equal(
|
||
f.filter(r => r.antipattern !== 'side-tab' && r.antipattern !== 'border-accent-on-rounded').length,
|
||
0,
|
||
`expected only border findings, got: ${f.map(r => `${r.antipattern}:${r.snippet}`).join('; ')}`
|
||
);
|
||
});
|
||
|
||
it('partial-component: flags borders, skips page-level', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'partial-component.html'));
|
||
assert.ok(f.some(r => r.antipattern === 'side-tab'));
|
||
assert.equal(f.filter(r => r.antipattern === 'flat-type-hierarchy').length, 0);
|
||
});
|
||
|
||
it('color: flag column triggers all color rules, pass column adds none', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
|
||
// pure-black-white was removed from the skill in v3.2; only the remaining rules
|
||
// are expected to fire from the flag column.
|
||
assert.ok(f.some(r => r.antipattern === 'gray-on-color'), 'expected gray-on-color');
|
||
assert.ok(f.some(r => r.antipattern === 'low-contrast'), 'expected low-contrast');
|
||
assert.ok(f.some(r => r.antipattern === 'gradient-text'), 'expected gradient-text');
|
||
assert.ok(f.some(r => r.antipattern === 'ai-color-palette'), 'expected ai-color-palette');
|
||
assert.equal(
|
||
f.some(r => r.antipattern === 'pure-black-white'),
|
||
false,
|
||
'pure-black-white detector was removed in v3.2',
|
||
);
|
||
// Gradient-bg + gray text case (added with the gradient-fix patch)
|
||
assert.ok(
|
||
f.some(r => r.antipattern === 'low-contrast' && /#808080|#3b82f6|#8b5cf6/i.test(r.snippet || '')),
|
||
'expected low-contrast finding for gray heading on blue/purple gradient',
|
||
);
|
||
assert.ok(
|
||
f.some(r => r.antipattern === 'gray-on-color' && /gradient/i.test(r.snippet || '')),
|
||
'expected gray-on-color finding referencing gradient',
|
||
);
|
||
});
|
||
|
||
it('color: currentcolor surface resolves var() text color instead of abstaining', async () => {
|
||
// background-color: currentcolor paints with the element's own text
|
||
// color, which in jsdom can itself be a var() token. The surface is
|
||
// knowable through the custom-prop map, so the faint text on it is a
|
||
// real low-contrast finding — abstention here would hide it.
|
||
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
|
||
assert.ok(
|
||
f.some(r =>
|
||
r.antipattern === 'low-contrast' &&
|
||
/#cfc9bd/i.test(r.snippet || '') &&
|
||
/#e8e2d6/i.test(r.snippet || '')
|
||
),
|
||
'expected low-contrast finding on the currentcolor var() surface',
|
||
);
|
||
// Good contrast on the same surface must not flag.
|
||
const goodFP = f.filter(r =>
|
||
(r.antipattern === 'low-contrast' || r.antipattern === 'gray-on-color') &&
|
||
/#3a352c/i.test(r.snippet || '')
|
||
);
|
||
assert.equal(goodFP.length, 0, `dark ink on bone must pass, got: ${goodFP.map(r => r.snippet).join('; ')}`);
|
||
// An undefined token keeps the surface unknowable: abstain, don't guess.
|
||
const unknownFP = f.filter(r =>
|
||
(r.antipattern === 'low-contrast' || r.antipattern === 'gray-on-color') &&
|
||
/#efe9dd/i.test(r.snippet || '')
|
||
);
|
||
assert.equal(
|
||
unknownFP.length, 0,
|
||
`unresolvable currentcolor surface must abstain, got: ${unknownFP.map(r => r.snippet).join('; ')}`,
|
||
);
|
||
});
|
||
|
||
it('color: a color-mix gradient stop never leaks its nested ingredient as a phantom surface', async () => {
|
||
// The stop paints as a 16% wash composited near-black over the dark
|
||
// wrap; the bright oklch(90% ...) nested inside the color-mix is an
|
||
// ingredient, never painted. Re-extracting nested tokens appended it as
|
||
// a phantom opaque stop, and the worst-case ratio then flagged the
|
||
// light text at ~1:1 against a color nobody sees.
|
||
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
|
||
const phantom = f.filter(r =>
|
||
(r.antipattern === 'low-contrast' || r.antipattern === 'gray-on-color') &&
|
||
/#ded9cf/i.test(r.snippet || '')
|
||
);
|
||
assert.equal(
|
||
phantom.length, 0,
|
||
`light text on the mixed wash must not flag: ${phantom.map(r => r.snippet).join('; ')}`,
|
||
);
|
||
});
|
||
|
||
it('color: nested #000 inside color-mix must not become on #000000', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
|
||
const light = f.filter(r =>
|
||
(r.antipattern === 'low-contrast' || r.antipattern === 'gray-on-color') &&
|
||
/#f7f3ea/i.test(r.snippet || '')
|
||
);
|
||
assert.equal(
|
||
light.length, 0,
|
||
`light text on the mixed green must not flag: ${light.map(r => r.snippet).join('; ')}`,
|
||
);
|
||
const leaked = f.filter(r => /#3d2418 on #000000/i.test(r.snippet || ''));
|
||
assert.equal(
|
||
leaked.length, 0,
|
||
`nested #000 must not become on #000000: ${leaked.map(r => r.snippet).join('; ')}`,
|
||
);
|
||
assert.ok(
|
||
f.some(r =>
|
||
r.antipattern === 'low-contrast' &&
|
||
/#3d2418/i.test(r.snippet || '') &&
|
||
/#17372d|#295344/i.test(r.snippet || '')
|
||
),
|
||
'dark ink on the mixed stop should flag against the mix, not phantom black',
|
||
);
|
||
});
|
||
|
||
it('color: white text on background-image url() ancestor is not flagged as low-contrast', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
|
||
// The pass column has white text on a div with background-image: url().
|
||
// The detector can't know the image color, so it must not assume the body
|
||
// bg and report a false low-contrast finding (#ffffff on #fafafa).
|
||
const falsePositive = f.filter(r =>
|
||
r.antipattern === 'low-contrast' &&
|
||
/#ffffff on #fafafa/i.test(r.snippet || '')
|
||
);
|
||
assert.equal(
|
||
falsePositive.length, 0,
|
||
`expected no low-contrast from bg-image ancestor, got: ${falsePositive.map(r => r.snippet).join('; ')}`
|
||
);
|
||
});
|
||
|
||
it('scoped-ignore: data-impeccable-ignore waives its subtree per rule, star, and list', async () => {
|
||
// Six identical side-tab violations; four sit under waiving containers
|
||
// (exact rule, star, comma list, and one two levels deep) and must not
|
||
// flag. The control and the container waived for a DIFFERENT rule must.
|
||
const f = await detectHtml(path.join(FIXTURES, 'scoped-ignore.html'));
|
||
const sideTabs = f.filter(r => r.antipattern === 'side-tab');
|
||
const snippets = sideTabs.map(r => r.snippet || '').join('\n');
|
||
// Width-attributed cases: control 6, other-rule 8, sibling-waiver 12,
|
||
// misspelled-rule 5 flag; the five waived shapes must not.
|
||
for (const w of [5, 6, 8, 12]) {
|
||
assert.match(snippets, new RegExp(`border-left: ${w}px`), `flag case ${w}px missing:\n${snippets}`);
|
||
}
|
||
for (const w of [4, 7, 9, 10, 11]) {
|
||
assert.doesNotMatch(snippets, new RegExp(`border-left: ${w}px`), `waived case ${w}px must not flag:\n${snippets}`);
|
||
}
|
||
assert.equal(sideTabs.length, 4, `expected exactly the 4 flag cases, got ${sideTabs.length}:\n${snippets}`);
|
||
});
|
||
|
||
it('dark-gradient-ground: a gradient body ground is measured against its stops, never assumed white', async () => {
|
||
// Static-engine twin of the browser test: the page ground is a dark oklch
|
||
// gradient set via `background:` shorthand on body (backgroundColor stays
|
||
// transparent). The old walk assumed white for any body-level gradient,
|
||
// flagging every light text at ~1.3:1 "on #ffffff" and missing the muted
|
||
// dark-gray true positives entirely. Stops must be measured instead, and
|
||
// the frosted translucent wash must composite over them.
|
||
const f = await detectHtml(path.join(FIXTURES, 'dark-gradient-ground.html'));
|
||
const contrast = f.filter(r => r.antipattern === 'low-contrast');
|
||
const snippets = contrast.map(r => r.snippet || '').join('\n');
|
||
assert.doesNotMatch(snippets, /on #ffffff/, `light-on-dark text was measured against an assumed white body:\n${snippets}`);
|
||
assert.match(snippets, /text #2e2e2e on /, `flag-muted-direct missing:\n${snippets}`);
|
||
assert.match(snippets, /text #333333 on /, `flag-muted-nested missing:\n${snippets}`);
|
||
assert.match(snippets, /text #d7d7d7 on #d/, `flag-light-on-frosted missing against the composited wash:\n${snippets}`);
|
||
assert.equal(contrast.length, 3, `expected exactly the 3 flag-column cases, got ${contrast.length}:\n${snippets}`);
|
||
});
|
||
|
||
it('color: styled <a> and <button> with their own background get contrast checks', async () => {
|
||
// SAFE_TAGS skips <a> and <button> by default to avoid noise on inline links
|
||
// (text links inside paragraphs). When these elements are styled as buttons
|
||
// (own opaque background, padding, direct text), the contrast check must run.
|
||
// Mirrors a real bug from the landing-demo: a pill-style <a> with
|
||
// warm-charcoal text on near-black bg, ~2:1 contrast, was missed by both
|
||
// the CLI and browser overlay paths because <a> was categorically skipped.
|
||
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
|
||
const pillBtnFlag = f.some(r =>
|
||
r.antipattern === 'low-contrast' &&
|
||
/#5b4f44/i.test(r.snippet || '') &&
|
||
/#1f1a15/i.test(r.snippet || '')
|
||
);
|
||
assert.ok(pillBtnFlag, 'expected low-contrast finding for styled <a> pill button');
|
||
const styledButtonFlag = f.some(r =>
|
||
r.antipattern === 'low-contrast' &&
|
||
/#6c7280/i.test(r.snippet || '') &&
|
||
/#374151/i.test(r.snippet || '')
|
||
);
|
||
assert.ok(styledButtonFlag, 'expected low-contrast finding for styled <button>');
|
||
});
|
||
|
||
it('color: inline <a> without own background remains skipped (no regression)', async () => {
|
||
// The exception for styled buttons must not regress to flagging plain
|
||
// inline text links — those would create noise on essentially every
|
||
// page on the web.
|
||
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
|
||
const inlineLinkFalsePositive = f.some(r =>
|
||
r.antipattern === 'low-contrast' &&
|
||
/#aaaaaa/i.test(r.snippet || '')
|
||
);
|
||
assert.equal(
|
||
inlineLinkFalsePositive, false,
|
||
'inline <a> without own background must remain skipped'
|
||
);
|
||
});
|
||
|
||
it('color: styled <a> with good contrast does not flag', async () => {
|
||
// The detector exception must let the check run, but a properly contrasted
|
||
// styled button must obviously pass.
|
||
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
|
||
const goodPillFalsePositive = f.some(r =>
|
||
r.antipattern === 'low-contrast' &&
|
||
/#f5f0e8/i.test(r.snippet || '') &&
|
||
/#141419/i.test(r.snippet || '')
|
||
);
|
||
assert.equal(
|
||
goodPillFalsePositive, false,
|
||
'styled <a> with high contrast must not flag'
|
||
);
|
||
});
|
||
|
||
it('color: text-bearing chips with their own background get contrast checks', async () => {
|
||
// A <span> chip painting an opaque background under direct text is a real
|
||
// contrast surface even though span sits in SAFE_TAGS. Mirrors a shipped
|
||
// miss: a SEV-2 chip whose white text lost a specificity fight and
|
||
// rendered muted brown on red at 1.2:1.
|
||
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
|
||
const chipFlag = f.some(r =>
|
||
r.antipattern === 'low-contrast' &&
|
||
/#5c5449/i.test(r.snippet || '') &&
|
||
/#b6322d/i.test(r.snippet || '')
|
||
);
|
||
assert.ok(chipFlag, 'expected low-contrast finding for the SEV-2 style chip');
|
||
|
||
// The properly contrasted chip must pass, and the sub-9px decorative
|
||
// chip stays below the font floor.
|
||
const chipOkFalsePositive = f.some(r =>
|
||
r.antipattern === 'low-contrast' &&
|
||
/#f5f0e8/i.test(r.snippet || '') &&
|
||
/#141419/i.test(r.snippet || '')
|
||
);
|
||
assert.equal(chipOkFalsePositive, false, 'high-contrast chip must not flag');
|
||
const sub9FalsePositive = f.some(r =>
|
||
r.antipattern === 'low-contrast' &&
|
||
/#963c37/i.test(r.snippet || '')
|
||
);
|
||
assert.equal(sub9FalsePositive, false, 'sub-9px chip must stay below the font floor');
|
||
});
|
||
|
||
it('color: background none shorthand resets an earlier background-color', async () => {
|
||
// `pre code { background: none }` after `code { background: <light> }`
|
||
// must leave the code text transparent over the dark panel. Keeping the
|
||
// light surface produces a phantom 1.1:1 finding the browser never paints.
|
||
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
|
||
const phantom = f.some(r =>
|
||
r.antipattern === 'low-contrast' &&
|
||
/#e6e8ed/i.test(r.snippet || '') &&
|
||
/#f6f2f4/i.test(r.snippet || '')
|
||
);
|
||
assert.equal(phantom, false, 'background: none must reset the earlier code background');
|
||
});
|
||
|
||
it('color: emoji-only text is never flagged as low-contrast', async () => {
|
||
// Emojis render as multicolor glyphs regardless of CSS `color`, so the
|
||
// CSS text color is irrelevant for contrast. The fixture's emoji cards
|
||
// intentionally set text color to match the bg (which would trip the
|
||
// rule for any other text). The detector must skip emoji-only nodes.
|
||
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
|
||
const emojiCardColorPairs = ['#ffe4e6 on #ffe4e6', '#1a1a1a on #1a1a1a'];
|
||
const matches = f.filter(r =>
|
||
(r.antipattern === 'low-contrast' || r.antipattern === 'gray-on-color') &&
|
||
emojiCardColorPairs.some(pair => (r.snippet || '').includes(pair))
|
||
);
|
||
assert.equal(
|
||
matches.length, 0,
|
||
`expected no contrast findings on emoji-only text, got: ${matches.map(r => r.snippet).join('; ')}`
|
||
);
|
||
});
|
||
|
||
it('color: gradient-clipped text is not contrast-checked against its own fill (issue #409 Case A)', async () => {
|
||
// background-clip: text with a transparent fill paints the glyphs with the
|
||
// gradient; the inherited `color` is never painted, so measuring it against
|
||
// the element's own gradient stops (#6d8cff / #a78bfa) is a false positive.
|
||
// The gradient-text pattern flag still fires; the backdrop-contrast rules
|
||
// (low-contrast / gray-on-color) must stay silent for the clipped element.
|
||
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
|
||
const clippedContrastFP = f.filter(r =>
|
||
(r.antipattern === 'low-contrast' || r.antipattern === 'gray-on-color') &&
|
||
/#6d8cff|#a78bfa/i.test(r.snippet || '')
|
||
);
|
||
assert.equal(
|
||
clippedContrastFP.length, 0,
|
||
`gradient-clipped text must not be contrast-checked against its own fill, got: ${clippedContrastFP.map(r => `${r.antipattern}:${r.snippet}`).join('; ')}`
|
||
);
|
||
// The pattern itself must still be surfaced.
|
||
assert.ok(
|
||
f.some(r => r.antipattern === 'gradient-text'),
|
||
'gradient-text pattern flag must still fire'
|
||
);
|
||
});
|
||
|
||
it('color: alpha gradient-glow stops composite against the surface beneath (issue #409 Case B)', async () => {
|
||
// A 9%-alpha teal glow stop (rgba(52,192,168,0.09)) over a dark section
|
||
// composites to ~near-black, not the full-opacity #34c0a8. Text on it is
|
||
// high-contrast; treating the stop as opaque flagged every text child.
|
||
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
|
||
const glowFP = f.filter(r =>
|
||
(r.antipattern === 'low-contrast' || r.antipattern === 'gray-on-color') &&
|
||
/#34c0a8/i.test(r.snippet || '')
|
||
);
|
||
assert.equal(
|
||
glowFP.length, 0,
|
||
`alpha glow stops must composite against the underlying surface, got: ${glowFP.map(r => `${r.antipattern}:${r.snippet}`).join('; ')}`
|
||
);
|
||
});
|
||
|
||
it('legitimate-borders: zero findings', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'legitimate-borders.html'));
|
||
assert.equal(f.length, 0, `expected no findings, got: ${f.map(r => `${r.antipattern}:${r.snippet}`).join('; ')}`);
|
||
});
|
||
|
||
it('modern-color-borders: oklch/oklab/lch/lab side-tabs are flagged, neutrals pass', async () => {
|
||
// Regression for the isNeutralColor bug where any non-rgb() color format
|
||
// (oklch, oklab, lch, lab — which jsdom does NOT normalize to rgb) was
|
||
// misclassified as neutral, causing checkBorders() to silently skip
|
||
// every element with a modern-color side border.
|
||
//
|
||
// Also regression for the SAFE_TAGS/label bug: card-shaped <label>
|
||
// elements (clickable checklist rows with padding + radius + colored
|
||
// side border) used to be silently skipped because checkBorders'
|
||
// SAFE_TAGS gate excluded <label>. The fix narrows that gate so card-
|
||
// shaped labels are checked while plain inline form labels still pass.
|
||
const f = await detectHtml(path.join(FIXTURES, 'modern-color-borders.html'));
|
||
const sideTabs = f.filter(r => r.antipattern === 'side-tab');
|
||
// Twelve FLAG cases: oklch x3, oklab, lch, lab — all colored border-left
|
||
// with a non-zero border-radius — plus two card-shaped <label> cases
|
||
// (one oklch, one rgb), plus four var()-based cases (shorthand, mixed
|
||
// neutral+colored, border-right, and a card-shaped <label>). Each must
|
||
// produce exactly one side-tab.
|
||
assert.equal(
|
||
sideTabs.length, 12,
|
||
`expected 12 side-tab findings from the FLAG column, got ${sideTabs.length}: ${sideTabs.map(r => r.snippet).join('; ')}`
|
||
);
|
||
// Eleven findings must be border-left; exactly one is border-right
|
||
// (the #flag-var-right case). The fixture doesn't decorate top/bottom
|
||
// on any flag element.
|
||
const leftFindings = sideTabs.filter(r => /border-left/.test(r.snippet || ''));
|
||
const rightFindings = sideTabs.filter(r => /border-right/.test(r.snippet || ''));
|
||
assert.equal(leftFindings.length, 11, `expected 11 border-left findings, got ${leftFindings.length}`);
|
||
assert.equal(rightFindings.length, 1, `expected 1 border-right finding, got ${rightFindings.length}`);
|
||
// PASS column must contribute zero border findings of either flavor.
|
||
// There are 14 pass cases: 7 structural neutrals plus 4 labels (plain
|
||
// inline form label, label with a neutral gray border, label in a form
|
||
// row, and a label with a thin 1px colored left border), plus 3 var()
|
||
// pass cases (neutral-resolving var, thin var, uniform all-sides var).
|
||
// If any leaks through, the label exception or var() fallback is
|
||
// over-broad.
|
||
const borderAccent = f.filter(r => r.antipattern === 'border-accent-on-rounded');
|
||
assert.equal(
|
||
borderAccent.length, 0,
|
||
`expected 0 border-accent-on-rounded, got ${borderAccent.length}: ${borderAccent.map(r => r.snippet).join('; ')}`
|
||
);
|
||
});
|
||
|
||
it('named-color-borders: named-color side-tabs are flagged, neutral names pass', async () => {
|
||
// Regression for issue #359: the static cascade's shorthand color
|
||
// extraction recognized only 9 named colors, so `border-left: 4px solid
|
||
// purple` (or any of the other named colors parseAnyColor understands)
|
||
// lost its color during expansion, defaulted to neutral black, and never
|
||
// fired side-tab — while the same declaration in a .css file was flagged
|
||
// by the regex engine. The extraction list is now derived from the same
|
||
// CSS_NAMED_COLORS table the parser uses, so the two can't drift apart.
|
||
const f = await detectHtml(path.join(FIXTURES, 'named-color-borders.html'));
|
||
const sideTabs = f.filter(r => r.antipattern === 'side-tab').map(r => r.snippet).sort();
|
||
// Six FLAG cases, each with a unique width/radius signature so every
|
||
// finding attributes to exactly one case (an offsetting miss + false
|
||
// positive can't cancel out in an aggregate count):
|
||
// purple 4px + radius 8 (the issue reproducer), rebeccapurple 5px +
|
||
// radius 4 (contains "purple" as a substring — whole-token matching),
|
||
// crimson 4px top stripe, bare 3px teal, var() resolving to a named
|
||
// color at 6px + radius 4, and a 7px inline style attribute.
|
||
// The PASS column (neutral named colors at 3-4px, 1px thin, uniform)
|
||
// must contribute nothing — dimgray/gainsboro/black have to parse AND
|
||
// read as neutral rather than being dropped as unknown colors, and none
|
||
// of its shapes can produce any of the signatures below.
|
||
assert.deepEqual(sideTabs, [
|
||
'border-left: 3px',
|
||
'border-left: 4px + border-radius: 8px',
|
||
'border-left: 5px + border-radius: 4px',
|
||
'border-left: 6px + border-radius: 4px',
|
||
'border-left: 7px',
|
||
'border-top: 4px',
|
||
]);
|
||
const borderAccent = f.filter(r => r.antipattern === 'border-accent-on-rounded');
|
||
assert.equal(
|
||
borderAccent.length, 0,
|
||
`expected 0 border-accent-on-rounded, got ${borderAccent.length}: ${borderAccent.map(r => r.snippet).join('; ')}`
|
||
);
|
||
});
|
||
|
||
it('modern-color-borders: regex fallback skips neutral 1px oklch dividers', () => {
|
||
const css = `
|
||
.flag-side-tab {
|
||
border-radius: 8px;
|
||
border-left: 2px solid oklch(65% 0.12 250);
|
||
}
|
||
|
||
.pass-context-divider {
|
||
border-radius: 8px;
|
||
border-right: 1px solid oklch(92% 0 0 / 0.12);
|
||
}
|
||
|
||
.pass-neutral-side {
|
||
border-radius: 8px;
|
||
border-left: 3px solid oklch(80% 0 0);
|
||
}
|
||
`;
|
||
const f = detectText(css, path.join(FIXTURES, 'modern-color-borders-regex.css'));
|
||
const sideTabs = f.filter(r => r.antipattern === 'side-tab');
|
||
assert.equal(
|
||
sideTabs.length,
|
||
1,
|
||
`expected only the colored 2px side-tab to flag, got: ${sideTabs.map(r => r.snippet).join('; ')}`
|
||
);
|
||
assert.match(sideTabs[0].snippet, /border-left: 2px solid oklch/);
|
||
});
|
||
|
||
it('typography-should-flag: detects both issues', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'typography-should-flag.html'));
|
||
assert.ok(f.some(r => r.antipattern === 'overused-font'));
|
||
// single-font retired 2026-07-29: one family with weight/size contrast is
|
||
// a legitimate system, and the rule mostly punished minimal test pages.
|
||
assert.ok(!f.some(r => r.antipattern === 'single-font'), 'retired rule single-font should not resurface');
|
||
assert.ok(f.some(r => r.antipattern === 'flat-type-hierarchy'));
|
||
assert.equal(
|
||
f.some(r => r.antipattern === 'low-contrast'),
|
||
false,
|
||
`typography fixture should not contain incidental contrast findings: ${f.map(r => `${r.antipattern}:${r.snippet}`).join('; ')}`
|
||
);
|
||
});
|
||
|
||
it('typography: side-by-side page has visible element-level flag cases', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'typography.html'));
|
||
const ids = new Set(f.map(r => r.antipattern));
|
||
for (const id of ['tight-leading', 'tiny-text', 'all-caps-body', 'wide-tracking', 'justified-text']) {
|
||
assert.ok(ids.has(id), `expected typography side-by-side fixture to include ${id}`);
|
||
}
|
||
assert.ok(ids.has('overused-font'), 'expected typography side-by-side fixture to include a page-level overused-font finding');
|
||
});
|
||
|
||
it('typography-should-pass: zero findings', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'typography-should-pass.html'));
|
||
assert.equal(f.length, 0);
|
||
});
|
||
|
||
it('overused-font: flags named primaries and skips system-stack Roboto', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'overused-font.html'));
|
||
const snippets = f.filter(r => r.antipattern === 'overused-font').map(r => r.snippet).join(' | ');
|
||
for (const font of ['inter', 'geist', 'montserrat', 'lato']) {
|
||
assert.match(snippets, new RegExp(`Primary font: ${font}`), `expected flag for ${font}: ${snippets}`);
|
||
}
|
||
assert.doesNotMatch(snippets, /roboto/i, `system-stack Roboto must not be primary: ${snippets}`);
|
||
assert.doesNotMatch(snippets, /arial/i, `system-stack Arial must not be primary: ${snippets}`);
|
||
assert.equal(
|
||
f.some(r => r.antipattern === 'flat-type-hierarchy'),
|
||
false,
|
||
`overused-font fixture should not contain incidental type findings: ${f.map(r => `${r.antipattern}:${r.snippet}`).join('; ')}`,
|
||
);
|
||
});
|
||
|
||
it('design-system: flags only values outside the provided DESIGN.md tokens', async () => {
|
||
const designSystem = normalizeDesignSystem({
|
||
frontmatter: {
|
||
typography: {
|
||
display: { fontFamily: 'Avenir Next, Georgia, serif', fontSize: 'clamp(2.5rem, 6vw, 4rem)' },
|
||
body: { fontFamily: 'IBM Plex Sans, Arial, sans-serif', fontSize: '16px' },
|
||
label: { fontFamily: 'IBM Plex Sans, Arial, sans-serif', fontSize: '14px' },
|
||
},
|
||
colors: {
|
||
ink: '#241f1a',
|
||
paper: '#f7f4ee',
|
||
surface: '#ffffff',
|
||
accent: '#b8422e',
|
||
border: '#d4c7b9',
|
||
},
|
||
rounded: {
|
||
sm: '4px',
|
||
md: '8px',
|
||
'"2xl"': '32px',
|
||
full: '999px',
|
||
},
|
||
},
|
||
sidecar: {
|
||
extensions: {
|
||
colorMeta: {
|
||
accent: {
|
||
canonical: '#b8422e',
|
||
tonalRamp: ['#923524', '#d55a42'],
|
||
},
|
||
},
|
||
},
|
||
},
|
||
});
|
||
const f = await detectHtml(path.join(FIXTURES, 'design-system.html'), { designSystem });
|
||
const designFindings = f.filter((r) => r.antipattern.startsWith('design-system-'));
|
||
const snippets = designFindings.map((r) => r.snippet).join('\n');
|
||
|
||
assert.ok(designFindings.some((r) => r.antipattern === 'design-system-font'), 'expected unsupported font');
|
||
assert.ok(designFindings.some((r) => r.antipattern === 'design-system-color'), 'expected undocumented colors');
|
||
assert.ok(designFindings.some((r) => r.antipattern === 'design-system-radius'), 'expected undocumented radius');
|
||
assert.ok(
|
||
designFindings.some((r) => r.antipattern === 'design-system-font' && /Google Fonts: Poppins/.test(r.snippet || '')),
|
||
'expected source-level Google Fonts usage in HTML to be flagged',
|
||
);
|
||
assert.ok(
|
||
designFindings.some((r) => r.antipattern === 'design-system-font-size' && /12\.5px/.test(r.snippet || '')),
|
||
'expected off-ramp literal font-size to be flagged',
|
||
);
|
||
assert.doesNotMatch(snippets, /1rem is off/, 'documented rem step must pass');
|
||
assert.doesNotMatch(snippets, /1\.2em is off/, 'relative em sizes are abstained on');
|
||
assert.doesNotMatch(snippets, /16px is off|14px is off/, 'on-ramp sizes must pass');
|
||
assert.doesNotMatch(snippets, /Undocumented color #ff00aa/, 'source and computed color findings should not duplicate');
|
||
assert.doesNotMatch(snippets, /font-family: Poppins/, 'source and computed font findings should not duplicate');
|
||
assert.doesNotMatch(snippets, /border-radius: 18px is outside/, 'source and computed radius findings should not duplicate');
|
||
assert.doesNotMatch(snippets, /on style "\.design-system-fixture/, 'static DOM design pass should skip <style> content');
|
||
assert.equal(
|
||
designFindings.find((r) => /Flag Color Hot Pink/.test(r.snippet || ''))?.line,
|
||
37,
|
||
'deduped HTML design findings should keep the source line when available',
|
||
);
|
||
assert.equal(
|
||
designFindings.find((r) => /Flag Radius Eighteen/.test(r.snippet || ''))?.line,
|
||
40,
|
||
'deduped radius findings should keep the source line when available',
|
||
);
|
||
|
||
for (const label of [
|
||
'Flag Font Unsupported',
|
||
'Flag Color Hot Pink',
|
||
'Flag Background Cyan',
|
||
'Flag Border Teal',
|
||
'Flag Radius Eighteen',
|
||
]) {
|
||
assert.match(snippets, new RegExp(label), `expected ${label} to be flagged`);
|
||
}
|
||
for (const label of [
|
||
'Pass Display Font',
|
||
'Pass Rem Font Size',
|
||
'Pass Relative Font Size',
|
||
'Pass Generic Font',
|
||
'Pass Token Color',
|
||
'Pass Alpha Color',
|
||
'Pass Close Color',
|
||
'Pass Ramp Color',
|
||
'Pass Zero Radius',
|
||
'Pass Percent Radius',
|
||
'Pass Scale Radius',
|
||
'Pass Pill Radius',
|
||
]) {
|
||
assert.doesNotMatch(snippets, new RegExp(label), `${label} should pass`);
|
||
}
|
||
});
|
||
|
||
it('numeric content is not classified without DOM context', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'numbered-section-markers.html'));
|
||
const numbered = f.filter(r => r.antipattern === 'numbered-section-markers');
|
||
assert.equal(numbered.length, 0, 'raw numeric sequences must not masquerade as semantic section evidence');
|
||
});
|
||
|
||
it('numbered-section-labels: tiny repeated index labels flag, deliberate/list/card numbering passes', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'numbered-section-labels.html'));
|
||
const labels = f.filter(r => r.antipattern === 'numbered-section-labels');
|
||
const snippets = labels.map(r => r.snippet).join(' | ');
|
||
assert.equal(
|
||
labels.length,
|
||
4,
|
||
`expected 4 numbered-label findings, got ${labels.length}: ${snippets}`
|
||
);
|
||
for (const heading of ['Alpha ships first', 'Beta earns trust', 'Gamma holds the line', 'Delta closes the loop']) {
|
||
assert.match(snippets, new RegExp(heading), `expected label beside "${heading}" to flag`);
|
||
}
|
||
for (const heading of ['Epsilon', 'Zeta', 'Eta', 'Theta', 'Iota', 'Kappa', 'Lambda', 'Mu']) {
|
||
assert.doesNotMatch(snippets, new RegExp(heading), `label beside "${heading}" should pass`);
|
||
}
|
||
});
|
||
|
||
it('repeated-container-text: same string in 3+ distinct slots of one card flags; structural repetition passes', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'repeated-container-text.html'));
|
||
const repeats = f.filter(r => r.antipattern === 'repeated-container-text');
|
||
const snippets = repeats.map(r => r.snippet).join(' | ');
|
||
assert.equal(
|
||
repeats.length,
|
||
2,
|
||
`expected 2 repeated-text findings, got ${repeats.length}: ${snippets}`
|
||
);
|
||
assert.match(snippets, /Suspended.*3×|Suspended" rendered 3/, 'expected the 3-slot status word to flag');
|
||
assert.match(snippets, /Unavailable" rendered 4/, 'expected the 4-slot status word to flag');
|
||
for (const passText of ['Rolled back', 'On schedule', 'Overview page', 'Standby mode', 'Open slot', 'Rescheduled', '2026']) {
|
||
assert.doesNotMatch(snippets, new RegExp(passText), `"${passText}" should pass`);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('detectHtml — icon-tile-stack', () => {
|
||
// Two-column fixture convention: left col = should-flag, right col = should-pass.
|
||
// The rule's snippet embeds the heading text in quotes, e.g.
|
||
// "80x80px icon tile above h3 \"Lightning Fast\"".
|
||
// The test extracts those quoted texts and matches them against the
|
||
// expected lists below.
|
||
const SHOULD_FLAG = [
|
||
'Lightning Fast',
|
||
'Secure Storage',
|
||
'Easy Setup',
|
||
'Powerful Analytics',
|
||
'Emoji Inline Icon',
|
||
];
|
||
const SHOULD_PASS = [
|
||
'Sarah Chen',
|
||
'Article Headline',
|
||
'Inline Side By Side',
|
||
'Plain Heading No Icon',
|
||
'Tiny Icon Above Me',
|
||
'Huge Hero Image',
|
||
];
|
||
|
||
it('icon-tile-stack: flags only the should-flag column', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'icon-tile-stack.html'));
|
||
const flagged = new Set();
|
||
for (const r of f) {
|
||
if (r.antipattern !== 'icon-tile-stack') continue;
|
||
const m = (r.snippet || '').match(/"([^"]+)"/);
|
||
if (m) flagged.add(m[1]);
|
||
}
|
||
|
||
for (const text of SHOULD_FLAG) {
|
||
assert.ok(flagged.has(text), `expected "${text}" to be flagged as icon-tile-stack`);
|
||
}
|
||
for (const text of SHOULD_PASS) {
|
||
assert.ok(!flagged.has(text), `"${text}" should NOT be flagged as icon-tile-stack`);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('detectHtml — radial-spotlight-glow', () => {
|
||
// Two-column fixture convention: left col = should-flag, right col = should-pass.
|
||
// The rule's snippet embeds the element's data-name in quotes, e.g.
|
||
// radial-gradient spotlight glow "Hero Spotlight Blue" (#506fff a0.26 → transparent).
|
||
const SHOULD_FLAG = [
|
||
'Hero Spotlight Blue',
|
||
'Section Glow Violet',
|
||
'Overlay Glow Cyan',
|
||
'Two Stop Soft Glow',
|
||
'Hex Alpha Glow',
|
||
];
|
||
const SHOULD_PASS = [
|
||
'Opaque Radial Background',
|
||
'Small Accent Badge',
|
||
'Avatar Glow Light',
|
||
'Neutral Vignette',
|
||
'White Vignette',
|
||
'Rich Radial Composition',
|
||
'Rich Transparent Composition',
|
||
'Opaque Center Glow',
|
||
'Linear Gradient Wash',
|
||
];
|
||
|
||
it('radial-spotlight-glow: flags only the should-flag column', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'radial-spotlight-glow.html'));
|
||
const flagged = new Set();
|
||
for (const r of f) {
|
||
if (r.antipattern !== 'radial-spotlight-glow') continue;
|
||
const m = (r.snippet || '').match(/"([^"]+)"/);
|
||
if (m) flagged.add(m[1]);
|
||
}
|
||
|
||
for (const text of SHOULD_FLAG) {
|
||
assert.ok(flagged.has(text), `expected "${text}" to be flagged as radial-spotlight-glow`);
|
||
}
|
||
for (const text of SHOULD_PASS) {
|
||
assert.ok(!flagged.has(text), `"${text}" should NOT be flagged as radial-spotlight-glow`);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('detectHtml — undersized-ui-text', () => {
|
||
// Two-column fixture: left col = should-flag, right col = should-pass.
|
||
// The rule's snippet embeds the element's direct text in quotes, e.g.
|
||
// `8px functional text "Flag Nav Link" (below 11px floor)`.
|
||
// The test extracts those quoted texts and matches them against the lists.
|
||
const SHOULD_FLAG = [
|
||
'Flag Nav Link', // interactive nav link at 8px
|
||
'Flag Category', // non-interactive furniture label at 8px
|
||
'Flag Meta Row', // meta row at 9px
|
||
'Flag Button', // interactive button at 10px
|
||
'Flag Table Cell', // structural table cell at 9px
|
||
'Flag Caps Label', // uppercase letterspaced micro-label — NOT exempt
|
||
'Flag Footer Link', // interactive text in footer stays on the 11px floor
|
||
];
|
||
const SHOULD_PASS = [
|
||
'Pass Legal Fine Print', // non-interactive footer smallprint at 10px (floor 10)
|
||
'Pass Sr Only', // visually-hidden text
|
||
'Pass Sup Marker', // sup tag exempt
|
||
'Pass Sub Marker', // sub tag exempt
|
||
'Pass Em Sized', // 0.6em of a 20px parent = 12px, above the floor
|
||
'Pass Terminal Line', // code/terminal mock, legitimately small
|
||
'Pass Normal Link', // functional text at the 12px floor
|
||
];
|
||
|
||
it('undersized-ui-text: flags only the should-flag column', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'undersized-ui-text.html'));
|
||
const flagged = new Set();
|
||
for (const r of f) {
|
||
if (r.antipattern !== 'undersized-ui-text') continue;
|
||
const m = (r.snippet || '').match(/"([^"]+)"/);
|
||
if (m) flagged.add(m[1]);
|
||
}
|
||
|
||
for (const text of SHOULD_FLAG) {
|
||
assert.ok(flagged.has(text), `expected "${text}" to be flagged as undersized-ui-text`);
|
||
}
|
||
for (const text of SHOULD_PASS) {
|
||
assert.ok(!flagged.has(text), `"${text}" should NOT be flagged as undersized-ui-text`);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('detectHtml — non-rendered text (issue #408)', () => {
|
||
// On sites that set `html { font-size: 62.5% }` the root computes to 10px, so
|
||
// <script>/<style>/<title>/<noscript> and display:none / visibility:hidden
|
||
// blocks — whose JS/CSS/JSON-LD text clears the hasDirectText gate — report a
|
||
// 10px size and used to produce dozens of phantom "10px body text" findings.
|
||
// Both text-size floors (tiny-text and undersized-ui-text) must skip them and
|
||
// measure only genuinely rendered text.
|
||
it('tiny-text / undersized-ui-text: non-rendered elements produce no findings, rendered text still flags', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'nonrendered-text.html'));
|
||
const tiny = f.filter(r => r.antipattern === 'tiny-text');
|
||
const undersized = f.filter(r => r.antipattern === 'undersized-ui-text');
|
||
|
||
// Exactly the two genuinely rendered elements flag: the 10px body <p>
|
||
// (tiny-text) and the 9px interactive nav link (undersized-ui-text).
|
||
assert.equal(
|
||
tiny.length, 1,
|
||
`expected exactly 1 tiny-text finding (rendered body copy), got ${tiny.length}: ${tiny.map(r => r.snippet).join('; ')}`
|
||
);
|
||
assert.equal(
|
||
undersized.length, 1,
|
||
`expected exactly 1 undersized-ui-text finding (rendered nav link), got ${undersized.length}: ${undersized.map(r => r.snippet).join('; ')}`
|
||
);
|
||
assert.match(undersized[0].snippet || '', /Rendered Nav Link/, 'the one undersized finding must be the rendered nav link');
|
||
});
|
||
});
|
||
|
||
describe('detectHtml — quality (static-compatible rules)', () => {
|
||
// Six of the eight quality rules can run in static HTML/CSS because they only need
|
||
// computed CSS values (tight-leading, tiny-text, justified-text,
|
||
// all-caps-body, wide-tracking) or pure DOM walks (skipped-heading).
|
||
// The other two (line-length, cramped-padding) need real layout rects and
|
||
// live in tests/detect-antipatterns-browser.test.mjs (Puppeteer-backed).
|
||
it('quality: flag column triggers all 6 static-compatible quality rules', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'quality.html'));
|
||
assert.equal(f.filter(r => r.antipattern === 'tight-leading').length, 1);
|
||
assert.equal(f.filter(r => r.antipattern === 'tiny-text').length, 1);
|
||
assert.equal(f.filter(r => r.antipattern === 'justified-text').length, 1);
|
||
assert.equal(f.filter(r => r.antipattern === 'all-caps-body').length, 1);
|
||
assert.equal(f.filter(r => r.antipattern === 'wide-tracking').length, 1);
|
||
assert.equal(f.filter(r => r.antipattern === 'skipped-heading').length, 1);
|
||
});
|
||
});
|
||
|
||
describe('detectHtml — layout', () => {
|
||
it('layout: flag column triggers nested-cards, pass column adds none', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'layout.html'));
|
||
const nested = f.filter(r => r.antipattern === 'nested-cards');
|
||
assert.ok(nested.length >= 4, `expected ≥4 nested-cards findings, got ${nested.length}`);
|
||
// The page-level layout rules (monotonous-spacing, everything-centered)
|
||
// need Tailwind-via-CDN to render, which the static engine does not fetch.
|
||
// They're effectively dormant in this test environment regardless of the fixture
|
||
// contents — so all we can verify is that the pass column doesn't push
|
||
// them awake unexpectedly.
|
||
assert.equal(f.filter(r => r.antipattern === 'monotonous-spacing').length, 0);
|
||
assert.equal(f.filter(r => r.antipattern === 'everything-centered').length, 0);
|
||
});
|
||
});
|
||
|
||
describe('detectHtml — italic-serif-display', () => {
|
||
// Two-column fixture: left col flag, right col pass. Snippet embeds the
|
||
// heading text in quotes so the test can extract it via /"([^"]+)"/.
|
||
const SHOULD_FLAG = [
|
||
'Fraunces 88px italic',
|
||
'Recoleta 64px italic',
|
||
'Playfair 72px italic',
|
||
'Unknown Serif Generic Fallback',
|
||
];
|
||
const SHOULD_PASS = [
|
||
'Sans Italic Display',
|
||
'Roman Serif Display',
|
||
'Italic Serif Pull Quote',
|
||
// The italic <em> inside the roman h1 is intentionally not detected in v1.
|
||
// The h1's own text "Inline Em Inside Roman" must not appear flagged.
|
||
'Inline Em Inside Roman',
|
||
'Italic Serif at 32px',
|
||
'h1 Sans-Serif Roman',
|
||
];
|
||
|
||
it('italic-serif-display: flags only the should-flag column', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'italic-serif-display.html'));
|
||
const flagged = new Set();
|
||
for (const r of f) {
|
||
if (r.antipattern !== 'italic-serif-display') continue;
|
||
const m = (r.snippet || '').match(/"([^"]+)"/);
|
||
if (m) flagged.add(m[1]);
|
||
}
|
||
|
||
for (const text of SHOULD_FLAG) {
|
||
assert.ok(flagged.has(text), `expected "${text}" to be flagged as italic-serif-display`);
|
||
}
|
||
for (const text of SHOULD_PASS) {
|
||
assert.ok(!flagged.has(text), `"${text}" should NOT be flagged as italic-serif-display`);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('detectHtml — hero-eyebrow-chip', () => {
|
||
const SHOULD_FLAG = [
|
||
'Eyebrow Above Hero',
|
||
'Span Eyebrow Above Hero',
|
||
'Pill Chip Above Hero',
|
||
'Already Uppercase Text',
|
||
'Long Uppercase Sentence Above Hero',
|
||
];
|
||
const SHOULD_PASS = [
|
||
'Eyebrow With Normal Tracking',
|
||
'Uppercase Caption Far From Hero',
|
||
'Hero With No Eyebrow',
|
||
'Heading Above Heading',
|
||
'Body-Sized Heading Below Eyebrow',
|
||
'Application Panel Heading',
|
||
];
|
||
|
||
it('hero-eyebrow-chip: flags only the should-flag column', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'hero-eyebrow-chip.html'));
|
||
const flagged = new Set();
|
||
for (const r of f) {
|
||
if (r.antipattern !== 'hero-eyebrow-chip') continue;
|
||
// Snippet shape: ... above h1 "Heading Text"
|
||
const matches = [...(r.snippet || '').matchAll(/"([^"]+)"/g)];
|
||
// Last quoted token is the heading text
|
||
if (matches.length) flagged.add(matches[matches.length - 1][1]);
|
||
}
|
||
|
||
for (const text of SHOULD_FLAG) {
|
||
assert.ok(flagged.has(text), `expected "${text}" to be flagged as hero-eyebrow-chip`);
|
||
}
|
||
for (const text of SHOULD_PASS) {
|
||
assert.ok(!flagged.has(text), `"${text}" should NOT be flagged as hero-eyebrow-chip`);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('detectHtml — kicker-above-heading', () => {
|
||
const SHOULD_FLAG = [
|
||
'A Single Kicker Still Flags',
|
||
'Standard Tracking Kicker',
|
||
'Kicker Above An H3',
|
||
'Kicker Above An H4',
|
||
'Sub Hero Heading',
|
||
'Small Caps Kicker',
|
||
'Heading Role Kicker',
|
||
];
|
||
const SHOULD_PASS = [
|
||
'Breadcrumb Before Heading',
|
||
'Breadcrumb Trail Outside Nav',
|
||
'Dateline Above Headline',
|
||
'Editorial Card Meta',
|
||
'Form Heading Is Separate',
|
||
'Figure Caption Label',
|
||
'Limitation Of Liability',
|
||
'Indemnification Clause',
|
||
'Chapter Numbering Passes',
|
||
'Application Panel Context',
|
||
'Page Title After Nav',
|
||
'48%',
|
||
'Sentence Case Lead In',
|
||
'Untracked Caps Label',
|
||
'Intentional Brand Label',
|
||
'Hero Owned By Hero Rule',
|
||
'Garden Suite',
|
||
'Sea Loft',
|
||
'Step Indicator',
|
||
'Mockup Hero Variant One',
|
||
];
|
||
|
||
it('kicker-above-heading: flags any kicker above a heading, without repetition', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'kicker-above-heading.html'));
|
||
const flagged = new Set();
|
||
for (const r of f) {
|
||
if (r.antipattern !== 'kicker-above-heading') continue;
|
||
assert.equal(r.severity, 'warning');
|
||
const matches = [...(r.snippet || '').matchAll(/"([^"]+)"/g)];
|
||
if (matches.length) flagged.add(matches[matches.length - 1][1]);
|
||
}
|
||
|
||
for (const text of SHOULD_FLAG) {
|
||
assert.ok(flagged.has(text), `expected "${text}" to be flagged as kicker-above-heading`);
|
||
}
|
||
for (const text of SHOULD_PASS) {
|
||
assert.ok(!flagged.has(text), `"${text}" should NOT be flagged as kicker-above-heading`);
|
||
}
|
||
|
||
// The retired repeated-section-kickers id must never resurface.
|
||
assert.ok(!f.some(r => r.antipattern === 'repeated-section-kickers'),
|
||
'retired rule id repeated-section-kickers should not fire');
|
||
|
||
// The hero-scale h1 eyebrow stays with hero-eyebrow-chip, exactly once.
|
||
const heroHits = f.filter(r => r.antipattern === 'hero-eyebrow-chip'
|
||
&& /Hero Owned By Hero Rule/.test(r.snippet || ''));
|
||
assert.equal(heroHits.length, 1, 'hero-eyebrow-chip should own the hero-scale h1 eyebrow');
|
||
});
|
||
});
|
||
|
||
describe('detectHtml — motion', () => {
|
||
// The static CSS engine applies class-based fixture styles, so it catches all
|
||
// flag-column layout-transition cases without relying on browser layout.
|
||
it('motion: flag column triggers both motion rules, pass column adds none', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'motion.html'));
|
||
assert.equal(f.filter(r => r.antipattern === 'bounce-easing').length, 2);
|
||
assert.equal(f.filter(r => r.antipattern === 'layout-transition').length, 8);
|
||
});
|
||
});
|
||
|
||
describe('detectHtml — dark glow', () => {
|
||
// Calibrated static baseline — see motion test note above.
|
||
// 12 element-level findings (glow-blue, glow-purple, glow-cyan, glow-multi,
|
||
// inline pink, glow-oklch, glow-hex, glow-hsl, glow-var, glow-text,
|
||
// glow-light-oklch, glow-photo-halo) + 1 page-level text-scan finding.
|
||
// Pass column adds none.
|
||
it('glow: flag column triggers dark-glow, pass column adds none', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'glow.html'));
|
||
const glow = f.filter(r => r.antipattern === 'dark-glow');
|
||
assert.equal(glow.length, 13);
|
||
// Every finding is a glow tell, none reference the pass-column shadows
|
||
for (const g of glow) {
|
||
assert.match(g.snippet, /Zero-offset (box|text)-shadow glow|Colored (box|text)-shadow glow/);
|
||
}
|
||
// Zero-offset halo under an unreadable url() surface still fires: the
|
||
// halo tell does not depend on the background at all.
|
||
assert.ok(
|
||
glow.some(g => /Zero-offset box-shadow glow \(#d946ef\)/i.test(g.snippet)),
|
||
'expected zero-offset halo finding under unreadable image surface',
|
||
);
|
||
// Offset chromatic shadow under the same unreadable surface abstains:
|
||
// the dark-background tell needs a surface we can actually read.
|
||
assert.equal(
|
||
glow.filter(g => /#10b981/i.test(g.snippet)).length, 0,
|
||
'offset chromatic shadow on unknown surface must not be scored',
|
||
);
|
||
// Translucent gradient over a url() image blends with pixels the engine
|
||
// cannot read; the wash stops must never be scored as the surface.
|
||
assert.equal(
|
||
glow.filter(g => /#f43f5e/i.test(g.snippet)).length, 0,
|
||
'offset chromatic shadow under a translucent wash over an image must abstain',
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('detectHtml — cramped-padding (wrapper variant)', () => {
|
||
// The cramped-padding rule has two shapes (merged under one id):
|
||
// 1. Self-text: element has its own text and padding-vs-font-size is wrong
|
||
// 2. Wrapper: element wraps text-bearing children and has near-zero
|
||
// padding against a visible boundary (border/outline/bg)
|
||
// This suite covers the wrapper variant via flush-against-border.html.
|
||
// The self-text variant lives in tests/detect-antipatterns-browser.test.mjs
|
||
// because it needs real layout rects.
|
||
//
|
||
// Snippet for the wrapper variant embeds the element's class in quotes
|
||
// so the test can grep for which cases fired.
|
||
const SHOULD_FLAG_CLASSES = [
|
||
'flag-frameworks',
|
||
'flag-card-borders',
|
||
'flag-bg-only',
|
||
'flag-outline-only',
|
||
'flag-asym-leftflush',
|
||
];
|
||
const SHOULD_PASS_CLASSES = [
|
||
'pass-no-boundary',
|
||
'pass-top-rule',
|
||
'pass-bordered-padded',
|
||
'pass-bg-padded',
|
||
'pass-outline-padded',
|
||
'pass-image-only',
|
||
'pass-margin-inset',
|
||
'pass-inner-shell',
|
||
'pass-same-bg-child',
|
||
'pass-inner-text-surface',
|
||
];
|
||
|
||
it('cramped-padding (wrapper): flags only the should-flag column', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'flush-against-border.html'));
|
||
const flagged = new Set();
|
||
for (const r of f) {
|
||
if (r.antipattern !== 'cramped-padding') continue;
|
||
const m = (r.snippet || '').match(/"([^"]+)"/);
|
||
if (m) flagged.add(m[1]);
|
||
}
|
||
|
||
for (const cls of SHOULD_FLAG_CLASSES) {
|
||
assert.ok(
|
||
flagged.has(cls),
|
||
`expected ".${cls}" to be flagged as cramped-padding (got: ${[...flagged].join(', ')})`
|
||
);
|
||
}
|
||
for (const cls of SHOULD_PASS_CLASSES) {
|
||
assert.ok(
|
||
!flagged.has(cls),
|
||
`".${cls}" should NOT be flagged as cramped-padding`
|
||
);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('detectHtml — oversized-h1', () => {
|
||
// Fires when a LONG headline is set at display size (dominating the
|
||
// viewport). A punchy one/two-word headline at the same size is a valid
|
||
// stylistic choice and must pass; so must a long headline at a sane size.
|
||
it('oversized-h1: flags only long headlines set at display size', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'oversized-h1.html'));
|
||
const hits = f.filter(r => r.antipattern === 'oversized-h1');
|
||
assert.equal(
|
||
hits.length, 2,
|
||
`expected 2 oversized-h1 findings, got ${hits.length}: ${hits.map(r => r.snippet).join('; ')}`,
|
||
);
|
||
// None of the pass cases (short-but-huge, or long-but-sane-size) may flag.
|
||
assert.equal(
|
||
hits.some(r => /Bold\.|Ship faster|ordinary headline/i.test(r.snippet || '')),
|
||
false,
|
||
'short display headlines and sanely-sized long headlines must not flag',
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('detectHtml — extreme-negative-tracking', () => {
|
||
// Mirror image of wide-tracking: catches letter-spacing crushed past the
|
||
// point of legibility. Optical tightening that display type legitimately
|
||
// wants (around -0.02em) must pass.
|
||
it('extreme-negative-tracking: flags the 3 crushed cases, pass column adds none', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'extreme-negative-tracking.html'));
|
||
const hits = f.filter(r => r.antipattern === 'extreme-negative-tracking');
|
||
assert.equal(
|
||
hits.length, 3,
|
||
`expected 3 extreme-negative-tracking findings, got ${hits.length}: ${hits.map(r => r.snippet).join('; ')}`
|
||
);
|
||
assert.equal(
|
||
hits.some(r => /Optical tighten/i.test(r.snippet || '')),
|
||
false,
|
||
'the -0.02em display heading must not be flagged',
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('detectHtml — clipped-overflow-container', () => {
|
||
// Snippet embeds the container class in quotes. A clipping ancestor
|
||
// (overflow hidden/clip) with an absolutely-positioned descendant clips
|
||
// tooltips/menus that need to escape. Real scroll regions (auto/scroll),
|
||
// visible overflow, and clipping containers without positioned children pass.
|
||
const SHOULD_FLAG = [
|
||
'flag-overflow-hidden',
|
||
'flag-overflow-clip',
|
||
'flag-overflow-negative',
|
||
'flag-overflow-right',
|
||
'flag-shadow-utility',
|
||
'flag-overlay-surface',
|
||
];
|
||
const SHOULD_PASS = [
|
||
'pass-hidden-no-abs',
|
||
'pass-visible-abs',
|
||
'pass-scroll-abs',
|
||
'pass-contained-abs',
|
||
'pass-button-shine',
|
||
'pass-crop-photo',
|
||
'pass-contained-overlay',
|
||
'pass-carousel-viewport',
|
||
'pass-fisheye-list',
|
||
'pass-split-container',
|
||
];
|
||
|
||
it('clipped-overflow-container: flags only clipping ancestors with positioned children', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'clipped-overflow-container.html'));
|
||
const flagged = new Set();
|
||
for (const r of f) {
|
||
if (r.antipattern !== 'clipped-overflow-container') continue;
|
||
const m = (r.snippet || '').match(/(flag-[\w-]+|pass-[\w-]+)/);
|
||
if (m) flagged.add(m[1]);
|
||
}
|
||
for (const cls of SHOULD_FLAG) {
|
||
assert.ok(flagged.has(cls), `expected ".${cls}" to be flagged as clipped-overflow-container`);
|
||
}
|
||
for (const cls of SHOULD_PASS) {
|
||
assert.ok(!flagged.has(cls), `".${cls}" should NOT be flagged as clipped-overflow-container`);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('detectHtml — cream-palette', () => {
|
||
it('cream-palette: flags a warm cream/beige page background', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'cream-palette.html'));
|
||
assert.equal(
|
||
f.filter(r => r.antipattern === 'cream-palette').length, 1,
|
||
`expected one cream-palette finding, got: ${f.filter(r => r.antipattern === 'cream-palette').map(r => r.snippet).join('; ')}`,
|
||
);
|
||
});
|
||
|
||
it('cream-palette: does not fire on a neutral (non-cream) page', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'typography-should-pass.html'));
|
||
assert.equal(f.some(r => r.antipattern === 'cream-palette'), false, 'neutral page must not flag cream-palette');
|
||
});
|
||
|
||
it('cream-palette: catches a Tailwind warm-light bg utility on body', async () => {
|
||
// No inline/<style> background — only a `bg-amber-50` class, which the
|
||
// static engine can't resolve to computed CSS. The class-list fallback
|
||
// must still flag it.
|
||
const f = await detectHtml(path.join(FIXTURES, 'cream-palette-tailwind.html'));
|
||
const hits = f.filter(r => r.antipattern === 'cream-palette');
|
||
assert.equal(hits.length, 1, `expected one cream-palette finding, got: ${hits.map(r => r.snippet).join('; ')}`);
|
||
assert.match(hits[0].snippet, /amber-50/, 'snippet should name the Tailwind utility');
|
||
});
|
||
});
|
||
|
||
describe('detectHtml — generated-UI tells', () => {
|
||
const GPT_IDS = ['gpt-thin-border-wide-shadow', 'repeating-stripes-gradient', 'codex-grid-background', 'theater-slop-phrase'];
|
||
|
||
it('gpt-tells: each flag case surfaces by default and the pass column adds none', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'gpt-tells.html'));
|
||
for (const id of GPT_IDS) {
|
||
assert.equal(
|
||
f.filter(r => r.antipattern === id).length, 1,
|
||
`expected exactly one default ${id} finding, got ${f.filter(r => r.antipattern === id).length}`,
|
||
);
|
||
}
|
||
});
|
||
|
||
it('codex-grid-background: 1D dashed rules and px-pair line-fields stay legal', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'codex-grid-1d-pass.html'));
|
||
assert.equal(
|
||
f.filter(r => r.antipattern === 'codex-grid-background').length, 0,
|
||
`1D tiled hairlines must not flag, got: ${f.filter(r => r.antipattern === 'codex-grid-background').map(r => r.snippet).join('; ')}`,
|
||
);
|
||
});
|
||
|
||
it('gemini-tells: both flag cases surface by default and pass cases stay legal', async () => {
|
||
const findings = await detectHtml(path.join(FIXTURES, 'gemini-tells.html'));
|
||
// Two flag cases: a CSS img:hover{transform} rule and a Tailwind hover:scale on <img>.
|
||
assert.equal(
|
||
findings.filter(r => r.antipattern === 'image-hover-transform').length, 2,
|
||
`expected 2 default image-hover-transform findings, got ${findings.filter(r => r.antipattern === 'image-hover-transform').length}`,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('em-dash overuse — HTML entity escapes', () => {
|
||
// Build a full page so the page-level text-content analyzer runs. `body` is the
|
||
// prose that carries the dashes; the doctype/html scaffold is required by
|
||
// isFullPage(). Each dash spelling is a separate case because the rule counts
|
||
// per page, not per element.
|
||
const page = (body) =>
|
||
`<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>t</title></head>` +
|
||
`<body><main><h1>A real page heading of ordinary length</h1><p>${body}</p></main></body></html>`;
|
||
|
||
// Eight dashes clears the raised advisory floor (EM_DASH_FLOOR = 8, up from
|
||
// the old flat 5). Packed into one short paragraph they also clear the density
|
||
// gate. Sentence fragments keep the surrounding prose realistic so nothing
|
||
// else in the pipeline objects.
|
||
const eightNamed = 'fast — cheap — honest — simple — quiet — kind — bright — calm — done';
|
||
const eightNumeric = 'fast — cheap — honest — simple — quiet — kind — bright — calm — done';
|
||
const eightHex = 'fast — cheap — honest — simple — quiet — kind — bright — calm — done';
|
||
const eightHexUpper = 'fast — cheap — honest — simple — quiet — kind — bright — calm — done';
|
||
const eightNumericPadded = 'fast — cheap — honest — simple — quiet — kind — bright — calm — done';
|
||
// Four literal glyphs + four named entities render identically; the count
|
||
// must see all eight.
|
||
const mixed = 'fast — cheap — honest — simple — quiet — kind — bright — calm — done';
|
||
|
||
const SHOULD_FLAG = {
|
||
'named —': eightNamed,
|
||
'numeric —': eightNumeric,
|
||
'hex —': eightHex,
|
||
'uppercase-hex —': eightHexUpper,
|
||
'zero-padded decimal —': eightNumericPadded,
|
||
'mixed literal + entity': mixed,
|
||
};
|
||
|
||
// A long paragraph carrying exactly eight dashes across several thousand
|
||
// characters of prose. Above the absolute floor, but the density gate
|
||
// (one per ~500 chars) keeps ordinary long-form writing from flagging.
|
||
const longLowDensityFiller = 'This paragraph is written in ordinary human prose that runs on for quite a while. '.repeat(60);
|
||
const longLowDensity = `a — b — c — d — e — f — g — h — end. ${longLowDensityFiller}`;
|
||
|
||
// False-positive shapes: none of these should trip the em-dash counter.
|
||
const SHOULD_PASS = {
|
||
// Below the floor: seven dashes on a short page is under the raised floor of 8.
|
||
'seven dashes below floor': 'a — b — c — d — e — f — g — done, otherwise plain sentences fill the paragraph body',
|
||
// Below the floor: occasional em-dash entity use is legitimate prose.
|
||
'two entities below threshold': 'fast — cheap — done, otherwise plain sentences fill the paragraph body',
|
||
// Above the floor but below the density gate: a long human article.
|
||
'eight dashes across a long article': longLowDensity,
|
||
// En-dashes are a different character and a different job (ranges); the em-dash
|
||
// rule must not decode or count them.
|
||
'en-dash entities': 'pages 10–20 and 30–40 and 50–60 and 70–80 and 90–100 and 1–2 and 3–4 and 5–6 and 7–8',
|
||
'numeric en-dash entities': 'pages 10–20 and 30–40 and 50–60 and 70–80 and 90–100 and 1–2 and 3–4 and 5–6',
|
||
// Double-escaped: the visible text is the literal string "—", not a dash.
|
||
'double-escaped ampersand': 'write &mdash; and &mdash; and &mdash; and &mdash; and &mdash; and &mdash; and &mdash; and &mdash; literally',
|
||
// Unrelated entities must never be miscounted as dashes.
|
||
'non-dash entities': 'a b © c … d & e ™ f ® g ° h § i ¶',
|
||
// Ordinary hyphenated compounds are single hyphens, not the double-hyphen tell.
|
||
'hyphenated compounds': 'state-of-the-art, well-being, high-quality, self-service, end-to-end, at-a-glance, day-to-day, off-the-shelf copy',
|
||
};
|
||
|
||
const emDashFindings = (findings) =>
|
||
findings.filter((r) => r.antipattern === 'em-dash-overuse');
|
||
|
||
for (const [label, body] of Object.entries(SHOULD_FLAG)) {
|
||
it(`flags em-dash overuse spelled as ${label}`, () => {
|
||
const findings = detectText(page(body), 'em-dash.html');
|
||
const hits = emDashFindings(findings);
|
||
assert.equal(
|
||
hits.length, 1,
|
||
`expected em-dash-overuse for "${label}", got: ${findings.map((r) => r.antipattern).join(', ') || 'none'}`,
|
||
);
|
||
// The rule is advisory: the finding must carry the flag so the CLI, JSON,
|
||
// and hook can partition it out of the failure set.
|
||
assert.equal(hits[0].advisory, true, `"${label}" finding should be marked advisory`);
|
||
});
|
||
}
|
||
|
||
for (const [label, body] of Object.entries(SHOULD_PASS)) {
|
||
it(`does not flag ${label}`, () => {
|
||
const findings = detectText(page(body), 'em-dash.html');
|
||
assert.equal(
|
||
emDashFindings(findings).length, 0,
|
||
`"${label}" should not flag em-dash overuse`,
|
||
);
|
||
});
|
||
}
|
||
|
||
it('static-HTML path decodes entity em-dashes too (fixture file)', async () => {
|
||
const findings = await detectHtml(path.join(FIXTURES, 'em-dash-entities.html'));
|
||
const hits = findings.filter((r) => r.antipattern === 'em-dash-overuse');
|
||
assert.equal(
|
||
hits.length, 1,
|
||
'em-dash-entities.html should flag em-dash overuse via the static-HTML path',
|
||
);
|
||
assert.equal(hits[0].advisory, true, 'static-HTML em-dash finding should be advisory');
|
||
});
|
||
});
|
||
|
||
describe('formatFindings — advisory partitioning', () => {
|
||
const primary = { antipattern: 'side-tab', name: 'Side-tab', description: 'A primary finding.', file: 'a.css', line: 1, snippet: 'x' };
|
||
const advisory = { antipattern: 'em-dash-overuse', name: 'Em-dash', description: 'An advisory finding.', file: 'a.html', line: 0, snippet: '8 em-dashes', advisory: true };
|
||
|
||
it('lists advisory findings in a separate section and excludes them from the failure count', () => {
|
||
const text = formatFindings([primary, advisory], false);
|
||
assert.match(text, /1 anti-pattern found\./); // primary count only
|
||
assert.match(text, /Advisory \(not counted as failures\)/);
|
||
assert.match(text, /em-dash-overuse/);
|
||
assert.match(text, /1 advisory note/);
|
||
});
|
||
|
||
it('reports zero failures for an advisory-only set but still shows the advisory section', () => {
|
||
const text = formatFindings([advisory], false);
|
||
assert.match(text, /0 anti-patterns found\./);
|
||
assert.match(text, /em-dash-overuse/);
|
||
});
|
||
|
||
it('keeps every finding (advisory flagged) in JSON output', () => {
|
||
const json = JSON.parse(formatFindings([primary, advisory], true));
|
||
assert.equal(json.length, 2);
|
||
assert.equal(json.find((f) => f.antipattern === 'em-dash-overuse').advisory, true);
|
||
assert.equal(json.find((f) => f.antipattern === 'side-tab').advisory, undefined);
|
||
});
|
||
});
|
||
|
||
describe('em-dash overuse — browser adapter parity (checkEmDashOveruse)', () => {
|
||
// The browser DOM check operates on already-rendered text, so it exercises
|
||
// the same two-gate logic without entity decoding. checkEmDashOveruse is the
|
||
// pure core the DOM wrapper calls.
|
||
const id = (findings) => findings.map((f) => f.id).join(',');
|
||
|
||
it('flags eight dense em-dashes', () => {
|
||
const findings = checkEmDashOveruse('a — b — c — d — e — f — g — h — done');
|
||
assert.equal(id(findings), 'em-dash-overuse');
|
||
});
|
||
|
||
it('does not flag seven em-dashes (below the floor)', () => {
|
||
const findings = checkEmDashOveruse('a — b — c — d — e — f — g — done');
|
||
assert.equal(findings.length, 0);
|
||
});
|
||
|
||
it('does not flag eight em-dashes spread across long prose (density gate)', () => {
|
||
const filler = 'This is ordinary human prose that continues at length. '.repeat(80);
|
||
const findings = checkEmDashOveruse(`a — b — c — d — e — f — g — h — end. ${filler}`);
|
||
assert.equal(findings.length, 0);
|
||
});
|
||
|
||
it('counts the double-hyphen em-dash substitute', () => {
|
||
const findings = checkEmDashOveruse('a--b c--d e--f g--h i--j k--l m--n o--p done');
|
||
assert.equal(id(findings), 'em-dash-overuse');
|
||
});
|
||
});
|
||
|
||
describe('detectHtml — CSS patterns in prose (css-in-prose fixtures)', () => {
|
||
// CSS-property regexes must scan only real style carriers (<style> blocks,
|
||
// style="" attributes, linked stylesheets, class="" attributes). Prose,
|
||
// <code>/<pre> samples, and comments documenting a pattern are not styling.
|
||
// Live FP: impeccable.style's changelog line
|
||
// `<code>background-clip: text</code> gradients stop tripping contrast`.
|
||
const SCOPED_RULE_IDS = ['gradient-text', 'ai-color-palette', 'side-tab', 'pulsing-dot'];
|
||
|
||
it('documentation about the patterns adds no findings', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'css-in-prose-should-pass.html'));
|
||
const hits = f.filter(r => SCOPED_RULE_IDS.includes(r.antipattern));
|
||
assert.equal(
|
||
hits.length, 0,
|
||
`prose/code/comment mentions must not flag, got: ${hits.map(r => `${r.antipattern}:${r.snippet}`).join('; ')}`
|
||
);
|
||
});
|
||
|
||
it('the same patterns applied as real styling still flag', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'css-in-prose-should-flag.html'));
|
||
const gradient = f.filter(r => r.antipattern === 'gradient-text');
|
||
assert.ok(
|
||
gradient.some(r => /background-clip: text \+ gradient/.test(r.snippet || '')),
|
||
'expected the CSS-mechanism gradient-text finding'
|
||
);
|
||
assert.ok(
|
||
gradient.some(r => /bg-clip-text \+ bg-gradient \(Tailwind\)/.test(r.snippet || '')),
|
||
'expected the Tailwind gradient-text finding'
|
||
);
|
||
assert.ok(f.some(r => r.antipattern === 'ai-color-palette'), 'expected ai-color-palette');
|
||
});
|
||
});
|
||
|
||
describe('detectHtml — dark themes written in modern color syntax', () => {
|
||
// A dark page whose ground and surfaces are written in oklch, color(srgb),
|
||
// color(display-p3), and lch. Backgrounds the parser cannot read must make
|
||
// the contrast checks abstain; assuming the browser default of white turns
|
||
// every light-on-dark line into a false "on #ffffff" finding.
|
||
const FLAG_PAIRS = [
|
||
// The ground is a two-stop oklch gradient; the check reports the worst
|
||
// stop, which for charcoal copy is the lighter one.
|
||
['#35332d', '#050403'], // Flag Muted On Oklch Ground
|
||
['#47474d', '#1a1c1f'], // Flag Dim On Srgb Panel
|
||
['#59595c', '#121215'], // Flag Dim On Display P3 Panel
|
||
['#56514e', '#302b27'], // Flag Dim On Lch Panel
|
||
['#bfbdb8', '#faf7f2'], // Flag Pale On Light Panel
|
||
['#c7c4bf', '#faf7f2'], // Flag Pale On Inherited Light Panel
|
||
['#bfbdb8', '#f0ede8'], // Flag Pale On Currentcolor Panel
|
||
];
|
||
|
||
it('flags text that genuinely fails against a ground the parser can read', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'dark-theme-modern-color.html'));
|
||
const snippets = f.filter(r => r.antipattern === 'low-contrast').map(r => r.snippet || '');
|
||
for (const [text, bg] of FLAG_PAIRS) {
|
||
assert.ok(
|
||
snippets.some(s => s.includes(`text ${text}`) && s.includes(`on ${bg}`)),
|
||
`expected low-contrast for text ${text} on ${bg}, got: ${snippets.join('; ')}`,
|
||
);
|
||
}
|
||
});
|
||
|
||
it('never assumes white when the ground is unreadable', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'dark-theme-modern-color.html'));
|
||
const onWhite = f.filter(r => /on #ffffff/i.test(r.snippet || ''));
|
||
assert.equal(
|
||
onWhite.length, 0,
|
||
`no finding may claim a white ground on this page, got: ${onWhite.map(r => r.snippet).join('; ')}`,
|
||
);
|
||
});
|
||
|
||
it('light copy on readable dark surfaces stays quiet', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'dark-theme-modern-color.html'));
|
||
const pale = f.filter(r =>
|
||
(r.antipattern === 'low-contrast' || r.antipattern === 'gray-on-color') &&
|
||
/#e7e4dd/i.test(r.snippet || '')
|
||
);
|
||
assert.equal(
|
||
pale.length, 0,
|
||
`ivory copy on dark grounds must not flag, got: ${pale.map(r => r.snippet).join('; ')}`,
|
||
);
|
||
});
|
||
|
||
it('never measures a gradient hidden beneath an image layer', async () => {
|
||
// `url(...), linear-gradient(red, blue)` paints the image on top; the
|
||
// gradient is invisible. Falling back to its stops manufactured
|
||
// gray-on-color / low-contrast findings against colors nobody sees.
|
||
const f = await detectHtml(path.join(FIXTURES, 'dark-theme-modern-color.html'));
|
||
const hidden = f.filter(r => /#ff0000|#0000ff/i.test(r.snippet || ''));
|
||
assert.equal(
|
||
hidden.length, 0,
|
||
`no finding may reference the occluded gradient's stops, got: ${hidden.map(r => r.snippet).join('; ')}`,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('detectHtml — organic-clip-path', () => {
|
||
const SHOULD_FLAG = ['polygon() with 18 vertices', 'polygon() with 21 vertices', 'path() with 6 curve segments'];
|
||
it('flags organic polygon/path clips and passes geometric clips', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'organic-clip-path.html'));
|
||
const hits = f.filter(r => r.antipattern === 'organic-clip-path');
|
||
// arch (18), blob (21), silhouette path, inline blob (21)
|
||
assert.equal(hits.length, 4, hits.map(h => h.snippet).join('\n'));
|
||
for (const text of SHOULD_FLAG) {
|
||
assert.ok(hits.some(h => (h.snippet || '').includes(text)), `expected a finding containing "${text}"`);
|
||
}
|
||
// geometric clips never mention themselves
|
||
for (const h of hits) assert.doesNotMatch(h.snippet, /with [5-9] vertices/);
|
||
});
|
||
});
|
||
|
||
describe('detectHtml — buried-raster', () => {
|
||
it('flags rasters under near-opaque washes and at near-zero opacity, passes tints, blends, and visible textures', async () => {
|
||
const f = await detectHtml(path.join(FIXTURES, 'buried-raster.html'));
|
||
const hits = f.filter(r => r.antipattern === 'buried-raster');
|
||
const snippets = hits.map(h => h.snippet || '');
|
||
assert.equal(snippets.filter(s => /near-opaque gradient wash/.test(s)).length, 2, snippets.join('\n'));
|
||
assert.ok(snippets.some(s => /raster background at opacity 0.04 "Grain"/.test(s)), snippets.join('\n'));
|
||
assert.ok(snippets.some(s => /<img> at opacity 0.05 "Ghost img"/.test(s)), snippets.join('\n'));
|
||
assert.equal(hits.length, 4, snippets.join('\n'));
|
||
// the passing shapes never appear
|
||
assert.ok(!snippets.some(s => /hero\.jpg|opacity 0\.6|multiply|Faint text/.test(s)));
|
||
});
|
||
});
|