detect-url.mjs

  1import fs from 'node:fs';
  2import path from 'node:path';
  3import { fileURLToPath } from 'node:url';
  4
  5import { finding } from '../../findings.mjs';
  6import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
  7import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
  8
  9async function runVisualContrastFallback(page, serializedGroups, options, profile, target) {
 10  if (options?.visualContrast === false) return [];
 11  const maxCandidates = Number.isFinite(options?.visualContrastMaxCandidates)
 12    ? options.visualContrastMaxCandidates
 13    : 12;
 14  const scrollOffscreen = options?.visualContrastScrollOffscreen !== false;
 15  const existingLowContrastSelectors = new Set(
 16    serializedGroups
 17      .filter(group => group.findings?.some(f => f.type === 'low-contrast'))
 18      .map(group => group.selector)
 19      .filter(Boolean)
 20  );
 21
 22  let browserAnalyses = [];
 23  const findings = [];
 24  if (options?.visualContrastBrowser !== false) {
 25    const browserFindings = await profileFindingsAsync(profile, {
 26      engine: 'browser',
 27      phase: 'visual-contrast',
 28      ruleId: 'browser-fallback',
 29      target,
 30    }, async () => {
 31      browserAnalyses = await page.evaluate(async ({ maxCandidates, scrollOffscreen }) => {
 32        if (typeof window.impeccableAnalyzeVisualContrast !== 'function') return [];
 33        return window.impeccableAnalyzeVisualContrast({ maxCandidates, scrollOffscreen });
 34      }, { maxCandidates, scrollOffscreen });
 35      return browserAnalyses
 36        .filter(result => result.finding && !existingLowContrastSelectors.has(result.selector))
 37        .map(result => result.finding);
 38    });
 39    findings.push(...browserFindings);
 40  }
 41
 42  let candidates = browserAnalyses.length > 0 ? browserAnalyses : [];
 43  if (candidates.length === 0) {
 44    candidates = await profileStepAsync(profile, {
 45      engine: 'browser',
 46      phase: 'visual-contrast',
 47      ruleId: 'collect-candidates',
 48      target,
 49    }, () => page.evaluate(({ maxCandidates }) => {
 50      if (typeof window.impeccableCollectVisualContrastCandidates !== 'function') return [];
 51      return window.impeccableCollectVisualContrastCandidates({ maxCandidates });
 52    }, { maxCandidates }));
 53  }
 54
 55  const viewport = options?.viewport || { width: 1280, height: 800 };
 56  const browserResolvedSelectors = new Set(
 57    browserAnalyses
 58      .filter(result => result.status === 'fail' || result.status === 'pass')
 59      .map(result => result.selector)
 60      .filter(Boolean)
 61  );
 62  const filtered = candidates.filter(candidate =>
 63    !existingLowContrastSelectors.has(candidate.selector) &&
 64    !browserResolvedSelectors.has(candidate.selector)
 65  );
 66  if (options?.visualContrastPixel === false) return findings;
 67  for (const candidate of filtered) {
 68    const result = await profileFindingsAsync(profile, {
 69      engine: 'browser',
 70      phase: 'visual-contrast',
 71      ruleId: 'pixel-diff',
 72      target,
 73    }, async () => {
 74      const finding = await captureVisualContrastCandidate(page, candidate, viewport);
 75      return finding ? [finding] : [];
 76    });
 77    findings.push(...result);
 78  }
 79  return findings;
 80}
 81
 82// ---------------------------------------------------------------------------
 83// Puppeteer detection (for URLs)
 84// ---------------------------------------------------------------------------
 85
 86async function detectUrl(url, options = {}) {
 87  const profile = options?.profile;
 88  const waitUntil = options?.waitUntil || 'networkidle0';
 89  const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
 90  const viewport = options?.viewport || { width: 1280, height: 800 };
 91  const externalBrowser = options?.browser || null;
 92  let puppeteer;
 93  if (!externalBrowser) {
 94    try {
 95      puppeteer = await profileStepAsync(profile, {
 96        engine: 'browser',
 97        phase: 'setup',
 98        ruleId: 'import-puppeteer',
 99        target: url,
100      }, () => import('puppeteer'));
101    } catch {
102      throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer');
103    }
104  }
105
106  // Read the browser detection script — reuse it instead of reimplementing
107  const browserScriptPath = path.resolve(
108    path.dirname(fileURLToPath(import.meta.url)),
109    '..',
110    '..',
111    'detect-antipatterns-browser.js'
112  );
113  let browserScript;
114  try {
115    browserScript = profileStep(profile, {
116      engine: 'browser',
117      phase: 'setup',
118      ruleId: 'read-browser-script',
119      target: url,
120    }, () => fs.readFileSync(browserScriptPath, 'utf-8'));
121  } catch {
122    throw new Error(`Browser script not found at ${browserScriptPath}`);
123  }
124
125  // CI runners (GitHub Actions Ubuntu) block unprivileged user namespaces, so
126  // Chrome can't initialize its sandbox there. Disable the sandbox only when
127  // running in CI; local users keep the default hardened launch.
128  const launchArgs = process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [];
129  const browser = externalBrowser || await profileStepAsync(profile, {
130    engine: 'browser',
131    phase: 'load',
132    ruleId: 'launch-browser',
133    target: url,
134  }, () => puppeteer.default.launch({ headless: true, args: launchArgs }));
135  const page = await profileStepAsync(profile, {
136    engine: 'browser',
137    phase: 'load',
138    ruleId: 'new-page',
139    target: url,
140  }, () => browser.newPage());
141  let results = [];
142  try {
143    await profileStepAsync(profile, {
144      engine: 'browser',
145      phase: 'load',
146      ruleId: 'set-viewport',
147      target: url,
148    }, () => page.setViewport(viewport));
149    await profileStepAsync(profile, {
150      engine: 'browser',
151      phase: 'load',
152      ruleId: `goto:${waitUntil}`,
153      target: url,
154    }, () => page.goto(url, { waitUntil, timeout: 30000 }));
155    if (settleMs > 0) {
156      await profileStepAsync(profile, {
157        engine: 'browser',
158        phase: 'load',
159        ruleId: 'settle',
160        target: url,
161      }, () => new Promise(resolve => setTimeout(resolve, settleMs)));
162    }
163
164    // Inject the browser detection script and collect results
165    await profileStepAsync(profile, {
166      engine: 'browser',
167      phase: 'scan',
168      ruleId: 'configure-pure-detect',
169      target: url,
170    }, () => page.evaluate(() => {
171      window.__IMPECCABLE_CONFIG__ = {
172        ...(window.__IMPECCABLE_CONFIG__ || {}),
173        autoScan: false,
174      };
175    }));
176    await profileStepAsync(profile, {
177      engine: 'browser',
178      phase: 'scan',
179      ruleId: 'inject-browser-script',
180      target: url,
181    }, () => page.evaluate(browserScript));
182    let serializedGroups = [];
183    results = await profileFindingsAsync(profile, {
184      engine: 'browser',
185      phase: 'scan',
186      ruleId: 'browser-scan',
187      target: url,
188    }, async () => {
189      serializedGroups = await page.evaluate(() => {
190        if (!window.impeccableDetect) return [];
191        return window.impeccableDetect({ decorate: false, serialize: true });
192      });
193      return serializedGroups.flatMap(({ findings }) =>
194        findings.map(f => ({ id: f.type, snippet: f.detail }))
195      );
196    });
197    const visualFindings = await runVisualContrastFallback(page, serializedGroups, options, profile, url);
198    results.push(...visualFindings);
199  } finally {
200    await profileStepAsync(profile, {
201      engine: 'browser',
202      phase: 'load',
203      ruleId: 'close-page',
204      target: url,
205    }, () => page.close().catch(() => {}));
206    if (!externalBrowser) {
207      await profileStepAsync(profile, {
208        engine: 'browser',
209        phase: 'load',
210        ruleId: 'close-browser',
211        target: url,
212      }, () => browser.close());
213    }
214  }
215  return results.map(f => finding(f.id, url, f.snippet));
216}
217
218async function createBrowserDetector(options = {}) {
219  let puppeteer;
220  try {
221    puppeteer = await import('puppeteer');
222  } catch {
223    throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer');
224  }
225  const launchArgs = options.launchArgs || (process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : []);
226  const browser = options.browser || await puppeteer.default.launch({
227    headless: options.headless ?? true,
228    args: launchArgs,
229  });
230  const ownsBrowser = !options.browser;
231  const defaults = {
232    waitUntil: options.waitUntil || 'load',
233    settleMs: Number.isFinite(options.settleMs) ? options.settleMs : 100,
234    viewport: options.viewport || { width: 1280, height: 800 },
235  };
236  return {
237    browser,
238    async detectUrl(url, scanOptions = {}) {
239      return detectUrl(url, {
240        ...defaults,
241        ...scanOptions,
242        browser,
243      });
244    },
245    async close() {
246      if (ownsBrowser) await browser.close().catch(() => {});
247    },
248  };
249}
250
251export { runVisualContrastFallback, detectUrl, createBrowserDetector };