benchmark-detector.mjs

  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});