mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
* 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
584 lines
17 KiB
JavaScript
584 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import fs from 'node:fs';
|
|
import http from 'node:http';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import {
|
|
createBrowserDetector,
|
|
createDetectorProfile,
|
|
detectHtml,
|
|
detectText,
|
|
detectUrl,
|
|
summarizeDetectorProfile,
|
|
walkDir,
|
|
} from '../cli/engine/detect-antipatterns.mjs';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
const FIXTURES = path.join(ROOT, 'tests', 'fixtures', 'antipatterns');
|
|
const BROWSER_FIXTURES = [
|
|
'cramped-padding.html',
|
|
'quality.html',
|
|
'body-text-viewport-edge.html',
|
|
];
|
|
|
|
const MIME = {
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.js': 'text/javascript; charset=utf-8',
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.svg': 'image/svg+xml',
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
};
|
|
|
|
function parseArgs(argv) {
|
|
const args = {
|
|
browser: false,
|
|
json: false,
|
|
out: null,
|
|
quick: false,
|
|
};
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const arg = argv[i];
|
|
if (arg === '--browser') args.browser = true;
|
|
else if (arg === '--json') args.json = true;
|
|
else if (arg === '--quick') args.quick = true;
|
|
else if (arg === '--out') args.out = argv[++i] || null;
|
|
else if (arg.startsWith('--out=')) args.out = arg.slice('--out='.length);
|
|
else if (arg === '--help') {
|
|
printUsage();
|
|
process.exit(0);
|
|
}
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function printUsage() {
|
|
console.log(`Usage: node scripts/benchmark-detector.mjs [options]
|
|
|
|
Options:
|
|
--quick Run a small smoke benchmark subset
|
|
--browser Include browser-backed URL benchmarks
|
|
--json Print the benchmark report as JSON
|
|
--out FILE Write the benchmark report JSON to FILE
|
|
--help Show this help message`);
|
|
}
|
|
|
|
function nowMs() {
|
|
return typeof performance !== 'undefined' && performance.now
|
|
? performance.now()
|
|
: Date.now();
|
|
}
|
|
|
|
function roundMs(value) {
|
|
return Number(value.toFixed(3));
|
|
}
|
|
|
|
function isHtml(filePath) {
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
return ext === '.html' || ext === '.htm';
|
|
}
|
|
|
|
function rel(filePath) {
|
|
return path.relative(ROOT, filePath);
|
|
}
|
|
|
|
function addEvent(profile, event) {
|
|
profile.events.push({
|
|
engine: event.engine || 'unknown',
|
|
phase: event.phase || 'unknown',
|
|
ruleId: event.ruleId || 'unknown',
|
|
target: event.target || '',
|
|
ms: Number.isFinite(event.ms) ? event.ms : 0,
|
|
findings: Number.isFinite(event.findings) ? event.findings : 0,
|
|
});
|
|
}
|
|
|
|
async function measureCase({ name, engine, mode, target, run }) {
|
|
const profile = createDetectorProfile();
|
|
const started = nowMs();
|
|
try {
|
|
const result = await run(profile);
|
|
const findings = Array.isArray(result)
|
|
? result.length
|
|
: (Number.isFinite(result?.findings) ? result.findings : 0);
|
|
return {
|
|
name,
|
|
engine,
|
|
mode,
|
|
target,
|
|
status: 'ok',
|
|
totalMs: roundMs(nowMs() - started),
|
|
findings,
|
|
profile: summarizeDetectorProfile(profile),
|
|
events: profile.events,
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
name,
|
|
engine,
|
|
mode,
|
|
target,
|
|
status: 'failed',
|
|
totalMs: roundMs(nowMs() - started),
|
|
findings: 0,
|
|
error: err?.message || String(err),
|
|
profile: summarizeDetectorProfile(profile),
|
|
events: profile.events,
|
|
};
|
|
}
|
|
}
|
|
|
|
function skippedCase({ name, engine, mode, target, reason }) {
|
|
return {
|
|
name,
|
|
engine,
|
|
mode,
|
|
target,
|
|
status: 'skipped',
|
|
totalMs: 0,
|
|
findings: 0,
|
|
skipReason: reason,
|
|
profile: [],
|
|
events: [],
|
|
};
|
|
}
|
|
|
|
async function scanDirectory(files, fastMode, profile) {
|
|
const findings = [];
|
|
for (const file of files) {
|
|
if (!fastMode && isHtml(file)) {
|
|
findings.push(...await detectHtml(file, { profile }));
|
|
} else {
|
|
const content = fs.readFileSync(file, 'utf-8');
|
|
findings.push(...detectText(content, file, { profile }));
|
|
}
|
|
}
|
|
return findings;
|
|
}
|
|
|
|
function selectQuickFiles(files, predicate, preferredNames) {
|
|
const preferred = preferredNames
|
|
.map(name => files.find(file => path.basename(file) === name))
|
|
.filter(Boolean);
|
|
const fallback = files.filter(predicate).slice(0, preferredNames.length || 2);
|
|
return preferred.length ? preferred : fallback;
|
|
}
|
|
|
|
async function runFileBenchmarks(args) {
|
|
const files = walkDir(FIXTURES).sort();
|
|
const htmlFiles = files.filter(isHtml);
|
|
const textFiles = files.filter(file => !isHtml(file));
|
|
const selectedText = args.quick
|
|
? textFiles.slice(0, 2)
|
|
: textFiles;
|
|
const selectedHtml = args.quick
|
|
? selectQuickFiles(htmlFiles, isHtml, ['color.html', 'quality.html'])
|
|
: htmlFiles;
|
|
const directoryFiles = args.quick
|
|
? [...selectedHtml, ...selectedText].sort()
|
|
: files;
|
|
|
|
const cases = [];
|
|
for (const file of selectedText) {
|
|
cases.push(await measureCase({
|
|
name: `detectText:${rel(file)}`,
|
|
engine: 'regex',
|
|
mode: 'file',
|
|
target: rel(file),
|
|
run: (profile) => detectText(fs.readFileSync(file, 'utf-8'), file, { profile }),
|
|
}));
|
|
}
|
|
|
|
for (const file of selectedHtml) {
|
|
cases.push(await measureCase({
|
|
name: `detectHtml:${rel(file)}`,
|
|
engine: 'static-html',
|
|
mode: 'file',
|
|
target: rel(file),
|
|
run: (profile) => detectHtml(file, { profile }),
|
|
}));
|
|
}
|
|
|
|
cases.push(await measureCase({
|
|
name: args.quick ? 'directory-default:quick-fixtures' : 'directory-default:all-fixtures',
|
|
engine: 'mixed',
|
|
mode: 'directory-default',
|
|
target: rel(FIXTURES),
|
|
run: (profile) => scanDirectory(directoryFiles, false, profile),
|
|
}));
|
|
|
|
cases.push(await measureCase({
|
|
name: args.quick ? 'directory-fast:quick-fixtures' : 'directory-fast:all-fixtures',
|
|
engine: 'regex',
|
|
mode: 'directory-fast',
|
|
target: rel(FIXTURES),
|
|
run: (profile) => scanDirectory(directoryFiles, true, profile),
|
|
}));
|
|
|
|
return cases;
|
|
}
|
|
|
|
function startFixtureServer() {
|
|
const server = http.createServer((req, res) => {
|
|
let filePath;
|
|
const urlPath = req.url?.split('?')[0] || '/';
|
|
if (urlPath.startsWith('/fixtures/')) {
|
|
filePath = path.join(ROOT, 'tests', urlPath);
|
|
} else if (urlPath === '/js/detect-antipatterns-browser.js') {
|
|
filePath = path.join(ROOT, 'cli', 'engine', 'detect-antipatterns-browser.js');
|
|
} else {
|
|
res.writeHead(404).end();
|
|
return;
|
|
}
|
|
try {
|
|
const body = fs.readFileSync(filePath);
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
|
|
res.end(body);
|
|
} catch {
|
|
res.writeHead(404).end();
|
|
}
|
|
});
|
|
|
|
return new Promise((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(0, '127.0.0.1', () => {
|
|
server.off('error', reject);
|
|
const address = server.address();
|
|
resolve({
|
|
server,
|
|
baseUrl: `http://127.0.0.1:${address.port}`,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
async function closeServer(server) {
|
|
await new Promise(resolve => server.close(resolve));
|
|
}
|
|
|
|
async function runBrowserBenchmarks(args) {
|
|
let serverInfo;
|
|
try {
|
|
serverInfo = await startFixtureServer();
|
|
} catch (err) {
|
|
return [
|
|
skippedCase({
|
|
name: 'browser:fixtures',
|
|
engine: 'browser',
|
|
mode: 'browser',
|
|
target: 'localhost',
|
|
reason: `localhost fixture server unavailable: ${err?.message || err}`,
|
|
}),
|
|
];
|
|
}
|
|
|
|
const cases = [];
|
|
const browserFiles = args.quick ? ['quality.html'] : BROWSER_FIXTURES;
|
|
|
|
try {
|
|
for (const fileName of browserFiles) {
|
|
const url = `${serverInfo.baseUrl}/fixtures/antipatterns/${fileName}`;
|
|
const fresh = await measureCase({
|
|
name: `detectUrl:fresh-load:${fileName}`,
|
|
engine: 'browser',
|
|
mode: 'fresh-load',
|
|
target: url,
|
|
run: (profile) => detectUrl(url, { profile, waitUntil: 'load', settleMs: 100 }),
|
|
});
|
|
if (fresh.status === 'failed' && /Could not find Chrome|Failed to launch|executable|spawn|puppeteer/i.test(fresh.error || '')) {
|
|
cases.push(skippedCase({
|
|
name: `detectUrl:fresh-load:${fileName}`,
|
|
engine: 'browser',
|
|
mode: 'fresh-load',
|
|
target: url,
|
|
reason: `Chromium unavailable: ${fresh.error}`,
|
|
}));
|
|
} else {
|
|
cases.push(fresh);
|
|
}
|
|
}
|
|
|
|
const visualContrastUrl = `${serverInfo.baseUrl}/fixtures/antipatterns/visual-contrast.html`;
|
|
cases.push(await measureCase({
|
|
name: 'detectUrl:visual-contrast',
|
|
engine: 'browser',
|
|
mode: 'visual-contrast',
|
|
target: visualContrastUrl,
|
|
run: (profile) => detectUrl(visualContrastUrl, {
|
|
profile,
|
|
waitUntil: 'load',
|
|
settleMs: 0,
|
|
visualContrast: true,
|
|
}),
|
|
}));
|
|
|
|
cases.push(await measureCase({
|
|
name: 'detectUrl:warm-load',
|
|
engine: 'browser',
|
|
mode: 'warm-load',
|
|
target: serverInfo.baseUrl,
|
|
run: async (profile) => {
|
|
const detector = await createBrowserDetector({ waitUntil: 'load', settleMs: 100 });
|
|
const findings = [];
|
|
try {
|
|
for (const fileName of browserFiles) {
|
|
const url = `${serverInfo.baseUrl}/fixtures/antipatterns/${fileName}`;
|
|
findings.push(...await detector.detectUrl(url, { profile }));
|
|
}
|
|
} finally {
|
|
await detector.close();
|
|
}
|
|
return findings;
|
|
},
|
|
}));
|
|
|
|
cases.push(await measureCase({
|
|
name: 'detectUrl:warm-networkidle0',
|
|
engine: 'browser',
|
|
mode: 'warm-networkidle0',
|
|
target: serverInfo.baseUrl,
|
|
run: async (profile) => {
|
|
const detector = await createBrowserDetector({ waitUntil: 'load', settleMs: 100 });
|
|
const findings = [];
|
|
try {
|
|
for (const fileName of browserFiles) {
|
|
const url = `${serverInfo.baseUrl}/fixtures/antipatterns/${fileName}`;
|
|
findings.push(...await detector.detectUrl(url, {
|
|
profile,
|
|
waitUntil: 'networkidle0',
|
|
settleMs: 0,
|
|
}));
|
|
}
|
|
} finally {
|
|
await detector.close();
|
|
}
|
|
return findings;
|
|
},
|
|
}));
|
|
|
|
let puppeteer;
|
|
try {
|
|
puppeteer = await import('puppeteer');
|
|
} catch (err) {
|
|
cases.push(skippedCase({
|
|
name: 'browser:pure-vs-overlay',
|
|
engine: 'browser',
|
|
mode: 'pure-vs-overlay',
|
|
target: serverInfo.baseUrl,
|
|
reason: `puppeteer unavailable: ${err?.message || err}`,
|
|
}));
|
|
return cases;
|
|
}
|
|
|
|
cases.push(await measureCase({
|
|
name: 'browser:pure-vs-overlay',
|
|
engine: 'browser',
|
|
mode: 'pure-vs-overlay',
|
|
target: serverInfo.baseUrl,
|
|
run: async (profile) => {
|
|
let browser;
|
|
const launchStarted = nowMs();
|
|
try {
|
|
browser = await puppeteer.default.launch({
|
|
headless: true,
|
|
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
|
|
});
|
|
addEvent(profile, {
|
|
engine: 'browser',
|
|
phase: 'load',
|
|
ruleId: 'launch-browser-overlay-bench',
|
|
target: serverInfo.baseUrl,
|
|
ms: nowMs() - launchStarted,
|
|
});
|
|
} catch (err) {
|
|
throw new Error(`Chromium unavailable: ${err?.message || err}`);
|
|
}
|
|
|
|
let findings = [];
|
|
try {
|
|
const page = await browser.newPage();
|
|
const url = `${serverInfo.baseUrl}/fixtures/antipatterns/${browserFiles[0]}`;
|
|
const browserScript = fs.readFileSync(path.join(ROOT, 'cli', 'engine', 'detect-antipatterns-browser.js'), 'utf-8');
|
|
await page.setViewport({ width: 1280, height: 800 });
|
|
await page.goto(url, { waitUntil: 'load', timeout: 30000 });
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
|
|
await page.evaluate(browserScript);
|
|
const pureStarted = nowMs();
|
|
findings = await page.evaluate(() => {
|
|
const serialized = window.impeccableDetect({ decorate: false, serialize: true });
|
|
return serialized.flatMap(({ findings }) => findings.map(f => ({ id: f.type, snippet: f.detail })));
|
|
});
|
|
addEvent(profile, {
|
|
engine: 'browser',
|
|
phase: 'scan',
|
|
ruleId: 'pure-detect',
|
|
target: url,
|
|
ms: nowMs() - pureStarted,
|
|
findings: findings.length,
|
|
});
|
|
const overlayStarted = nowMs();
|
|
const overlayGroupCount = await page.evaluate(() => window.impeccableScan().length);
|
|
addEvent(profile, {
|
|
engine: 'browser',
|
|
phase: 'scan',
|
|
ruleId: 'overlay-scan',
|
|
target: url,
|
|
ms: nowMs() - overlayStarted,
|
|
findings: overlayGroupCount,
|
|
});
|
|
await page.close().catch(() => {});
|
|
} finally {
|
|
const closeStarted = nowMs();
|
|
await browser.close().catch(() => {});
|
|
addEvent(profile, {
|
|
engine: 'browser',
|
|
phase: 'load',
|
|
ruleId: 'close-browser-overlay-bench',
|
|
target: serverInfo.baseUrl,
|
|
ms: nowMs() - closeStarted,
|
|
});
|
|
}
|
|
return findings;
|
|
},
|
|
}));
|
|
} finally {
|
|
await closeServer(serverInfo.server);
|
|
}
|
|
|
|
return cases.map(testCase => {
|
|
if (testCase.engine === 'browser' && testCase.status === 'failed' && /Chromium unavailable|Failed to launch|Could not find Chrome|executable|spawn|puppeteer/i.test(testCase.error || '')) {
|
|
return skippedCase({
|
|
name: testCase.name,
|
|
engine: testCase.engine,
|
|
mode: testCase.mode,
|
|
target: testCase.target,
|
|
reason: testCase.error,
|
|
});
|
|
}
|
|
return testCase;
|
|
});
|
|
}
|
|
|
|
function aggregateEvents(cases) {
|
|
const profile = createDetectorProfile();
|
|
for (const testCase of cases) {
|
|
if (Array.isArray(testCase.events)) profile.events.push(...testCase.events);
|
|
}
|
|
return summarizeDetectorProfile(profile);
|
|
}
|
|
|
|
function makeReport(args, cases) {
|
|
const summary = aggregateEvents(cases);
|
|
return {
|
|
version: 1,
|
|
createdAt: new Date().toISOString(),
|
|
cwd: ROOT,
|
|
quick: args.quick,
|
|
browser: args.browser,
|
|
cases: cases.map(({ events, ...testCase }) => testCase),
|
|
summary,
|
|
};
|
|
}
|
|
|
|
function pad(value, width) {
|
|
const str = String(value);
|
|
if (str.length >= width) return str.slice(0, width);
|
|
return str + ' '.repeat(width - str.length);
|
|
}
|
|
|
|
function printRows(rows, columns) {
|
|
const header = columns.map(col => pad(col.label, col.width)).join(' ');
|
|
console.log(header);
|
|
console.log(columns.map(col => '-'.repeat(col.width)).join(' '));
|
|
for (const row of rows) {
|
|
console.log(columns.map(col => pad(row[col.key] ?? '', col.width)).join(' '));
|
|
}
|
|
}
|
|
|
|
function printConsoleReport(report) {
|
|
console.log(`Detector benchmark ${report.quick ? '(quick)' : '(full)'}`);
|
|
console.log(`Cases: ${report.cases.length}`);
|
|
const caseRows = report.cases.map(testCase => ({
|
|
status: testCase.status,
|
|
engine: testCase.engine,
|
|
mode: testCase.mode,
|
|
totalMs: testCase.totalMs,
|
|
findings: testCase.findings,
|
|
target: testCase.target,
|
|
}));
|
|
printRows(caseRows, [
|
|
{ key: 'status', label: 'Status', width: 8 },
|
|
{ key: 'engine', label: 'Engine', width: 12 },
|
|
{ key: 'mode', label: 'Mode', width: 20 },
|
|
{ key: 'totalMs', label: 'Total ms', width: 10 },
|
|
{ key: 'findings', label: 'Findings', width: 8 },
|
|
{ key: 'target', label: 'Target', width: 60 },
|
|
]);
|
|
|
|
const skipped = report.cases.filter(testCase => testCase.status === 'skipped');
|
|
for (const testCase of skipped) {
|
|
console.log(`Skipped ${testCase.name}: ${testCase.skipReason}`);
|
|
}
|
|
|
|
console.log('\nSlowest profile groups');
|
|
const slowRows = report.summary.slice(0, 20).map(item => ({
|
|
engine: item.engine,
|
|
phase: item.phase,
|
|
ruleId: item.ruleId,
|
|
calls: item.calls,
|
|
totalMs: item.totalMs,
|
|
avgMs: item.avgMs,
|
|
p95: item.p95,
|
|
findings: item.findings,
|
|
target: item.target,
|
|
}));
|
|
printRows(slowRows, [
|
|
{ key: 'engine', label: 'Engine', width: 12 },
|
|
{ key: 'phase', label: 'Phase', width: 14 },
|
|
{ key: 'ruleId', label: 'Rule', width: 28 },
|
|
{ key: 'calls', label: 'Calls', width: 8 },
|
|
{ key: 'totalMs', label: 'Total ms', width: 10 },
|
|
{ key: 'avgMs', label: 'Avg ms', width: 8 },
|
|
{ key: 'p95', label: 'P95', width: 8 },
|
|
{ key: 'findings', label: 'Finds', width: 7 },
|
|
{ key: 'target', label: 'Target', width: 45 },
|
|
]);
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const cases = [
|
|
...await runFileBenchmarks(args),
|
|
];
|
|
if (args.browser) {
|
|
cases.push(...await runBrowserBenchmarks(args));
|
|
}
|
|
|
|
const report = makeReport(args, cases);
|
|
const json = JSON.stringify(report, null, 2);
|
|
if (args.out) {
|
|
fs.writeFileSync(path.resolve(args.out), json + '\n');
|
|
}
|
|
if (args.json) {
|
|
process.stdout.write(json + '\n');
|
|
} else {
|
|
printConsoleReport(report);
|
|
if (args.out) console.log(`\nWrote JSON report to ${path.resolve(args.out)}`);
|
|
}
|
|
|
|
if (report.cases.some(testCase => testCase.status === 'failed')) {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
main().catch(err => {
|
|
console.error(err?.stack || err?.message || err);
|
|
process.exit(1);
|
|
});
|