import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { finding } from '../../findings.mjs'; import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs'; import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs'; import { checkContentHiddenAtRest } from '../../rules/checks.mjs'; // On Windows, puppeteer's bundled Chrome lives in a user-writable cache // directory. Its GPU process can be denied (STATUS_ACCESS_DENIED) by security // software or the GPU sandbox because it launches from an untrusted path. // Chrome then crash-loops the GPU process, and each relaunch briefly flashes a // compositor surface, the black window users report during `detect ` // (issue #372). The system-installed Chrome runs from a trusted location with a // healthy GPU, so channel:'chrome' avoids the crash entirely; both use hardware // GPU, so contrast measurement is unaffected. Scope this to Windows only: other // platforms do not have the bug, so they keep the pinned bundled build for // consistent measurement across machines. Fall back to bundled when the switch // fails (Chrome not installed, or channel resolution fails). If the bundled // launch then also fails, surface the original system-Chrome error as the // cause so the real failure is not lost. async function launchBrowser(puppeteer, { headless = true, args = [] } = {}) { let channelError; if (process.platform === 'win32') { try { return await puppeteer.default.launch({ channel: 'chrome', headless, args }); } catch (err) { // System Chrome unavailable or unlaunchable; fall through to the bundled // browser, but keep the error in case the fallback fails too. channelError = err; } } try { return await puppeteer.default.launch({ headless, args }); } catch (err) { if (channelError && err && err.cause === undefined) err.cause = channelError; throw err; } } // Reveal sweep + invisible-text measurement for the content-hidden-at-rest // rule. Scrolls through the document with instant jumps (bypasses CSS // scroll-behavior: smooth) so IntersectionObserver / scroll reveal handlers // get every chance to fire, returns to the top, lets transitions settle, // then measures how much text still renders invisible. A healthy // reveal-on-scroll page drops to ~0 after the sweep; a page whose reveal // script died keeps most of its text at opacity 0. async function measureContentHiddenAfterReveal(page) { await page.evaluate(async () => { const step = Math.max(200, Math.floor(window.innerHeight * 0.7)); const max = Math.max( document.documentElement.scrollHeight || 0, document.body?.scrollHeight || 0, ); for (let y = 0; y <= max; y += step) { window.scrollTo({ top: y, left: 0, behavior: 'instant' }); await new Promise(resolve => requestAnimationFrame(() => setTimeout(resolve, 40))); } window.scrollTo({ top: 0, left: 0, behavior: 'instant' }); await new Promise(resolve => setTimeout(resolve, 700)); }); return page.evaluate(() => { if (typeof window.impeccableMeasureHiddenText !== 'function') return null; return window.impeccableMeasureHiddenText(); }); } function serializeDesignSystemForBrowser(designSystem) { if (!designSystem?.present) return null; return { present: true, hasFonts: designSystem.hasFonts === true, allowedFonts: Array.from(designSystem.allowedFonts || []), hasColors: designSystem.hasColors === true, allowedColors: Array.from(designSystem.allowedColorKeys?.values?.() || []) .map(entry => entry?.color) .filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b)) .map(color => ({ r: color.r, g: color.g, b: color.b })), hasRadii: designSystem.hasRadii === true, allowedRadii: (designSystem.allowedRadii || []) .map(entry => Number(entry?.px)) .filter(px => Number.isFinite(px)), hasPillRadius: designSystem.hasPillRadius === true, }; } async function runVisualContrastFallback(page, serializedGroups, options, profile, target) { if (options?.visualContrast === false) return []; const maxCandidates = Number.isFinite(options?.visualContrastMaxCandidates) ? options.visualContrastMaxCandidates : 12; const scrollOffscreen = options?.visualContrastScrollOffscreen !== false; const existingLowContrastSelectors = new Set( serializedGroups .filter(group => group.findings?.some(f => f.type === 'low-contrast')) .map(group => group.selector) .filter(Boolean) ); let browserAnalyses = []; const findings = []; if (options?.visualContrastBrowser !== false) { const browserFindings = await profileFindingsAsync(profile, { engine: 'browser', phase: 'visual-contrast', ruleId: 'browser-fallback', target, }, async () => { browserAnalyses = await page.evaluate(async ({ maxCandidates, scrollOffscreen }) => { if (typeof window.impeccableAnalyzeVisualContrast !== 'function') return []; return window.impeccableAnalyzeVisualContrast({ maxCandidates, scrollOffscreen }); }, { maxCandidates, scrollOffscreen }); return browserAnalyses .filter(result => result.finding && !existingLowContrastSelectors.has(result.selector)) .map(result => result.finding); }); findings.push(...browserFindings); } let candidates = browserAnalyses.length > 0 ? browserAnalyses : []; if (candidates.length === 0) { candidates = await profileStepAsync(profile, { engine: 'browser', phase: 'visual-contrast', ruleId: 'collect-candidates', target, }, () => page.evaluate(({ maxCandidates }) => { if (typeof window.impeccableCollectVisualContrastCandidates !== 'function') return []; return window.impeccableCollectVisualContrastCandidates({ maxCandidates }); }, { maxCandidates })); } const viewport = options?.viewport || { width: 1280, height: 800 }; const browserResolvedSelectors = new Set( browserAnalyses .filter(result => result.status === 'fail' || result.status === 'pass') .map(result => result.selector) .filter(Boolean) ); const filtered = candidates.filter(candidate => !existingLowContrastSelectors.has(candidate.selector) && !browserResolvedSelectors.has(candidate.selector) ); if (options?.visualContrastPixel === false) return findings; for (const candidate of filtered) { const result = await profileFindingsAsync(profile, { engine: 'browser', phase: 'visual-contrast', ruleId: 'pixel-diff', target, }, async () => { const finding = await captureVisualContrastCandidate(page, candidate, viewport); return finding ? [finding] : []; }); findings.push(...result); } return findings; } // --------------------------------------------------------------------------- // Puppeteer detection (for URLs) // --------------------------------------------------------------------------- async function detectUrl(url, options = {}) { const profile = options?.profile; const waitUntil = options?.waitUntil || 'networkidle0'; const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0; const viewport = options?.viewport || { width: 1280, height: 800 }; const externalBrowser = options?.browser || null; let puppeteer; if (!externalBrowser) { try { puppeteer = await profileStepAsync(profile, { engine: 'browser', phase: 'setup', ruleId: 'import-puppeteer', target: url, }, () => import('puppeteer')); } catch { throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer'); } } // Read the browser detection script — reuse it instead of reimplementing const browserScriptPath = path.resolve( path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'detect-antipatterns-browser.js' ); let browserScript; try { browserScript = profileStep(profile, { engine: 'browser', phase: 'setup', ruleId: 'read-browser-script', target: url, }, () => fs.readFileSync(browserScriptPath, 'utf-8')); } catch { throw new Error(`Browser script not found at ${browserScriptPath}`); } // CI runners (GitHub Actions Ubuntu) block unprivileged user namespaces, so // Chrome can't initialize its sandbox there. Disable the sandbox only when // running in CI; local users keep the default hardened launch. const launchArgs = process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : []; const browser = externalBrowser || await profileStepAsync(profile, { engine: 'browser', phase: 'load', ruleId: 'launch-browser', target: url, }, () => launchBrowser(puppeteer, { headless: options?.headless ?? true, args: launchArgs })); const page = await profileStepAsync(profile, { engine: 'browser', phase: 'load', ruleId: 'new-page', target: url, }, () => browser.newPage()); // Uncaught exceptions and parse errors surface as pageerror events. The // listener must attach before goto: a syntax error fires during the // initial parse, long before the load event. Dedupe by message; a single // broken loop can otherwise throw hundreds of identical errors. const pageErrors = []; if (options?.scriptErrors !== false) { page.on('pageerror', (err) => { const message = String(err?.message || err).split('\n')[0].trim().slice(0, 160); if (message && !pageErrors.includes(message)) pageErrors.push(message); }); } let results = []; try { await profileStepAsync(profile, { engine: 'browser', phase: 'load', ruleId: 'set-viewport', target: url, }, () => page.setViewport(viewport)); await profileStepAsync(profile, { engine: 'browser', phase: 'load', ruleId: `goto:${waitUntil}`, target: url, }, () => page.goto(url, { waitUntil, timeout: 30000 })); if (settleMs > 0) { await profileStepAsync(profile, { engine: 'browser', phase: 'load', ruleId: 'settle', target: url, }, () => new Promise(resolve => setTimeout(resolve, settleMs))); } // Inject the browser detection script and collect results const browserDesignSystem = serializeDesignSystemForBrowser(options?.designSystem); await profileStepAsync(profile, { engine: 'browser', phase: 'scan', ruleId: 'configure-pure-detect', target: url, }, () => page.evaluate((designSystem) => { window.__IMPECCABLE_CONFIG__ = { ...(window.__IMPECCABLE_CONFIG__ || {}), autoScan: false, ...(designSystem ? { designSystem } : {}), }; }, browserDesignSystem)); await profileStepAsync(profile, { engine: 'browser', phase: 'scan', ruleId: 'inject-browser-script', target: url, }, () => page.evaluate(browserScript)); let serializedGroups = []; results = await profileFindingsAsync(profile, { engine: 'browser', phase: 'scan', ruleId: 'browser-scan', target: url, }, async () => { serializedGroups = await page.evaluate(() => { if (!window.impeccableDetect) return []; return window.impeccableDetect({ decorate: false, serialize: true }); }); return serializedGroups.flatMap(({ findings }) => findings.map(f => ({ id: f.type, snippet: f.detail, ignoreValue: f.ignoreValue || '', severity: f.severity || '' })) ); }); // Content invisible at rest: reveal sweep, then re-measure. Runs after // the main scan (which must see the true at-rest state) and before the // visual contrast fallback (the sweep restores scroll to the top). if (options?.contentHidden !== false) { const hiddenFindings = await profileFindingsAsync(profile, { engine: 'browser', phase: 'scan', ruleId: 'content-hidden-at-rest', target: url, }, async () => { const measured = await measureContentHiddenAfterReveal(page); return measured ? checkContentHiddenAtRest(measured) : []; }); results.push(...hiddenFindings); } for (const message of pageErrors.slice(0, 3)) { results.push({ id: 'script-error', snippet: message }); } const visualFindings = await runVisualContrastFallback(page, serializedGroups, options, profile, url); results.push(...visualFindings); } finally { await profileStepAsync(profile, { engine: 'browser', phase: 'load', ruleId: 'close-page', target: url, }, () => page.close().catch(() => {})); if (!externalBrowser) { await profileStepAsync(profile, { engine: 'browser', phase: 'load', ruleId: 'close-browser', target: url, }, () => browser.close()); } } return results.map(f => { const item = finding(f.id, url, f.snippet); if (f.ignoreValue) item.ignoreValue = f.ignoreValue; // Per-finding severity promotion (e.g. hero-region pulsing dot) // overrides the registry default carried by finding(). if (f.severity && f.severity !== item.severity) item.severity = f.severity; return item; }); } async function createBrowserDetector(options = {}) { let puppeteer; try { puppeteer = await import('puppeteer'); } catch { throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer'); } const launchArgs = options.launchArgs || (process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : []); const browser = options.browser || await launchBrowser(puppeteer, { headless: options.headless ?? true, args: launchArgs, }); const ownsBrowser = !options.browser; const defaults = { waitUntil: options.waitUntil || 'load', settleMs: Number.isFinite(options.settleMs) ? options.settleMs : 100, viewport: options.viewport || { width: 1280, height: 800 }, }; return { browser, async detectUrl(url, scanOptions = {}) { return detectUrl(url, { ...defaults, ...scanOptions, browser, }); }, async close() { if (ownsBrowser) await browser.close().catch(() => {}); }, }; } export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };