mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-18 00:56:30 +03:00
Detector architecture v2: static engine, benchmarks, lab, and visual contrast (#156)
* Add detector benchmark lab and visual contrast fallback * Expand visual contrast fixture coverage * Add browser visual contrast fallback * Show visual contrast overlays in detector lab * Fix detector lab short viewport layout * Fix detector lab visual overlays * Add visual contrast to browser scan overlays * Avoid browser scroll jumps during visual contrast scans * Resolve visual contrast lazily on scroll * Refresh detector lab visual counts lazily * Update pnpm lockfile for static parser deps * Address Bugbot detector API comments * Report extension visual contrast errors * Refactor detector into engine modules * Address Bugbot detector comments * Fix latest Bugbot detector notes * Fix visual contrast fixture labels * Refine detector lab fixtures * Fix stale detector overlay references * Fix detector lab fixture URLs * Fix typography lab fixture highlights * Fix typography lab page-level signal * Fix visual overlay lifecycle cleanup * Remove dead spotlight timer cleanup * Make browser async APIs reject consistently
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
/**
|
||||
* 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.
|
||||
* Some detection rules (cramped-padding, line-length, body-text-viewport-edge)
|
||||
* need real browser layout — they read getBoundingClientRect and real
|
||||
* getComputedStyle results that the static HTML/CSS engine intentionally
|
||||
* does not invent.
|
||||
*
|
||||
* This file uses detectUrl() (Puppeteer) to load fixtures in headless Chrome
|
||||
* via a temporary static HTTP server, so the fixtures can use absolute
|
||||
@@ -20,12 +19,10 @@ 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';
|
||||
import { createBrowserDetector, 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',
|
||||
@@ -37,6 +34,7 @@ const MIME = {
|
||||
};
|
||||
|
||||
let server;
|
||||
let baseUrl;
|
||||
|
||||
before(async () => {
|
||||
// Static server: maps /fixtures/* to tests/fixtures/* and
|
||||
@@ -61,22 +59,29 @@ before(async () => {
|
||||
res.writeHead(404).end();
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(PORT, resolve));
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
server.off('error', reject);
|
||||
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
if (server?.listening) 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
|
||||
// Everything else in the quality.html fixture runs in static HTML/CSS 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 f = await detectUrl(`${baseUrl}/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)):
|
||||
@@ -94,12 +99,49 @@ describe('detectUrl — browser-only fixtures', () => {
|
||||
});
|
||||
|
||||
it('line-length: flag column triggers, pass column adds none', async () => {
|
||||
const f = await detectUrl(`${BASE}/fixtures/antipatterns/quality.html`);
|
||||
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/quality.html`);
|
||||
assert.equal(f.filter(r => r.antipattern === 'line-length').length, 1);
|
||||
});
|
||||
|
||||
it('typography side-by-side: element-level flag cases get regular overlays', async () => {
|
||||
const puppeteer = await import('puppeteer');
|
||||
const browser = await puppeteer.default.launch({
|
||||
headless: true,
|
||||
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
await page.goto(`${baseUrl}/fixtures/antipatterns/typography.html`, { waitUntil: 'load' });
|
||||
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
|
||||
await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
|
||||
await page.evaluate(browserScript);
|
||||
const result = await page.evaluate(() => {
|
||||
const groups = window.impeccableScan();
|
||||
const types = groups.flatMap(group => group.findings.map(finding => finding.type || finding.id));
|
||||
return {
|
||||
types,
|
||||
pageTypes: groups
|
||||
.filter(group => group.el === document.body || group.el === document.documentElement)
|
||||
.flatMap(group => group.findings.map(finding => finding.type || finding.id)),
|
||||
hasBanner: Boolean(document.querySelector('.impeccable-banner')),
|
||||
overlays: document.querySelectorAll('.impeccable-overlay:not(.impeccable-banner)').length,
|
||||
};
|
||||
});
|
||||
for (const id of ['tight-leading', 'tiny-text', 'all-caps-body', 'wide-tracking', 'justified-text']) {
|
||||
assert.ok(result.types.includes(id), `expected browser typography scan to include ${id}: ${JSON.stringify(result)}`);
|
||||
}
|
||||
assert.ok(result.pageTypes.includes('overused-font'), `expected browser typography scan to include page-level overused-font: ${JSON.stringify(result)}`);
|
||||
assert.equal(result.hasBanner, true, `expected page-level typography banner: ${JSON.stringify(result)}`);
|
||||
assert.ok(result.overlays >= 5, `expected visible typography overlays, got: ${JSON.stringify(result)}`);
|
||||
await page.close();
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
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 f = await detectUrl(`${baseUrl}/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
|
||||
@@ -107,4 +149,500 @@ describe('detectUrl — browser-only fixtures', () => {
|
||||
// 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))}`);
|
||||
});
|
||||
|
||||
it('visual contrast: browser fallback catches low contrast on image backgrounds', async () => {
|
||||
const analyticOnly = await detectUrl(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, {
|
||||
waitUntil: 'load',
|
||||
visualContrast: false,
|
||||
});
|
||||
assert.equal(
|
||||
analyticOnly.some(r => r.antipattern === 'low-contrast' && /White text on light image/i.test(r.snippet || '')),
|
||||
false,
|
||||
'analytic contrast should not guess image-background contrast',
|
||||
);
|
||||
|
||||
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, {
|
||||
waitUntil: 'load',
|
||||
visualContrast: true,
|
||||
visualContrastMaxCandidates: 20,
|
||||
});
|
||||
const visualFindings = f.filter(r =>
|
||||
r.antipattern === 'low-contrast' &&
|
||||
/(?:browser|pixel) contrast/i.test(r.snippet || '')
|
||||
);
|
||||
assert.equal(
|
||||
visualFindings.length,
|
||||
4,
|
||||
`expected 4 visual contrast findings, got ${visualFindings.length}: ${JSON.stringify(visualFindings.map(r => r.snippet))}`,
|
||||
);
|
||||
assert.ok(
|
||||
f.some(r =>
|
||||
r.antipattern === 'low-contrast' &&
|
||||
/(?:browser|pixel) contrast/i.test(r.snippet || '') &&
|
||||
/White text on light image/i.test(r.snippet || '')
|
||||
),
|
||||
`expected visual contrast finding for light image background, got: ${JSON.stringify(f.map(r => r.snippet))}`,
|
||||
);
|
||||
assert.ok(
|
||||
f.some(r => r.antipattern === 'low-contrast' && /Dark text on dark image/i.test(r.snippet || '')),
|
||||
'expected pixel contrast finding for dark text on dark image',
|
||||
);
|
||||
assert.ok(
|
||||
f.some(r => r.antipattern === 'low-contrast' && /Translucent white text on a pale pattern/i.test(r.snippet || '')),
|
||||
'expected pixel contrast finding for translucent text on pale pattern',
|
||||
);
|
||||
assert.ok(
|
||||
f.some(r => r.antipattern === 'low-contrast' && /Muted gray text on a misty image/i.test(r.snippet || '')),
|
||||
'expected pixel contrast finding for muted gray text on misty image',
|
||||
);
|
||||
assert.equal(
|
||||
f.some(r => r.antipattern === 'low-contrast' && /White text on dark image/i.test(r.snippet || '')),
|
||||
false,
|
||||
'dark image background should keep enough contrast',
|
||||
);
|
||||
assert.equal(
|
||||
f.some(r => r.antipattern === 'low-contrast' && /Dark text on light image/i.test(r.snippet || '')),
|
||||
false,
|
||||
'light image with dark text should keep enough contrast',
|
||||
);
|
||||
assert.equal(
|
||||
f.some(r => r.antipattern === 'low-contrast' && /Should (?:flag|pass) after pixel sampling/i.test(r.snippet || '')),
|
||||
false,
|
||||
'fixture column headings should not be low-contrast findings',
|
||||
);
|
||||
});
|
||||
|
||||
it('browser API: visual contrast fallback resolves readable image backgrounds without overlays', async () => {
|
||||
const puppeteer = await import('puppeteer');
|
||||
const browser = await puppeteer.default.launch({
|
||||
headless: true,
|
||||
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
await page.goto(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, { waitUntil: 'load' });
|
||||
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
|
||||
await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
|
||||
await page.evaluate(browserScript);
|
||||
const result = await page.evaluate(async () => {
|
||||
const before = document.querySelectorAll('.impeccable-overlay, .impeccable-label, .impeccable-banner').length;
|
||||
const analyses = await window.impeccableAnalyzeVisualContrast({ maxCandidates: 20, scrollOffscreen: true });
|
||||
const after = document.querySelectorAll('.impeccable-overlay, .impeccable-label, .impeccable-banner').length;
|
||||
return {
|
||||
before,
|
||||
after,
|
||||
failed: analyses.filter(item => item.status === 'fail').map(item => item.finding?.snippet || ''),
|
||||
passed: analyses.filter(item => item.status === 'pass').map(item => item.text || ''),
|
||||
unresolved: analyses.filter(item => item.status === 'unresolved').map(item => item.reason || ''),
|
||||
};
|
||||
});
|
||||
assert.equal(result.before, 0);
|
||||
assert.equal(result.after, 0);
|
||||
assert.equal(result.failed.length, 4, `expected 4 browser visual failures, got: ${JSON.stringify(result)}`);
|
||||
assert.ok(result.failed.some(snippet => /White text on light image/i.test(snippet)));
|
||||
assert.ok(result.failed.some(snippet => /Dark text on dark image/i.test(snippet)));
|
||||
assert.ok(result.failed.every(snippet => /browser contrast/i.test(snippet)));
|
||||
assert.ok(result.passed.some(text => /White text on dark image/i.test(text)));
|
||||
assert.ok(result.passed.some(text => /Dark text on light image/i.test(text)));
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('browser API: visual contrast scan decorates visible findings without scrolling by default', async () => {
|
||||
const puppeteer = await import('puppeteer');
|
||||
const browser = await puppeteer.default.launch({
|
||||
headless: true,
|
||||
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
await page.goto(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, { waitUntil: 'load' });
|
||||
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
|
||||
await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
|
||||
await page.evaluate(browserScript);
|
||||
const result = await page.evaluate(async () => {
|
||||
let scrollEvents = 0;
|
||||
let maxScrollY = window.scrollY;
|
||||
window.addEventListener('scroll', () => {
|
||||
scrollEvents += 1;
|
||||
maxScrollY = Math.max(maxScrollY, window.scrollY);
|
||||
}, { passive: true });
|
||||
const syncScanResult = window.impeccableScan({
|
||||
visualContrast: true,
|
||||
visualContrastMaxCandidates: 20,
|
||||
});
|
||||
const syncDetectResult = window.impeccableDetect({
|
||||
visualContrast: true,
|
||||
serialize: true,
|
||||
});
|
||||
const groups = await window.impeccableScanAsync({
|
||||
visualContrast: true,
|
||||
visualContrastMaxCandidates: 20,
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
return {
|
||||
groups: groups.map(group => ({
|
||||
text: group.el.textContent || '',
|
||||
types: group.findings.map(finding => finding.type || finding.id),
|
||||
})),
|
||||
overlays: document.querySelectorAll('.impeccable-overlay:not(.impeccable-banner)').length,
|
||||
labels: document.querySelectorAll('.impeccable-label').length,
|
||||
analyses: window.impeccableGetLastVisualContrastAnalyses().filter(item => item.status === 'fail').length,
|
||||
scrollEvents,
|
||||
maxScrollY,
|
||||
finalScrollY: window.scrollY,
|
||||
syncScanIsArray: Array.isArray(syncScanResult),
|
||||
syncDetectIsArray: Array.isArray(syncDetectResult),
|
||||
hasAsyncApi: typeof window.impeccableScanAsync === 'function' && typeof window.impeccableDetectAsync === 'function',
|
||||
};
|
||||
});
|
||||
const visualGroups = result.groups.filter(group =>
|
||||
group.types.includes('low-contrast') &&
|
||||
/(?:White text on light image|Dark text on dark image|Translucent white text|Muted gray text)/i.test(group.text)
|
||||
);
|
||||
assert.equal(result.analyses, 3, `expected 3 viewport visual failures, got: ${JSON.stringify(result)}`);
|
||||
assert.equal(visualGroups.length, 3, `expected 3 viewport visual groups, got: ${JSON.stringify(result)}`);
|
||||
assert.ok(result.overlays >= 3, `expected regular overlays for visible visual findings, got: ${JSON.stringify(result)}`);
|
||||
assert.ok(result.labels >= 3, `expected regular labels for visible visual findings, got: ${JSON.stringify(result)}`);
|
||||
assert.equal(result.maxScrollY, 0, `visual scan should not scroll the page by default: ${JSON.stringify(result)}`);
|
||||
assert.equal(result.finalScrollY, 0, `visual scan should preserve scroll by default: ${JSON.stringify(result)}`);
|
||||
assert.equal(result.syncScanIsArray, true, `impeccableScan should keep a synchronous Array return: ${JSON.stringify(result)}`);
|
||||
assert.equal(result.syncDetectIsArray, true, `impeccableDetect should keep a synchronous Array return: ${JSON.stringify(result)}`);
|
||||
assert.equal(result.hasAsyncApi, true, `visual contrast should expose explicit async APIs: ${JSON.stringify(result)}`);
|
||||
|
||||
const refreshedOverlayResult = await page.evaluate(async () => {
|
||||
window.scrollTo(0, 0);
|
||||
const target = [...document.querySelectorAll('p')]
|
||||
.find(node => /White text on light image should be sampled/i.test(node.textContent || ''));
|
||||
target.style.fontSize = '10px';
|
||||
const initialGroups = window.impeccableScan({
|
||||
visualContrast: true,
|
||||
visualContrastMaxCandidates: 20,
|
||||
});
|
||||
const initialTargetGroup = initialGroups.find(group => group.el === target);
|
||||
const deadline = Date.now() + 1000;
|
||||
while (
|
||||
Date.now() < deadline &&
|
||||
!/low contrast/i.test(target?._impeccableOverlay?.textContent || '')
|
||||
) {
|
||||
const nextButton = target?._impeccableOverlay?.querySelector('button:last-of-type');
|
||||
if (nextButton) nextButton.click();
|
||||
await new Promise(resolve => setTimeout(resolve, 25));
|
||||
}
|
||||
const labelVariants = [];
|
||||
const overlay = target?._impeccableOverlay;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
labelVariants.push(overlay?.textContent || '');
|
||||
overlay?.querySelector('button:last-of-type')?.click();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
return {
|
||||
initialTypes: initialTargetGroup?.findings.map(finding => finding.type || finding.id) || [],
|
||||
labelText: target?._impeccableOverlay?.textContent || '',
|
||||
labelVariants,
|
||||
overlayConnected: Boolean(target?._impeccableOverlay?.isConnected),
|
||||
};
|
||||
});
|
||||
assert.ok(refreshedOverlayResult.initialTypes.includes('tiny-text'), `test setup should create an initial sync overlay on the target: ${JSON.stringify(refreshedOverlayResult)}`);
|
||||
assert.ok(refreshedOverlayResult.labelVariants.some(text => /tiny body text/i.test(text)), `expected refreshed overlay to keep the sync finding label: ${JSON.stringify(refreshedOverlayResult)}`);
|
||||
assert.ok(refreshedOverlayResult.labelVariants.some(text => /low contrast/i.test(text)), `expected visual contrast to refresh the existing overlay label: ${JSON.stringify(refreshedOverlayResult)}`);
|
||||
assert.equal(refreshedOverlayResult.overlayConnected, true, `expected refreshed overlay to stay connected: ${JSON.stringify(refreshedOverlayResult)}`);
|
||||
|
||||
const lazyResult = await page.evaluate(async () => {
|
||||
const target = [...document.querySelectorAll('p')]
|
||||
.find(node => /Muted gray text on a misty image/i.test(node.textContent || ''));
|
||||
target?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
return {
|
||||
overlays: document.querySelectorAll('.impeccable-overlay:not(.impeccable-banner)').length,
|
||||
labels: document.querySelectorAll('.impeccable-label').length,
|
||||
analyses: window.impeccableGetLastVisualContrastAnalyses().filter(item => item.status === 'fail').length,
|
||||
targetHasOverlay: Boolean(target?._impeccableOverlay),
|
||||
scrollY: window.scrollY,
|
||||
};
|
||||
});
|
||||
assert.equal(lazyResult.analyses, 4, `expected lazy visual resolution after scrolling into view, got: ${JSON.stringify(lazyResult)}`);
|
||||
assert.ok(lazyResult.overlays >= 4, `expected lazy visual overlay after scrolling into view, got: ${JSON.stringify(lazyResult)}`);
|
||||
assert.ok(lazyResult.labels >= 4, `expected lazy visual label after scrolling into view, got: ${JSON.stringify(lazyResult)}`);
|
||||
assert.equal(lazyResult.targetHasOverlay, true, `expected lazy visual target to get a regular overlay, got: ${JSON.stringify(lazyResult)}`);
|
||||
assert.ok(lazyResult.scrollY > 0, `test should have naturally scrolled to the offscreen case: ${JSON.stringify(lazyResult)}`);
|
||||
|
||||
const staleOverlayResult = await page.evaluate(async () => {
|
||||
const target = [...document.querySelectorAll('p')]
|
||||
.find(node => /Muted gray text on a misty image/i.test(node.textContent || ''));
|
||||
window.scrollTo(0, 0);
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
await window.impeccableScanAsync({
|
||||
visualContrast: true,
|
||||
visualContrastMaxCandidates: 20,
|
||||
});
|
||||
const staleCleared = !target?._impeccableOverlay;
|
||||
target?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
return {
|
||||
staleCleared,
|
||||
targetHasOverlay: Boolean(target?._impeccableOverlay),
|
||||
targetOverlayConnected: Boolean(target?._impeccableOverlay?.isConnected),
|
||||
overlays: document.querySelectorAll('.impeccable-overlay:not(.impeccable-banner)').length,
|
||||
analyses: window.impeccableGetLastVisualContrastAnalyses().filter(item => item.status === 'fail').length,
|
||||
};
|
||||
});
|
||||
assert.equal(staleOverlayResult.staleCleared, true, `expected clearOverlays to remove stale target overlay refs, got: ${JSON.stringify(staleOverlayResult)}`);
|
||||
assert.equal(staleOverlayResult.targetHasOverlay, true, `expected lazy visual target to be highlightable after a rescan, got: ${JSON.stringify(staleOverlayResult)}`);
|
||||
assert.equal(staleOverlayResult.targetOverlayConnected, true, `expected lazy visual overlay after rescan to be connected, got: ${JSON.stringify(staleOverlayResult)}`);
|
||||
|
||||
const offscreenResult = await page.evaluate(async () => {
|
||||
window.scrollTo(0, 0);
|
||||
let maxScrollY = window.scrollY;
|
||||
window.addEventListener('scroll', () => {
|
||||
maxScrollY = Math.max(maxScrollY, window.scrollY);
|
||||
}, { passive: true });
|
||||
const groups = await window.impeccableScanAsync({
|
||||
visualContrast: true,
|
||||
visualContrastMaxCandidates: 20,
|
||||
visualContrastScrollOffscreen: true,
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
return {
|
||||
groups: groups.map(group => ({
|
||||
text: group.el.textContent || '',
|
||||
types: group.findings.map(finding => finding.type || finding.id),
|
||||
})),
|
||||
analyses: window.impeccableGetLastVisualContrastAnalyses().filter(item => item.status === 'fail').length,
|
||||
maxScrollY,
|
||||
finalScrollY: window.scrollY,
|
||||
};
|
||||
});
|
||||
const offscreenVisualGroups = offscreenResult.groups.filter(group =>
|
||||
group.types.includes('low-contrast') &&
|
||||
/(?:White text on light image|Dark text on dark image|Translucent white text|Muted gray text)/i.test(group.text)
|
||||
);
|
||||
assert.equal(offscreenResult.analyses, 4, `expected 4 opt-in visual failures, got: ${JSON.stringify(offscreenResult)}`);
|
||||
assert.equal(offscreenVisualGroups.length, 4, `expected 4 opt-in visual groups, got: ${JSON.stringify(offscreenResult)}`);
|
||||
assert.ok(offscreenResult.maxScrollY > 0, `offscreen opt-in should be allowed to scroll: ${JSON.stringify(offscreenResult)}`);
|
||||
assert.equal(offscreenResult.finalScrollY, 0, `offscreen opt-in should restore scroll: ${JSON.stringify(offscreenResult)}`);
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('extension mode remove cancels pending lazy visual contrast work', async () => {
|
||||
const puppeteer = await import('puppeteer');
|
||||
const browser = await puppeteer.default.launch({
|
||||
headless: true,
|
||||
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
await page.goto(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, { waitUntil: 'load' });
|
||||
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.dataset.impeccableExtension = 'true';
|
||||
window.__impeccableMessages = [];
|
||||
window.addEventListener('message', event => {
|
||||
if (event.source !== window || !event.data?.source?.startsWith('impeccable-')) return;
|
||||
window.__impeccableMessages.push(event.data);
|
||||
});
|
||||
});
|
||||
await page.evaluate(browserScript);
|
||||
const result = await page.evaluate(async () => {
|
||||
window.postMessage({
|
||||
source: 'impeccable-command',
|
||||
action: 'scan',
|
||||
config: {
|
||||
visualContrast: true,
|
||||
visualContrastMaxCandidates: 20,
|
||||
},
|
||||
}, '*');
|
||||
const scanDeadline = Date.now() + 1000;
|
||||
while (
|
||||
Date.now() < scanDeadline &&
|
||||
!window.impeccableGetLastVisualContrastAnalyses()
|
||||
.some(item => item.status === 'unresolved' && item.reason === 'text outside viewport')
|
||||
) {
|
||||
await new Promise(resolve => setTimeout(resolve, 25));
|
||||
}
|
||||
const unresolvedBeforeRemove = window.impeccableGetLastVisualContrastAnalyses()
|
||||
.filter(item => item.status === 'unresolved' && item.reason === 'text outside viewport').length;
|
||||
window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
const target = [...document.querySelectorAll('p')]
|
||||
.find(node => /Muted gray text on a misty image/i.test(node.textContent || ''));
|
||||
target?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
const resultsAfterRemove = window.__impeccableMessages
|
||||
.filter(message => message.source === 'impeccable-results').length;
|
||||
return {
|
||||
unresolvedBeforeRemove,
|
||||
overlayCount: document.querySelectorAll('.impeccable-overlay').length,
|
||||
targetHasOverlay: Boolean(target?._impeccableOverlay),
|
||||
resultsAfterRemove,
|
||||
};
|
||||
});
|
||||
assert.ok(result.unresolvedBeforeRemove > 0, `test setup should leave lazy visual candidates pending: ${JSON.stringify(result)}`);
|
||||
assert.equal(result.overlayCount, 0, `remove should not allow lazy visual overlays to reappear: ${JSON.stringify(result)}`);
|
||||
assert.equal(result.targetHasOverlay, false, `remove should clear stale target overlay refs: ${JSON.stringify(result)}`);
|
||||
await page.close();
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('extension mode reports async visual contrast errors to the panel', async () => {
|
||||
const puppeteer = await import('puppeteer');
|
||||
const browser = await puppeteer.default.launch({
|
||||
headless: true,
|
||||
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
await page.goto(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, { waitUntil: 'load' });
|
||||
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.dataset.impeccableExtension = 'true';
|
||||
window.__impeccableMessages = [];
|
||||
window.addEventListener('message', event => {
|
||||
if (event.source !== window || !event.data?.source?.startsWith('impeccable-')) return;
|
||||
window.__impeccableMessages.push(event.data);
|
||||
});
|
||||
});
|
||||
await page.evaluate(browserScript);
|
||||
const result = await page.evaluate(async () => {
|
||||
const originalGetContext = HTMLCanvasElement.prototype.getContext;
|
||||
HTMLCanvasElement.prototype.getContext = function getContext() {
|
||||
throw new Error('forced visual contrast canvas failure');
|
||||
};
|
||||
try {
|
||||
window.postMessage({
|
||||
source: 'impeccable-command',
|
||||
action: 'scan',
|
||||
config: {
|
||||
visualContrast: true,
|
||||
visualContrastMaxCandidates: 20,
|
||||
},
|
||||
}, '*');
|
||||
const deadline = Date.now() + 1000;
|
||||
while (
|
||||
Date.now() < deadline &&
|
||||
!window.__impeccableMessages.some(message => message.source === 'impeccable-error')
|
||||
) {
|
||||
await new Promise(resolve => setTimeout(resolve, 25));
|
||||
}
|
||||
return {
|
||||
ready: window.__impeccableMessages.some(message => message.source === 'impeccable-ready'),
|
||||
results: window.__impeccableMessages.some(message => message.source === 'impeccable-results'),
|
||||
errors: window.__impeccableMessages
|
||||
.filter(message => message.source === 'impeccable-error')
|
||||
.map(message => message.message || ''),
|
||||
};
|
||||
} finally {
|
||||
HTMLCanvasElement.prototype.getContext = originalGetContext;
|
||||
}
|
||||
});
|
||||
assert.equal(result.ready, true, `expected extension ready message, got: ${JSON.stringify(result)}`);
|
||||
assert.equal(result.results, true, `expected initial sync results before async visual error, got: ${JSON.stringify(result)}`);
|
||||
assert.ok(
|
||||
result.errors.some(message => /forced visual contrast canvas failure/.test(message)),
|
||||
`expected extension visual contrast error message, got: ${JSON.stringify(result)}`,
|
||||
);
|
||||
await page.close();
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('browser API: impeccableDetect is pure, impeccableScan decorates', async () => {
|
||||
const puppeteer = await import('puppeteer');
|
||||
const browser = await puppeteer.default.launch({
|
||||
headless: true,
|
||||
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
await page.goto(`${baseUrl}/fixtures/antipatterns/quality.html`, { waitUntil: 'load' });
|
||||
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
|
||||
await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
|
||||
await page.evaluate(browserScript);
|
||||
const pure = await page.evaluate(() => {
|
||||
const before = document.querySelectorAll('.impeccable-overlay, .impeccable-label, .impeccable-banner').length;
|
||||
const findings = window.impeccableDetect({ decorate: false, serialize: true });
|
||||
const after = document.querySelectorAll('.impeccable-overlay, .impeccable-label, .impeccable-banner').length;
|
||||
return { before, after, count: findings.length };
|
||||
});
|
||||
assert.equal(pure.before, 0);
|
||||
assert.equal(pure.after, 0);
|
||||
assert.ok(pure.count > 0);
|
||||
|
||||
const decorated = await page.evaluate(() => {
|
||||
const groups = window.impeccableScan();
|
||||
const overlays = document.querySelectorAll('.impeccable-overlay, .impeccable-label, .impeccable-banner').length;
|
||||
return { groups: groups.length, overlays };
|
||||
});
|
||||
assert.ok(decorated.groups > 0);
|
||||
assert.ok(decorated.overlays > 0);
|
||||
await page.close();
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('browser API: async scan and detect reject instead of throwing synchronously', async () => {
|
||||
const puppeteer = await import('puppeteer');
|
||||
const browser = await puppeteer.default.launch({
|
||||
headless: true,
|
||||
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
await page.goto(`${baseUrl}/fixtures/antipatterns/quality.html`, { waitUntil: 'load' });
|
||||
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
|
||||
await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
|
||||
await page.evaluate(browserScript);
|
||||
const result = await page.evaluate(async () => {
|
||||
const originalQuerySelectorAll = Document.prototype.querySelectorAll;
|
||||
Document.prototype.querySelectorAll = function querySelectorAll() {
|
||||
throw new Error('forced query failure');
|
||||
};
|
||||
try {
|
||||
const scan = await window.impeccableScanAsync().then(
|
||||
() => ({ state: 'resolved' }),
|
||||
error => ({ state: 'rejected', message: error?.message || String(error) }),
|
||||
);
|
||||
const detect = await window.impeccableDetectAsync().then(
|
||||
() => ({ state: 'resolved' }),
|
||||
error => ({ state: 'rejected', message: error?.message || String(error) }),
|
||||
);
|
||||
return { scan, detect };
|
||||
} finally {
|
||||
Document.prototype.querySelectorAll = originalQuerySelectorAll;
|
||||
}
|
||||
});
|
||||
assert.deepEqual(result.scan, { state: 'rejected', message: 'forced query failure' });
|
||||
assert.deepEqual(result.detect, { state: 'rejected', message: 'forced query failure' });
|
||||
await page.close();
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('createBrowserDetector reuses a browser and honors waitUntil overrides', async () => {
|
||||
const detector = await createBrowserDetector({ waitUntil: 'load', settleMs: 0 });
|
||||
try {
|
||||
const first = await detector.detectUrl(`${baseUrl}/fixtures/antipatterns/quality.html`);
|
||||
const second = await detector.detectUrl(`${baseUrl}/fixtures/antipatterns/body-text-viewport-edge.html`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
assert.ok(first.some(r => r.antipattern === 'line-length'));
|
||||
assert.equal(second.filter(r => r.antipattern === 'body-text-viewport-edge').length, 3);
|
||||
} finally {
|
||||
await detector.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* jsdom fixture tests for anti-pattern detection.
|
||||
* Run via Node's built-in test runner (not bun) to avoid jsdom resource limits.
|
||||
* 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
|
||||
*/
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURES = path.join(__dirname, 'fixtures', 'antipatterns');
|
||||
|
||||
describe('detectHtml — jsdom fixtures', () => {
|
||||
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'));
|
||||
@@ -27,11 +27,32 @@ describe('detectHtml — jsdom fixtures', () => {
|
||||
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,
|
||||
4,
|
||||
`expected 4 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 () => {
|
||||
@@ -48,6 +69,11 @@ describe('detectHtml — jsdom fixtures', () => {
|
||||
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' && /#ffffff|#fff/i.test(r.snippet || '')),
|
||||
false,
|
||||
'pure white surfaces with dark text should remain allowed',
|
||||
);
|
||||
// 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 || '')),
|
||||
@@ -163,10 +189,9 @@ describe('detectHtml — jsdom fixtures', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('legitimate-borders: minimal false positives', async () => {
|
||||
it('legitimate-borders: zero findings', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'legitimate-borders.html'));
|
||||
const borderFindings = f.filter(r => r.antipattern === 'side-tab' || r.antipattern === 'border-accent-on-rounded');
|
||||
assert.ok(borderFindings.length <= 1);
|
||||
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 () => {
|
||||
@@ -217,6 +242,20 @@ describe('detectHtml — jsdom fixtures', () => {
|
||||
assert.ok(f.some(r => r.antipattern === 'overused-font'));
|
||||
assert.ok(f.some(r => r.antipattern === 'single-font'));
|
||||
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 () => {
|
||||
@@ -265,13 +304,13 @@ describe('detectHtml — icon-tile-stack', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectHtml — quality (jsdom-compatible rules)', () => {
|
||||
// Six of the eight quality rules can run in jsdom because they only need
|
||||
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 jsdom-compatible quality rules', async () => {
|
||||
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);
|
||||
@@ -288,8 +327,8 @@ describe('detectHtml — layout', () => {
|
||||
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 jsdom doesn't execute. They're
|
||||
// effectively dormant in this test environment regardless of the fixture
|
||||
// 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);
|
||||
@@ -342,7 +381,7 @@ describe('detectHtml — hero-eyebrow-chip', () => {
|
||||
'Pill Chip Above Hero',
|
||||
'Already Uppercase Text',
|
||||
// The rule no longer gates on heading font size (modern hero h1s
|
||||
// use clamp() / vw / var() that jsdom can't resolve), and the
|
||||
// use clamp() / vw / var() that static HTML/CSS cannot resolve), and the
|
||||
// eyebrow text ceiling moved 30 → 60 chars. Both shapes now flag.
|
||||
'Body-Sized Heading Below Eyebrow',
|
||||
'Long Uppercase Sentence Above Hero',
|
||||
@@ -410,19 +449,17 @@ describe('detectHtml — repeated-section-kickers', () => {
|
||||
});
|
||||
|
||||
describe('detectHtml — motion', () => {
|
||||
// jsdom doesn't fully apply class-based styles, so the absolute finding counts
|
||||
// are lower than what a real browser would see. The hardcoded counts below are
|
||||
// the calibrated jsdom baseline — if a future change pushes them up, that's a
|
||||
// pass-column false positive; if down, the rule or fixture has regressed.
|
||||
// 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, 2);
|
||||
assert.equal(f.filter(r => r.antipattern === 'layout-transition').length, 8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectHtml — dark glow', () => {
|
||||
// Calibrated jsdom baseline — see motion test note above.
|
||||
// Calibrated static baseline — see motion test note above.
|
||||
it('glow: flag column triggers dark-glow, pass column adds none', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'glow.html'));
|
||||
assert.equal(f.filter(r => r.antipattern === 'dark-glow').length, 1);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
import {
|
||||
ANTIPATTERNS, checkElementBorders, checkElementMotion, checkElementGlow, isNeutralColor, isFullPage,
|
||||
detectText, extractStyleBlocks, extractCSSinJS,
|
||||
detectText, detectHtml, extractStyleBlocks, extractCSSinJS,
|
||||
walkDir, SCANNABLE_EXTENSIONS,
|
||||
buildImportGraph, resolveImport,
|
||||
detectFrameworkConfig, isPortListening, FRAMEWORK_CONFIGS,
|
||||
@@ -12,6 +13,30 @@ import {
|
||||
|
||||
const FIXTURES = path.join(import.meta.dir, 'fixtures', 'antipatterns');
|
||||
const SCRIPT = path.join(import.meta.dir, '..', 'cli', 'engine', 'detect-antipatterns.mjs');
|
||||
const BENCH_SCRIPT = path.join(import.meta.dir, '..', 'scripts', 'benchmark-detector.mjs');
|
||||
|
||||
function writeStaticFixture(files) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-static-'));
|
||||
for (const [name, contents] of Object.entries(files)) {
|
||||
const fullPath = path.join(dir, name);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, contents);
|
||||
}
|
||||
return { dir, file: path.join(dir, 'index.html') };
|
||||
}
|
||||
|
||||
async function withStaticFixture(files, callback) {
|
||||
const fixture = writeStaticFixture(files);
|
||||
try {
|
||||
return await callback(fixture);
|
||||
} finally {
|
||||
fs.rmSync(fixture.dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function findingIds(findings) {
|
||||
return findings.map(f => f.antipattern);
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -170,7 +195,7 @@ describe('detectText — flat type hierarchy', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// jsdom fixture tests moved to detect-antipatterns-fixtures.test.mjs (run via node --test)
|
||||
// Static HTML/CSS fixture tests moved to detect-antipatterns-fixtures.test.mjs (run via node --test)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full page vs partial detection
|
||||
@@ -508,6 +533,116 @@ describe('detectText — dark glow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static HTML/CSS engine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectHtml — static HTML/CSS engine', () => {
|
||||
test('inlines local linked stylesheets', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'linked-stylesheet.html'));
|
||||
expect(findingIds(f)).toContain('side-tab');
|
||||
});
|
||||
|
||||
test('flattens @layer, resolves CSS variables and fallbacks, and skips unsupported selectors', async () => {
|
||||
await withStaticFixture({
|
||||
'index.html': `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
@layer components {
|
||||
:root { --accent: #3b82f6; --fallback-accent: var(--missing-accent, #a855f7); }
|
||||
.layer-side { border-left: 5px solid var(--accent); border-radius: 8px; }
|
||||
.layer-top { border-top: 4px solid var(--fallback-accent); border-radius: 8px; }
|
||||
.ignored:future-only(foo) { border-left: 20px solid #ef4444; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layer-side">Layer variable side tab</div>
|
||||
<div class="layer-top">Fallback variable top accent</div>
|
||||
</body>
|
||||
</html>`,
|
||||
}, async ({ file }) => {
|
||||
const profile = [];
|
||||
const f = await detectHtml(file, { profile });
|
||||
const ids = findingIds(f);
|
||||
expect(ids).toContain('side-tab');
|
||||
expect(ids).toContain('border-accent-on-rounded');
|
||||
expect(profile.some(e => e.engine === 'static-html' && e.ruleId === 'unsupported-selector')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('honors specificity, source order, !important, and inline style precedence', async () => {
|
||||
await withStaticFixture({
|
||||
'index.html': `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
.specificity-pass { border-left: 5px solid #3b82f6; border-radius: 8px; }
|
||||
div.specificity-pass { border-left-color: #d1d5db; }
|
||||
.source-order-flag { border-left: 5px solid #d1d5db; border-radius: 8px; }
|
||||
.source-order-flag { border-left-color: #ef4444; }
|
||||
.important-pass { border-left: 5px solid #d1d5db !important; border-radius: 8px; }
|
||||
.important-pass { border-left-color: #3b82f6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="specificity-pass">Specificity neutral pass</div>
|
||||
<div class="source-order-flag">Source order chromatic flag</div>
|
||||
<div class="important-pass">Important neutral pass</div>
|
||||
<div style="border-left: 5px solid #06b6d4; border-radius: 8px;">Inline chromatic flag</div>
|
||||
</body>
|
||||
</html>`,
|
||||
}, async ({ file }) => {
|
||||
const f = await detectHtml(file);
|
||||
expect(findingIds(f).filter(id => id === 'side-tab')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
test('expands background, border, font, transition, and animation shorthands', async () => {
|
||||
await withStaticFixture({
|
||||
'index.html': `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
.font-short {
|
||||
font: italic 700 11px/1.05 Arial, sans-serif;
|
||||
}
|
||||
.background-short {
|
||||
background: #000;
|
||||
color: #111;
|
||||
font-size: 16px;
|
||||
}
|
||||
.border-short {
|
||||
border: 1px solid #d1d5db;
|
||||
border-left: 5px solid #3b82f6;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.motion-short {
|
||||
transition: width 250ms cubic-bezier(.68,-.55,.27,1.55);
|
||||
animation: bounce 1s cubic-bezier(.68,-.55,.27,1.55) infinite;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p class="font-short">This tiny paragraph is long enough to trigger both the static font shorthand size and line-height checks.</p>
|
||||
<button class="background-short">Low contrast button text</button>
|
||||
<div class="border-short">Border shorthand side tab</div>
|
||||
<div class="motion-short">Motion shorthand easing</div>
|
||||
</body>
|
||||
</html>`,
|
||||
}, async ({ file }) => {
|
||||
const ids = findingIds(await detectHtml(file));
|
||||
expect(ids).toContain('tiny-text');
|
||||
expect(ids).toContain('tight-leading');
|
||||
expect(ids).toContain('low-contrast');
|
||||
expect(ids).toContain('side-tab');
|
||||
expect(ids).toContain('bounce-easing');
|
||||
expect(ids).toContain('layout-transition');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ANTIPATTERNS registry
|
||||
@@ -559,6 +694,12 @@ describe('CLI', () => {
|
||||
expect(stdout).toContain('Usage:');
|
||||
});
|
||||
|
||||
test('detect subcommand is not treated as a scan target', () => {
|
||||
const { stderr, code } = run('detect', '--json', path.join(FIXTURES, 'should-pass.html'));
|
||||
expect(code).toBe(0);
|
||||
expect(stderr).not.toContain('cannot access detect');
|
||||
});
|
||||
|
||||
test('should-pass exits 0', () => {
|
||||
const { code } = run(path.join(FIXTURES, 'should-pass.html'));
|
||||
expect(code).toBe(0);
|
||||
@@ -598,7 +739,7 @@ describe('CLI', () => {
|
||||
expect(code).toBe(2);
|
||||
});
|
||||
|
||||
test('linked stylesheet detected (jsdom default)', () => {
|
||||
test('linked stylesheet detected (static HTML/CSS default)', () => {
|
||||
const { code, stderr } = run(path.join(FIXTURES, 'linked-stylesheet.html'));
|
||||
expect(code).toBe(2);
|
||||
expect(stderr).toContain('side-tab');
|
||||
@@ -610,6 +751,39 @@ describe('CLI', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detector benchmark smoke test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('benchmark-detector', () => {
|
||||
test('--quick --json emits timing schema', () => {
|
||||
const result = spawnSync('node', [BENCH_SCRIPT, '--quick', '--json'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30000,
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
const parsed = JSON.parse(result.stdout.trim());
|
||||
expect(parsed.version).toBe(1);
|
||||
expect(parsed.quick).toBe(true);
|
||||
expect(parsed.browser).toBe(false);
|
||||
expect(parsed.cases).toBeArray();
|
||||
expect(parsed.cases.length).toBeGreaterThan(0);
|
||||
expect(parsed.summary).toBeArray();
|
||||
expect(parsed.summary.length).toBeGreaterThan(0);
|
||||
|
||||
const okCase = parsed.cases.find(c => c.status === 'ok');
|
||||
expect(okCase).toBeTruthy();
|
||||
expect(okCase).toHaveProperty('totalMs');
|
||||
expect(okCase).toHaveProperty('findings');
|
||||
expect(okCase.profile).toBeArray();
|
||||
|
||||
const row = parsed.summary[0];
|
||||
for (const key of ['engine', 'phase', 'ruleId', 'target', 'calls', 'totalMs', 'avgMs', 'p50', 'p95', 'findings']) {
|
||||
expect(row).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tier 1: Vue/Svelte <style> block extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -17,14 +17,16 @@
|
||||
.container-good { max-width: 720px; margin: 0 auto; padding: 16px 32px; }
|
||||
.container-tight { padding: 16px 32px; }
|
||||
.full-bleed-section { background: #fef3c7; padding: 24px 16px; }
|
||||
nav.site-nav { padding: 12px 0; background: #f5f5f5; }
|
||||
header.banner { padding: 12px 0; background: #e5e7eb; }
|
||||
nav.site-nav { padding: 12px 24px; background: #f5f5f5; }
|
||||
header.banner { padding: 12px 24px; background: #e5e7eb; }
|
||||
|
||||
/* Override the grid container's own padding so we can demonstrate
|
||||
a paragraph that genuinely bleeds to the viewport edge inside
|
||||
the flag column — the col itself shouldn't reset its padding. */
|
||||
.flag-fullwidth-p { /* no container; placed directly in body via .escape */ }
|
||||
.escape { position: relative; left: 50%; right: 50%; margin-left: -50vw; margin-right: -50vw; width: 100vw; padding: 0; }
|
||||
.edge-list { margin: 0; padding: 0; list-style-position: inside; }
|
||||
.edge-list li { margin: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -41,7 +43,7 @@
|
||||
<p>This second flush-to-edge paragraph confirms the rule fires on every body paragraph that touches the viewport, not just one. The rendered width should be the full viewport, with text starting essentially at x=0.</p>
|
||||
</div>
|
||||
<div class="escape">
|
||||
<ul>
|
||||
<ul class="edge-list">
|
||||
<li>This is a list item whose text content is substantially long enough to qualify as body content. It also runs flush against the viewport edge with no left padding.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Border Anti-Patterns — Should Flag vs Should Pass</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
background: #f8fafc;
|
||||
color: #111827;
|
||||
font: 14px/1.5 system-ui, sans-serif;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto 8px;
|
||||
font-size: 34px;
|
||||
line-height: 1.05;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.intro {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto 24px;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 32px;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.col h2 {
|
||||
margin: 0 0 14px;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.case {
|
||||
margin: 0 0 14px;
|
||||
padding: 16px;
|
||||
background: #fdfdfd;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.case h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 15px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.case p {
|
||||
margin: 0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.flag-left {
|
||||
border-left: 4px solid #2563eb;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.flag-right {
|
||||
border-right: 5px solid #dc2626;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.flag-left-plain {
|
||||
border-left: 4px solid #0f766e;
|
||||
}
|
||||
|
||||
.flag-top {
|
||||
border-top: 4px solid #7c3aed;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.flag-bottom {
|
||||
border-bottom: 3px solid #ea580c;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.flag-dark {
|
||||
background: #141820;
|
||||
color: #f4f7fb;
|
||||
border-left: 4px solid #38bdf8;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.flag-dark p {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.pass-full-border {
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.pass-neutral-side {
|
||||
border-left: 4px solid #d1d5db;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.pass-thin-side {
|
||||
border-left: 1px solid #2563eb;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.pass-square-top {
|
||||
border-top: 4px solid #7c3aed;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.pass-square-bottom {
|
||||
border-bottom: 3px solid #ea580c;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.pass-dark-uniform {
|
||||
background: #161a22;
|
||||
color: #f4f7fb;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.pass-dark-uniform p {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Border side-tab detector cases</h1>
|
||||
<p class="intro">A paired fixture for colored side borders and rounded accent borders. The left column should produce border findings; the right column should stay clean.</p>
|
||||
|
||||
<div class="grid">
|
||||
<section class="col" data-col="flag">
|
||||
<h2>Should flag</h2>
|
||||
|
||||
<div class="case flag-left">
|
||||
<h3>Rounded card with colored left border</h3>
|
||||
<p>Classic side tab: border-left is thick, colored, and paired with rounded corners.</p>
|
||||
</div>
|
||||
|
||||
<div class="case flag-right">
|
||||
<h3>Rounded card with colored right border</h3>
|
||||
<p>The right-side version is the same visual trope mirrored.</p>
|
||||
</div>
|
||||
|
||||
<div class="case flag-left-plain">
|
||||
<h3>Thick colored side border without radius</h3>
|
||||
<p>A thick chromatic side stripe still reads as a tab even without rounded corners.</p>
|
||||
</div>
|
||||
|
||||
<div class="case flag-top">
|
||||
<h3>Rounded card with top accent border</h3>
|
||||
<p>A heavy top accent on a rounded card is the horizontal variant of the same pattern.</p>
|
||||
</div>
|
||||
|
||||
<div class="case flag-bottom">
|
||||
<h3>Rounded card with bottom accent border</h3>
|
||||
<p>The bottom accent is also a decorative stripe on a card surface.</p>
|
||||
</div>
|
||||
|
||||
<div class="case flag-dark">
|
||||
<h3>Dark card with bright side stripe</h3>
|
||||
<p>Dark surfaces can hide the cliche, but the thick colored side stripe remains.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="col" data-col="pass">
|
||||
<h2>Should pass</h2>
|
||||
|
||||
<div class="case pass-full-border">
|
||||
<h3>Uniform full border</h3>
|
||||
<p>A quiet all-around border frames the card instead of creating a side tab.</p>
|
||||
</div>
|
||||
|
||||
<div class="case pass-neutral-side">
|
||||
<h3>Neutral structural side rule</h3>
|
||||
<p>A gray structural rule can be a legitimate divider when it is not chromatic emphasis.</p>
|
||||
</div>
|
||||
|
||||
<div class="case pass-thin-side">
|
||||
<h3>Thin colored side rule</h3>
|
||||
<p>A 1px colored rule is below the decorative side-tab threshold.</p>
|
||||
</div>
|
||||
|
||||
<div class="case pass-square-top">
|
||||
<h3>Square top border</h3>
|
||||
<p>A top rule without rounded card corners is treated as a structural divider.</p>
|
||||
</div>
|
||||
|
||||
<div class="case pass-square-bottom">
|
||||
<h3>Square bottom border</h3>
|
||||
<p>A bottom rule without rounded card corners is also allowed.</p>
|
||||
</div>
|
||||
|
||||
<div class="case pass-dark-uniform">
|
||||
<h3>Dark card with uniform border</h3>
|
||||
<p>The dark surface has a full neutral outline, not a chromatic side stripe.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="/js/detect-antipatterns-browser.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+6
-4
@@ -28,13 +28,10 @@
|
||||
<div class="col" data-col="flag">
|
||||
<h2>Should flag</h2>
|
||||
|
||||
<h3>Pure black & white</h3>
|
||||
<h3>Pure black surfaces</h3>
|
||||
<div class="card" style="background: #000000; color: white;">
|
||||
<p>Pure #000 background</p>
|
||||
</div>
|
||||
<div class="card" style="background: #ffffff;">
|
||||
<p style="color: #000000;">Pure #000 text on pure #fff background</p>
|
||||
</div>
|
||||
|
||||
<h3>Gray on color</h3>
|
||||
<div class="card" style="background: rgb(59, 130, 246);">
|
||||
@@ -103,6 +100,11 @@
|
||||
<p style="color: rgb(20, 20, 25);">Near-white bg, near-black text — good contrast, tinted</p>
|
||||
</div>
|
||||
|
||||
<h3>Pure white surface is acceptable</h3>
|
||||
<div class="card" style="background: #ffffff;">
|
||||
<p style="color: #111827;">Pure #fff background with dark text — acceptable contrast</p>
|
||||
</div>
|
||||
|
||||
<h3>Good contrast</h3>
|
||||
<div class="card" style="background: rgb(30, 64, 175);">
|
||||
<p style="color: rgb(240, 240, 245);">Near-white text on dark blue — high contrast, not pure white</p>
|
||||
|
||||
+18
@@ -31,3 +31,21 @@
|
||||
border: 1px solid #e5e7eb;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Neutral side rule — should NOT flag */
|
||||
.external-neutral-side {
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
border-left: 4px solid #d1d5db;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Top rule without rounded card corners — should NOT flag */
|
||||
.external-square-top {
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0;
|
||||
border-top: 4px solid #8b5cf6;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
+4
-4
@@ -38,8 +38,8 @@
|
||||
<div class="demo">
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem;">Email</label>
|
||||
<input type="email" value="not-an-email" style="width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #fca5a5; border-left: 3px solid #ef4444; border-radius: 6px; outline: none; font-size: 0.875rem;">
|
||||
<p style="color: #ef4444; font-size: 0.75rem; margin-top: 0.25rem;">Please enter a valid email address.</p>
|
||||
<input type="email" value="not-an-email" style="width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #fca5a5; border-left: 3px solid #b91c1c; border-radius: 6px; outline: none; font-size: 0.875rem;">
|
||||
<p style="color: #b91c1c; font-size: 0.75rem; margin-top: 0.25rem;">Please enter a valid email address.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
</div>
|
||||
<div style="position: relative;">
|
||||
<div style="position: absolute; left: -2.05rem; top: 0.125rem; width: 10px; height: 10px; border-radius: 50%; background: #d1d5db; border: 2px solid white;"></div>
|
||||
<p style="font-weight: 500; margin: 0; color: #9ca3af;">Delivered</p>
|
||||
<p style="font-weight: 500; margin: 0; color: #4b5563;">Delivered</p>
|
||||
<p style="font-size: 0.75rem; color: #6b7280; margin: 0.125rem 0 0;">Expected March 18</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,7 +104,7 @@
|
||||
<!-- 8. ALERT BANNER — full-width, not a card -->
|
||||
<h2>Alert Banner</h2>
|
||||
<div class="demo">
|
||||
<div style="border-left: 4px solid #f59e0b; background: #fffbeb; padding: 0.75rem 1rem; font-size: 0.875rem; color: #92400e;">
|
||||
<div style="border: 1px solid #f59e0b; background: #fffbeb; padding: 0.75rem 1rem; font-size: 0.875rem; color: #92400e;">
|
||||
<strong>Warning:</strong> Your trial expires in 3 days. <a href="#" style="color: #d97706;">Upgrade now</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+85
-25
@@ -6,11 +6,58 @@
|
||||
<title>Anti-Patterns From Linked Stylesheet</title>
|
||||
<link rel="stylesheet" href="external-styles.css">
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; background: #f9fafb; padding: 2rem; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
||||
h2 { font-size: 1.125rem; margin: 2rem 0 0.75rem; color: #6b7280; }
|
||||
p.intro { color: #6b7280; margin-bottom: 2rem; max-width: 36rem; }
|
||||
.cards { display: grid; gap: 1rem; max-width: 28rem; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
background: #f9fafb;
|
||||
color: #111827;
|
||||
font: 14px/1.5 system-ui, sans-serif;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto 8px;
|
||||
font-size: 34px;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
p.intro {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto 24px;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 32px;
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.col h2 {
|
||||
margin: 0 0 14px;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 15px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.cards p {
|
||||
margin: 0;
|
||||
color: #4b5563;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -20,28 +67,41 @@
|
||||
Regex-only scanning misses these — only computed style analysis catches them.
|
||||
</p>
|
||||
|
||||
<h2>Side-Tab (from external CSS)</h2>
|
||||
<div class="cards">
|
||||
<div class="external-side-tab">
|
||||
<h3 style="font-weight: 600;">External side-tab class</h3>
|
||||
<p style="font-size: 0.875rem; color: #6b7280;">border-left + border-radius from linked stylesheet.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<section class="col" data-col="flag">
|
||||
<h2>Should flag</h2>
|
||||
<div class="cards">
|
||||
<div class="external-side-tab">
|
||||
<h3>External side-tab class</h3>
|
||||
<p>border-left plus border-radius from a linked stylesheet.</p>
|
||||
</div>
|
||||
|
||||
<h2>Top Accent + Rounded (from external CSS)</h2>
|
||||
<div class="cards">
|
||||
<div class="external-top-accent">
|
||||
<h3 style="font-weight: 600;">External top accent class</h3>
|
||||
<p style="font-size: 0.875rem; color: #6b7280;">border-top + border-radius from linked stylesheet.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="external-top-accent">
|
||||
<h3>External top accent class</h3>
|
||||
<p>border-top plus border-radius from a linked stylesheet.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h2>Clean Card (from external CSS)</h2>
|
||||
<div class="cards">
|
||||
<div class="external-clean">
|
||||
<h3 style="font-weight: 600;">External clean card</h3>
|
||||
<p style="font-size: 0.875rem; color: #6b7280;">Uniform 1px border — should NOT flag.</p>
|
||||
</div>
|
||||
<section class="col" data-col="pass">
|
||||
<h2>Should pass</h2>
|
||||
<div class="cards">
|
||||
<div class="external-clean">
|
||||
<h3>External clean card</h3>
|
||||
<p>Uniform 1px border from the linked stylesheet.</p>
|
||||
</div>
|
||||
|
||||
<div class="external-neutral-side">
|
||||
<h3>External neutral side rule</h3>
|
||||
<p>Thick side border, but the color is structural gray.</p>
|
||||
</div>
|
||||
|
||||
<div class="external-square-top">
|
||||
<h3>External square top rule</h3>
|
||||
<p>Heavy top border with no rounded card corners.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<script src="/js/detect-antipatterns-browser.js"></script>
|
||||
</body>
|
||||
|
||||
+2
-25
@@ -7,8 +7,8 @@
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; background: #f9fafb; padding: 2rem; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
||||
h2 { font-size: 1.125rem; margin: 2.5rem 0 0.75rem; color: #6b7280; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.5rem; }
|
||||
h1 { font-size: 2rem; margin-bottom: 0.5rem; }
|
||||
h2 { font-size: 0.875rem; margin: 2.5rem 0 0.75rem; color: #4b5563; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.5rem; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
p.intro { color: #6b7280; margin-bottom: 2rem; max-width: 36rem; }
|
||||
.cards { display: grid; gap: 1rem; max-width: 28rem; }
|
||||
.card { background: white; padding: 1rem; border-radius: 0.375rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||||
@@ -20,9 +20,6 @@
|
||||
.card-shorthand-right { border-right: 5px solid #8b5cf6; }
|
||||
.card-longhand-left { border-left-width: 3px; border-left-style: solid; border-left-color: #10b981; }
|
||||
.card-longhand-right { border-right-width: 6px; border-right-style: solid; border-right-color: #ef4444; }
|
||||
.card-logical-start { border-inline-start: 4px solid #f59e0b; }
|
||||
.card-logical-end { border-inline-end: 3px solid #ec4899; }
|
||||
.card-logical-start-width { border-inline-start-width: 5px; border-inline-start-style: solid; border-inline-start-color: #06b6d4; }
|
||||
|
||||
/* CSS top/bottom + border-radius */
|
||||
.card-css-top { border-radius: 12px; border-top: 4px solid #3b82f6; }
|
||||
@@ -40,18 +37,10 @@
|
||||
<h3>border-l-4 + rounded-r</h3>
|
||||
<p>The classic AI tell.</p>
|
||||
</div>
|
||||
<div class="border-l-2 border-emerald-500 bg-white p-4 rounded-r shadow-sm">
|
||||
<h3>border-l-2 + rounded-r</h3>
|
||||
<p>Thin but still recognizable with rounded corners.</p>
|
||||
</div>
|
||||
<div class="border-r-4 border-purple-500 bg-white p-4 rounded-l shadow-sm">
|
||||
<h3>border-r-4 + rounded-l</h3>
|
||||
<p>Right side variant.</p>
|
||||
</div>
|
||||
<div class="border-s-3 border-amber-500 bg-white p-4 rounded-r shadow-sm">
|
||||
<h3>border-s-3 + rounded-r</h3>
|
||||
<p>Logical inline-start.</p>
|
||||
</div>
|
||||
<div class="border-e-8 border-red-500 bg-white p-4 rounded-l shadow-sm">
|
||||
<h3>border-e-8 + rounded-l</h3>
|
||||
<p>Logical inline-end, extra thick.</p>
|
||||
@@ -77,18 +66,6 @@
|
||||
<h3>border-right-width: 6px</h3>
|
||||
<p>CSS longhand right.</p>
|
||||
</div>
|
||||
<div class="card card-logical-start">
|
||||
<h3>border-inline-start: 4px solid</h3>
|
||||
<p>CSS logical start.</p>
|
||||
</div>
|
||||
<div class="card card-logical-end">
|
||||
<h3>border-inline-end: 3px solid</h3>
|
||||
<p>CSS logical end.</p>
|
||||
</div>
|
||||
<div class="card card-logical-start-width">
|
||||
<h3>border-inline-start-width: 5px</h3>
|
||||
<p>CSS logical longhand.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOP/BOTTOM + ROUNDED -->
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
h2 { font-size: 16px; font-weight: 600; margin: 2rem 0 0.75rem; color: #6b7280; }
|
||||
h3 { font-size: 15px; font-weight: 600; }
|
||||
p { font-size: 14px; color: #6b7280; margin-top: 0.25rem; }
|
||||
.caption { font-size: 13px; color: #9ca3af; }
|
||||
.caption { font-size: 13px; color: #6b7280; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Typography Anti-Patterns — Side by Side</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Newsreader:opsz,wght@6..72,400;6..72,700&family=Karla:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
background: #f9fafb;
|
||||
color: #111827;
|
||||
font: 16px/1.5 system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 32px;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.col {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.col-label {
|
||||
margin: 0 0 14px;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.flag-type {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: #fdfdfd;
|
||||
padding: 22px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.flag-type h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 18px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.flag-type h2 {
|
||||
margin: 24px 0 8px;
|
||||
color: #4b5563;
|
||||
font-size: 16px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.flag-type h3 {
|
||||
margin: 18px 0 4px;
|
||||
font-size: 15px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.flag-type p {
|
||||
margin: 0 0 10px;
|
||||
color: #4b5563;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.flag-type .caption {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.flag-type .tight-leading-demo {
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.flag-type .tiny-body-demo {
|
||||
color: #374151;
|
||||
font-size: 10px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.flag-type .caps-body-demo {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.flag-type .tracked-body-demo {
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.flag-type .justified-body-demo {
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.pass-type {
|
||||
background: #fdfdfd;
|
||||
padding: 22px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.pass-type h1,
|
||||
.pass-type h2 {
|
||||
font-family: 'Newsreader', Georgia, serif;
|
||||
}
|
||||
|
||||
.pass-type h1 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 48px;
|
||||
line-height: 0.98;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pass-type h2 {
|
||||
margin: 32px 0 10px;
|
||||
font-size: 30px;
|
||||
line-height: 1.08;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pass-type h3,
|
||||
.pass-type p,
|
||||
.pass-type .caption {
|
||||
font-family: 'Karla', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.pass-type h3 {
|
||||
margin: 22px 0 6px;
|
||||
font-size: 22px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.pass-type p {
|
||||
margin: 0 0 12px;
|
||||
color: #374151;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.pass-type .caption {
|
||||
color: #4b5563;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="grid">
|
||||
<section class="col" data-col="flag">
|
||||
<h2 class="col-label">Should flag</h2>
|
||||
<article class="flag-type">
|
||||
<h1>Typography Anti-Patterns</h1>
|
||||
<p>This side uses element-level typography failures that can be highlighted directly in the detector lab.</p>
|
||||
|
||||
<h2>Tight Leading</h2>
|
||||
<p class="tight-leading-demo">This paragraph has a line-height that is too tight for body copy. The lines press into each other and make scanning harder, especially once the text wraps across multiple lines in a production layout.</p>
|
||||
|
||||
<h2>Tiny Body Text</h2>
|
||||
<p class="tiny-body-demo">This body text is set at 10px, which is too small for real reading content and should be reserved for neither primary copy nor explanatory product text.</p>
|
||||
|
||||
<h2>All-Caps Body</h2>
|
||||
<p class="caps-body-demo">This entire paragraph is transformed to uppercase, which removes useful word shapes and makes longer body copy feel like a shout instead of a readable passage.</p>
|
||||
|
||||
<h2>Wide Tracking</h2>
|
||||
<p class="tracked-body-demo">This paragraph spreads every letter apart, a treatment that belongs on short labels at most, not on body copy that people need to read comfortably.</p>
|
||||
|
||||
<h2>Justified Text</h2>
|
||||
<p class="justified-body-demo">This paragraph uses justified alignment without automatic hyphenation. In narrow columns it creates uneven word spacing and distracting rivers through the text block.</p>
|
||||
|
||||
<h2>Overused Font</h2>
|
||||
<p>Inter is also the dominant face on this side of the page, so the browser page-level detector has enough real text elements to show the global overused-font banner.</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="col" data-col="pass">
|
||||
<h2 class="col-label">Should pass</h2>
|
||||
<article class="pass-type">
|
||||
<h1>Good Typography</h1>
|
||||
<p>This side keeps readable body defaults, a real font pairing, clear scale, and visible hierarchy.</p>
|
||||
|
||||
<h2>Two Font Families</h2>
|
||||
<p>Newsreader carries display text, while Karla handles reading surfaces and supporting labels.</p>
|
||||
|
||||
<h3>Strong Size Hierarchy</h3>
|
||||
<p>Sizes range from 12px to 48px with distinct roles for heading, subheading, body, and caption.</p>
|
||||
<p class="caption">Caption text is intentionally smaller but still readable.</p>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="/js/detect-antipatterns-browser.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Visual Contrast Fixture</title>
|
||||
<style>
|
||||
:root {
|
||||
--paper: #f7f3ee;
|
||||
--ink: #171717;
|
||||
--muted: #566174;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 32px;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 24px;
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.column > h2 {
|
||||
margin: 0 0 2px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1.4;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.image-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
align-content: end;
|
||||
min-height: 180px;
|
||||
padding: 28px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.image-card p {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 28ch;
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.light-image {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 420 240'%3E%3Crect width='420' height='240' fill='%23ece8df'/%3E%3Ccircle cx='308' cy='58' r='132' fill='%23faf7f0'/%3E%3Cpath d='M0 186c52-22 92-26 142-8s98 18 148-8 92-22 130 0v70H0z' fill='%23f5efe4'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.pale-pattern {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 420 240'%3E%3Crect width='420' height='240' fill='%23edf2f7'/%3E%3Cg fill='%23f8fafc'%3E%3Ccircle cx='60' cy='64' r='42'/%3E%3Ccircle cx='182' cy='142' r='58'/%3E%3Ccircle cx='336' cy='84' r='74'/%3E%3C/g%3E%3Cg stroke='%23dbe4ef' stroke-width='14' opacity='.55'%3E%3Cpath d='M-20 220C74 112 150 94 232 164s138 46 214-62' fill='none'/%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.mist-image {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 420 240'%3E%3Crect width='420' height='240' fill='%23b9c4cf'/%3E%3Cpath d='M0 52c74-28 138-22 194 18s124 38 226-28v198H0z' fill='%23cbd3dc'/%3E%3Ccircle cx='82' cy='176' r='68' fill='%23aeb8c4'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.dark-image {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 420 240'%3E%3Crect width='420' height='240' fill='%23191b20'/%3E%3Ccircle cx='316' cy='50' r='132' fill='%232d3440'/%3E%3Cpath d='M0 182c70-48 138-42 204-6s128 24 216-58v122H0z' fill='%23101318'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.underlay {
|
||||
isolation: isolate;
|
||||
background: #1a1d24;
|
||||
}
|
||||
|
||||
.underlay.light-shell {
|
||||
background: #f5f2ea;
|
||||
}
|
||||
|
||||
.underlay img,
|
||||
.underlay svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.underlay svg {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="grid">
|
||||
<section class="column" data-col="flag">
|
||||
<h2>Should flag after pixel sampling</h2>
|
||||
|
||||
<article class="image-card light-image">
|
||||
<p style="color: rgb(255, 255, 255);">White text on light image should be sampled by pixel contrast.</p>
|
||||
</article>
|
||||
|
||||
<article class="image-card dark-image">
|
||||
<p style="color: rgb(28, 30, 35);">Dark text on dark image should be sampled by pixel contrast.</p>
|
||||
</article>
|
||||
|
||||
<article class="image-card pale-pattern">
|
||||
<p style="color: rgba(255, 255, 255, 0.72);">Translucent white text on a pale pattern should use rendered pixels.</p>
|
||||
</article>
|
||||
|
||||
<article class="image-card mist-image">
|
||||
<p style="color: rgb(138, 146, 156);">Muted gray text on a misty image should be sampled by pixel contrast.</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="column" data-col="pass">
|
||||
<h2>Should pass after pixel sampling</h2>
|
||||
|
||||
<article class="image-card dark-image">
|
||||
<p style="color: rgb(250, 247, 239);">White text on dark image should pass the pixel contrast fallback.</p>
|
||||
</article>
|
||||
|
||||
<article class="image-card light-image">
|
||||
<p style="color: rgb(31, 35, 42);">Dark text on light image should pass the pixel contrast fallback.</p>
|
||||
</article>
|
||||
|
||||
<article class="image-card underlay">
|
||||
<img alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 420 240'%3E%3Crect width='420' height='240' fill='%23141920'/%3E%3Ccircle cx='318' cy='70' r='126' fill='%23293442'/%3E%3C/svg%3E">
|
||||
<p style="color: rgb(246, 242, 232);">White text over a dark image underlay should pass.</p>
|
||||
</article>
|
||||
|
||||
<article class="image-card underlay light-shell">
|
||||
<svg viewBox="0 0 420 240" aria-hidden="true">
|
||||
<rect width="420" height="240" fill="#f3eee4"></rect>
|
||||
<circle cx="86" cy="70" r="68" fill="#fffaf2"></circle>
|
||||
<path d="M0 176c72-24 132-22 190 6s128 30 230-34v92H0z" fill="#e5dccd"></path>
|
||||
</svg>
|
||||
<p style="color: rgb(35, 31, 27);">Dark text over a light SVG underlay should pass.</p>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -51,10 +51,10 @@ describe('Windows path doubling fix (#95)', () => {
|
||||
expect(thisFile).toContain('windows-path-fix.test');
|
||||
});
|
||||
|
||||
test('source file no longer uses raw .pathname for path construction', () => {
|
||||
test('URL detector source no longer uses raw .pathname for path construction', () => {
|
||||
const fs = require('fs');
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'cli', 'engine', 'detect-antipatterns.mjs'),
|
||||
path.join(__dirname, '..', 'cli', 'engine', 'engines', 'browser', 'detect-url.mjs'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user