1#!/usr/bin/env node
2
3import fs from 'node:fs';
4import http from 'node:http';
5import path from 'node:path';
6import { fileURLToPath } from 'node:url';
7
8import {
9 createBrowserDetector,
10 createDetectorProfile,
11 detectHtml,
12 detectText,
13 detectUrl,
14 summarizeDetectorProfile,
15 walkDir,
16} from '../cli/engine/detect-antipatterns.mjs';
17
18const __dirname = path.dirname(fileURLToPath(import.meta.url));
19const ROOT = path.resolve(__dirname, '..');
20const FIXTURES = path.join(ROOT, 'tests', 'fixtures', 'antipatterns');
21const BROWSER_FIXTURES = [
22 'cramped-padding.html',
23 'quality.html',
24 'body-text-viewport-edge.html',
25];
26
27const MIME = {
28 '.html': 'text/html; charset=utf-8',
29 '.js': 'text/javascript; charset=utf-8',
30 '.css': 'text/css; charset=utf-8',
31 '.svg': 'image/svg+xml',
32 '.png': 'image/png',
33 '.jpg': 'image/jpeg',
34 '.jpeg': 'image/jpeg',
35};
36
37function parseArgs(argv) {
38 const args = {
39 browser: false,
40 json: false,
41 out: null,
42 quick: false,
43 };
44 for (let i = 0; i < argv.length; i++) {
45 const arg = argv[i];
46 if (arg === '--browser') args.browser = true;
47 else if (arg === '--json') args.json = true;
48 else if (arg === '--quick') args.quick = true;
49 else if (arg === '--out') args.out = argv[++i] || null;
50 else if (arg.startsWith('--out=')) args.out = arg.slice('--out='.length);
51 else if (arg === '--help') {
52 printUsage();
53 process.exit(0);
54 }
55 }
56 return args;
57}
58
59function printUsage() {
60 console.log(`Usage: node scripts/benchmark-detector.mjs [options]
61
62Options:
63 --quick Run a small smoke benchmark subset
64 --browser Include browser-backed URL benchmarks
65 --json Print the benchmark report as JSON
66 --out FILE Write the benchmark report JSON to FILE
67 --help Show this help message`);
68}
69
70function nowMs() {
71 return typeof performance !== 'undefined' && performance.now
72 ? performance.now()
73 : Date.now();
74}
75
76function roundMs(value) {
77 return Number(value.toFixed(3));
78}
79
80function isHtml(filePath) {
81 const ext = path.extname(filePath).toLowerCase();
82 return ext === '.html' || ext === '.htm';
83}
84
85function rel(filePath) {
86 return path.relative(ROOT, filePath);
87}
88
89function addEvent(profile, event) {
90 profile.events.push({
91 engine: event.engine || 'unknown',
92 phase: event.phase || 'unknown',
93 ruleId: event.ruleId || 'unknown',
94 target: event.target || '',
95 ms: Number.isFinite(event.ms) ? event.ms : 0,
96 findings: Number.isFinite(event.findings) ? event.findings : 0,
97 });
98}
99
100async function measureCase({ name, engine, mode, target, run }) {
101 const profile = createDetectorProfile();
102 const started = nowMs();
103 try {
104 const result = await run(profile);
105 const findings = Array.isArray(result)
106 ? result.length
107 : (Number.isFinite(result?.findings) ? result.findings : 0);
108 return {
109 name,
110 engine,
111 mode,
112 target,
113 status: 'ok',
114 totalMs: roundMs(nowMs() - started),
115 findings,
116 profile: summarizeDetectorProfile(profile),
117 events: profile.events,
118 };
119 } catch (err) {
120 return {
121 name,
122 engine,
123 mode,
124 target,
125 status: 'failed',
126 totalMs: roundMs(nowMs() - started),
127 findings: 0,
128 error: err?.message || String(err),
129 profile: summarizeDetectorProfile(profile),
130 events: profile.events,
131 };
132 }
133}
134
135function skippedCase({ name, engine, mode, target, reason }) {
136 return {
137 name,
138 engine,
139 mode,
140 target,
141 status: 'skipped',
142 totalMs: 0,
143 findings: 0,
144 skipReason: reason,
145 profile: [],
146 events: [],
147 };
148}
149
150async function scanDirectory(files, fastMode, profile) {
151 const findings = [];
152 for (const file of files) {
153 if (!fastMode && isHtml(file)) {
154 findings.push(...await detectHtml(file, { profile }));
155 } else {
156 const content = fs.readFileSync(file, 'utf-8');
157 findings.push(...detectText(content, file, { profile }));
158 }
159 }
160 return findings;
161}
162
163function selectQuickFiles(files, predicate, preferredNames) {
164 const preferred = preferredNames
165 .map(name => files.find(file => path.basename(file) === name))
166 .filter(Boolean);
167 const fallback = files.filter(predicate).slice(0, preferredNames.length || 2);
168 return preferred.length ? preferred : fallback;
169}
170
171async function runFileBenchmarks(args) {
172 const files = walkDir(FIXTURES).sort();
173 const htmlFiles = files.filter(isHtml);
174 const textFiles = files.filter(file => !isHtml(file));
175 const selectedText = args.quick
176 ? textFiles.slice(0, 2)
177 : textFiles;
178 const selectedHtml = args.quick
179 ? selectQuickFiles(htmlFiles, isHtml, ['color.html', 'quality.html'])
180 : htmlFiles;
181 const directoryFiles = args.quick
182 ? [...selectedHtml, ...selectedText].sort()
183 : files;
184
185 const cases = [];
186 for (const file of selectedText) {
187 cases.push(await measureCase({
188 name: `detectText:${rel(file)}`,
189 engine: 'regex',
190 mode: 'file',
191 target: rel(file),
192 run: (profile) => detectText(fs.readFileSync(file, 'utf-8'), file, { profile }),
193 }));
194 }
195
196 for (const file of selectedHtml) {
197 cases.push(await measureCase({
198 name: `detectHtml:${rel(file)}`,
199 engine: 'static-html',
200 mode: 'file',
201 target: rel(file),
202 run: (profile) => detectHtml(file, { profile }),
203 }));
204 }
205
206 cases.push(await measureCase({
207 name: args.quick ? 'directory-default:quick-fixtures' : 'directory-default:all-fixtures',
208 engine: 'mixed',
209 mode: 'directory-default',
210 target: rel(FIXTURES),
211 run: (profile) => scanDirectory(directoryFiles, false, profile),
212 }));
213
214 cases.push(await measureCase({
215 name: args.quick ? 'directory-fast:quick-fixtures' : 'directory-fast:all-fixtures',
216 engine: 'regex',
217 mode: 'directory-fast',
218 target: rel(FIXTURES),
219 run: (profile) => scanDirectory(directoryFiles, true, profile),
220 }));
221
222 return cases;
223}
224
225function startFixtureServer() {
226 const server = http.createServer((req, res) => {
227 let filePath;
228 const urlPath = req.url?.split('?')[0] || '/';
229 if (urlPath.startsWith('/fixtures/')) {
230 filePath = path.join(ROOT, 'tests', urlPath);
231 } else if (urlPath === '/js/detect-antipatterns-browser.js') {
232 filePath = path.join(ROOT, 'cli', 'engine', 'detect-antipatterns-browser.js');
233 } else {
234 res.writeHead(404).end();
235 return;
236 }
237 try {
238 const body = fs.readFileSync(filePath);
239 const ext = path.extname(filePath).toLowerCase();
240 res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
241 res.end(body);
242 } catch {
243 res.writeHead(404).end();
244 }
245 });
246
247 return new Promise((resolve, reject) => {
248 server.once('error', reject);
249 server.listen(0, '127.0.0.1', () => {
250 server.off('error', reject);
251 const address = server.address();
252 resolve({
253 server,
254 baseUrl: `http://127.0.0.1:${address.port}`,
255 });
256 });
257 });
258}
259
260async function closeServer(server) {
261 await new Promise(resolve => server.close(resolve));
262}
263
264async function runBrowserBenchmarks(args) {
265 let serverInfo;
266 try {
267 serverInfo = await startFixtureServer();
268 } catch (err) {
269 return [
270 skippedCase({
271 name: 'browser:fixtures',
272 engine: 'browser',
273 mode: 'browser',
274 target: 'localhost',
275 reason: `localhost fixture server unavailable: ${err?.message || err}`,
276 }),
277 ];
278 }
279
280 const cases = [];
281 const browserFiles = args.quick ? ['quality.html'] : BROWSER_FIXTURES;
282
283 try {
284 for (const fileName of browserFiles) {
285 const url = `${serverInfo.baseUrl}/fixtures/antipatterns/${fileName}`;
286 const fresh = await measureCase({
287 name: `detectUrl:fresh-load:${fileName}`,
288 engine: 'browser',
289 mode: 'fresh-load',
290 target: url,
291 run: (profile) => detectUrl(url, { profile, waitUntil: 'load', settleMs: 100 }),
292 });
293 if (fresh.status === 'failed' && /Could not find Chrome|Failed to launch|executable|spawn|puppeteer/i.test(fresh.error || '')) {
294 cases.push(skippedCase({
295 name: `detectUrl:fresh-load:${fileName}`,
296 engine: 'browser',
297 mode: 'fresh-load',
298 target: url,
299 reason: `Chromium unavailable: ${fresh.error}`,
300 }));
301 } else {
302 cases.push(fresh);
303 }
304 }
305
306 const visualContrastUrl = `${serverInfo.baseUrl}/fixtures/antipatterns/visual-contrast.html`;
307 cases.push(await measureCase({
308 name: 'detectUrl:visual-contrast',
309 engine: 'browser',
310 mode: 'visual-contrast',
311 target: visualContrastUrl,
312 run: (profile) => detectUrl(visualContrastUrl, {
313 profile,
314 waitUntil: 'load',
315 settleMs: 0,
316 visualContrast: true,
317 }),
318 }));
319
320 cases.push(await measureCase({
321 name: 'detectUrl:warm-load',
322 engine: 'browser',
323 mode: 'warm-load',
324 target: serverInfo.baseUrl,
325 run: async (profile) => {
326 const detector = await createBrowserDetector({ waitUntil: 'load', settleMs: 100 });
327 const findings = [];
328 try {
329 for (const fileName of browserFiles) {
330 const url = `${serverInfo.baseUrl}/fixtures/antipatterns/${fileName}`;
331 findings.push(...await detector.detectUrl(url, { profile }));
332 }
333 } finally {
334 await detector.close();
335 }
336 return findings;
337 },
338 }));
339
340 cases.push(await measureCase({
341 name: 'detectUrl:warm-networkidle0',
342 engine: 'browser',
343 mode: 'warm-networkidle0',
344 target: serverInfo.baseUrl,
345 run: async (profile) => {
346 const detector = await createBrowserDetector({ waitUntil: 'load', settleMs: 100 });
347 const findings = [];
348 try {
349 for (const fileName of browserFiles) {
350 const url = `${serverInfo.baseUrl}/fixtures/antipatterns/${fileName}`;
351 findings.push(...await detector.detectUrl(url, {
352 profile,
353 waitUntil: 'networkidle0',
354 settleMs: 0,
355 }));
356 }
357 } finally {
358 await detector.close();
359 }
360 return findings;
361 },
362 }));
363
364 let puppeteer;
365 try {
366 puppeteer = await import('puppeteer');
367 } catch (err) {
368 cases.push(skippedCase({
369 name: 'browser:pure-vs-overlay',
370 engine: 'browser',
371 mode: 'pure-vs-overlay',
372 target: serverInfo.baseUrl,
373 reason: `puppeteer unavailable: ${err?.message || err}`,
374 }));
375 return cases;
376 }
377
378 cases.push(await measureCase({
379 name: 'browser:pure-vs-overlay',
380 engine: 'browser',
381 mode: 'pure-vs-overlay',
382 target: serverInfo.baseUrl,
383 run: async (profile) => {
384 let browser;
385 const launchStarted = nowMs();
386 try {
387 browser = await puppeteer.default.launch({
388 headless: true,
389 args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
390 });
391 addEvent(profile, {
392 engine: 'browser',
393 phase: 'load',
394 ruleId: 'launch-browser-overlay-bench',
395 target: serverInfo.baseUrl,
396 ms: nowMs() - launchStarted,
397 });
398 } catch (err) {
399 throw new Error(`Chromium unavailable: ${err?.message || err}`);
400 }
401
402 let findings = [];
403 try {
404 const page = await browser.newPage();
405 const url = `${serverInfo.baseUrl}/fixtures/antipatterns/${browserFiles[0]}`;
406 const browserScript = fs.readFileSync(path.join(ROOT, 'cli', 'engine', 'detect-antipatterns-browser.js'), 'utf-8');
407 await page.setViewport({ width: 1280, height: 800 });
408 await page.goto(url, { waitUntil: 'load', timeout: 30000 });
409 await new Promise(resolve => setTimeout(resolve, 100));
410 await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
411 await page.evaluate(browserScript);
412 const pureStarted = nowMs();
413 findings = await page.evaluate(() => {
414 const serialized = window.impeccableDetect({ decorate: false, serialize: true });
415 return serialized.flatMap(({ findings }) => findings.map(f => ({ id: f.type, snippet: f.detail })));
416 });
417 addEvent(profile, {
418 engine: 'browser',
419 phase: 'scan',
420 ruleId: 'pure-detect',
421 target: url,
422 ms: nowMs() - pureStarted,
423 findings: findings.length,
424 });
425 const overlayStarted = nowMs();
426 const overlayGroupCount = await page.evaluate(() => window.impeccableScan().length);
427 addEvent(profile, {
428 engine: 'browser',
429 phase: 'scan',
430 ruleId: 'overlay-scan',
431 target: url,
432 ms: nowMs() - overlayStarted,
433 findings: overlayGroupCount,
434 });
435 await page.close().catch(() => {});
436 } finally {
437 const closeStarted = nowMs();
438 await browser.close().catch(() => {});
439 addEvent(profile, {
440 engine: 'browser',
441 phase: 'load',
442 ruleId: 'close-browser-overlay-bench',
443 target: serverInfo.baseUrl,
444 ms: nowMs() - closeStarted,
445 });
446 }
447 return findings;
448 },
449 }));
450 } finally {
451 await closeServer(serverInfo.server);
452 }
453
454 return cases.map(testCase => {
455 if (testCase.engine === 'browser' && testCase.status === 'failed' && /Chromium unavailable|Failed to launch|Could not find Chrome|executable|spawn|puppeteer/i.test(testCase.error || '')) {
456 return skippedCase({
457 name: testCase.name,
458 engine: testCase.engine,
459 mode: testCase.mode,
460 target: testCase.target,
461 reason: testCase.error,
462 });
463 }
464 return testCase;
465 });
466}
467
468function aggregateEvents(cases) {
469 const profile = createDetectorProfile();
470 for (const testCase of cases) {
471 if (Array.isArray(testCase.events)) profile.events.push(...testCase.events);
472 }
473 return summarizeDetectorProfile(profile);
474}
475
476function makeReport(args, cases) {
477 const summary = aggregateEvents(cases);
478 return {
479 version: 1,
480 createdAt: new Date().toISOString(),
481 cwd: ROOT,
482 quick: args.quick,
483 browser: args.browser,
484 cases: cases.map(({ events, ...testCase }) => testCase),
485 summary,
486 };
487}
488
489function pad(value, width) {
490 const str = String(value);
491 if (str.length >= width) return str.slice(0, width);
492 return str + ' '.repeat(width - str.length);
493}
494
495function printRows(rows, columns) {
496 const header = columns.map(col => pad(col.label, col.width)).join(' ');
497 console.log(header);
498 console.log(columns.map(col => '-'.repeat(col.width)).join(' '));
499 for (const row of rows) {
500 console.log(columns.map(col => pad(row[col.key] ?? '', col.width)).join(' '));
501 }
502}
503
504function printConsoleReport(report) {
505 console.log(`Detector benchmark ${report.quick ? '(quick)' : '(full)'}`);
506 console.log(`Cases: ${report.cases.length}`);
507 const caseRows = report.cases.map(testCase => ({
508 status: testCase.status,
509 engine: testCase.engine,
510 mode: testCase.mode,
511 totalMs: testCase.totalMs,
512 findings: testCase.findings,
513 target: testCase.target,
514 }));
515 printRows(caseRows, [
516 { key: 'status', label: 'Status', width: 8 },
517 { key: 'engine', label: 'Engine', width: 12 },
518 { key: 'mode', label: 'Mode', width: 20 },
519 { key: 'totalMs', label: 'Total ms', width: 10 },
520 { key: 'findings', label: 'Findings', width: 8 },
521 { key: 'target', label: 'Target', width: 60 },
522 ]);
523
524 const skipped = report.cases.filter(testCase => testCase.status === 'skipped');
525 for (const testCase of skipped) {
526 console.log(`Skipped ${testCase.name}: ${testCase.skipReason}`);
527 }
528
529 console.log('\nSlowest profile groups');
530 const slowRows = report.summary.slice(0, 20).map(item => ({
531 engine: item.engine,
532 phase: item.phase,
533 ruleId: item.ruleId,
534 calls: item.calls,
535 totalMs: item.totalMs,
536 avgMs: item.avgMs,
537 p95: item.p95,
538 findings: item.findings,
539 target: item.target,
540 }));
541 printRows(slowRows, [
542 { key: 'engine', label: 'Engine', width: 12 },
543 { key: 'phase', label: 'Phase', width: 14 },
544 { key: 'ruleId', label: 'Rule', width: 28 },
545 { key: 'calls', label: 'Calls', width: 8 },
546 { key: 'totalMs', label: 'Total ms', width: 10 },
547 { key: 'avgMs', label: 'Avg ms', width: 8 },
548 { key: 'p95', label: 'P95', width: 8 },
549 { key: 'findings', label: 'Finds', width: 7 },
550 { key: 'target', label: 'Target', width: 45 },
551 ]);
552}
553
554async function main() {
555 const args = parseArgs(process.argv.slice(2));
556 const cases = [
557 ...await runFileBenchmarks(args),
558 ];
559 if (args.browser) {
560 cases.push(...await runBrowserBenchmarks(args));
561 }
562
563 const report = makeReport(args, cases);
564 const json = JSON.stringify(report, null, 2);
565 if (args.out) {
566 fs.writeFileSync(path.resolve(args.out), json + '\n');
567 }
568 if (args.json) {
569 process.stdout.write(json + '\n');
570 } else {
571 printConsoleReport(report);
572 if (args.out) console.log(`\nWrote JSON report to ${path.resolve(args.out)}`);
573 }
574
575 if (report.cases.some(testCase => testCase.status === 'failed')) {
576 process.exitCode = 1;
577 }
578}
579
580main().catch(err => {
581 console.error(err?.stack || err?.message || err);
582 process.exit(1);
583});