Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
One-time setup that gathers your project's design context and saves it to CLAUDE.md for future sessions.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/detector/engines/browser/detect-url.mjs
1import fs from 'node:fs';2import path from 'node:path';3import { fileURLToPath } from 'node:url';45import { finding } from '../../findings.mjs';6import { filterByProviders } from '../../registry/antipatterns.mjs';7import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';8import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';910function serializeDesignSystemForBrowser(designSystem) {11if (!designSystem?.present) return null;12return {13present: true,14hasFonts: designSystem.hasFonts === true,15allowedFonts: Array.from(designSystem.allowedFonts || []),16hasColors: designSystem.hasColors === true,17allowedColors: Array.from(designSystem.allowedColorKeys?.values?.() || [])18.map(entry => entry?.color)19.filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b))20.map(color => ({ r: color.r, g: color.g, b: color.b })),21hasRadii: designSystem.hasRadii === true,22allowedRadii: (designSystem.allowedRadii || [])23.map(entry => Number(entry?.px))24.filter(px => Number.isFinite(px)),25hasPillRadius: designSystem.hasPillRadius === true,26};27}2829async function runVisualContrastFallback(page, serializedGroups, options, profile, target) {30if (options?.visualContrast === false) return [];31const maxCandidates = Number.isFinite(options?.visualContrastMaxCandidates)32? options.visualContrastMaxCandidates33: 12;34const scrollOffscreen = options?.visualContrastScrollOffscreen !== false;35const existingLowContrastSelectors = new Set(36serializedGroups37.filter(group => group.findings?.some(f => f.type === 'low-contrast'))38.map(group => group.selector)39.filter(Boolean)40);4142let browserAnalyses = [];43const findings = [];44if (options?.visualContrastBrowser !== false) {45const browserFindings = await profileFindingsAsync(profile, {46engine: 'browser',47phase: 'visual-contrast',48ruleId: 'browser-fallback',49target,50}, async () => {51browserAnalyses = await page.evaluate(async ({ maxCandidates, scrollOffscreen }) => {52if (typeof window.impeccableAnalyzeVisualContrast !== 'function') return [];53return window.impeccableAnalyzeVisualContrast({ maxCandidates, scrollOffscreen });54}, { maxCandidates, scrollOffscreen });55return browserAnalyses56.filter(result => result.finding && !existingLowContrastSelectors.has(result.selector))57.map(result => result.finding);58});59findings.push(...browserFindings);60}6162let candidates = browserAnalyses.length > 0 ? browserAnalyses : [];63if (candidates.length === 0) {64candidates = await profileStepAsync(profile, {65engine: 'browser',66phase: 'visual-contrast',67ruleId: 'collect-candidates',68target,69}, () => page.evaluate(({ maxCandidates }) => {70if (typeof window.impeccableCollectVisualContrastCandidates !== 'function') return [];71return window.impeccableCollectVisualContrastCandidates({ maxCandidates });72}, { maxCandidates }));73}7475const viewport = options?.viewport || { width: 1280, height: 800 };76const browserResolvedSelectors = new Set(77browserAnalyses78.filter(result => result.status === 'fail' || result.status === 'pass')79.map(result => result.selector)80.filter(Boolean)81);82const filtered = candidates.filter(candidate =>83!existingLowContrastSelectors.has(candidate.selector) &&84!browserResolvedSelectors.has(candidate.selector)85);86if (options?.visualContrastPixel === false) return findings;87for (const candidate of filtered) {88const result = await profileFindingsAsync(profile, {89engine: 'browser',90phase: 'visual-contrast',91ruleId: 'pixel-diff',92target,93}, async () => {94const finding = await captureVisualContrastCandidate(page, candidate, viewport);95return finding ? [finding] : [];96});97findings.push(...result);98}99return findings;100}101102// ---------------------------------------------------------------------------103// Puppeteer detection (for URLs)104// ---------------------------------------------------------------------------105106async function detectUrl(url, options = {}) {107const profile = options?.profile;108const waitUntil = options?.waitUntil || 'networkidle0';109const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;110const viewport = options?.viewport || { width: 1280, height: 800 };111const externalBrowser = options?.browser || null;112let puppeteer;113if (!externalBrowser) {114try {115puppeteer = await profileStepAsync(profile, {116engine: 'browser',117phase: 'setup',118ruleId: 'import-puppeteer',119target: url,120}, () => import('puppeteer'));121} catch {122throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer');123}124}125126// Read the browser detection script — reuse it instead of reimplementing127const browserScriptPath = path.resolve(128path.dirname(fileURLToPath(import.meta.url)),129'..',130'..',131'detect-antipatterns-browser.js'132);133let browserScript;134try {135browserScript = profileStep(profile, {136engine: 'browser',137phase: 'setup',138ruleId: 'read-browser-script',139target: url,140}, () => fs.readFileSync(browserScriptPath, 'utf-8'));141} catch {142throw new Error(`Browser script not found at ${browserScriptPath}`);143}144145// CI runners (GitHub Actions Ubuntu) block unprivileged user namespaces, so146// Chrome can't initialize its sandbox there. Disable the sandbox only when147// running in CI; local users keep the default hardened launch.148const launchArgs = process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [];149const browser = externalBrowser || await profileStepAsync(profile, {150engine: 'browser',151phase: 'load',152ruleId: 'launch-browser',153target: url,154}, () => puppeteer.default.launch({ headless: true, args: launchArgs }));155const page = await profileStepAsync(profile, {156engine: 'browser',157phase: 'load',158ruleId: 'new-page',159target: url,160}, () => browser.newPage());161let results = [];162try {163await profileStepAsync(profile, {164engine: 'browser',165phase: 'load',166ruleId: 'set-viewport',167target: url,168}, () => page.setViewport(viewport));169await profileStepAsync(profile, {170engine: 'browser',171phase: 'load',172ruleId: `goto:${waitUntil}`,173target: url,174}, () => page.goto(url, { waitUntil, timeout: 30000 }));175if (settleMs > 0) {176await profileStepAsync(profile, {177engine: 'browser',178phase: 'load',179ruleId: 'settle',180target: url,181}, () => new Promise(resolve => setTimeout(resolve, settleMs)));182}183184// Inject the browser detection script and collect results185const browserDesignSystem = serializeDesignSystemForBrowser(options?.designSystem);186await profileStepAsync(profile, {187engine: 'browser',188phase: 'scan',189ruleId: 'configure-pure-detect',190target: url,191}, () => page.evaluate((designSystem) => {192window.__IMPECCABLE_CONFIG__ = {193...(window.__IMPECCABLE_CONFIG__ || {}),194autoScan: false,195...(designSystem ? { designSystem } : {}),196};197}, browserDesignSystem));198await profileStepAsync(profile, {199engine: 'browser',200phase: 'scan',201ruleId: 'inject-browser-script',202target: url,203}, () => page.evaluate(browserScript));204let serializedGroups = [];205results = await profileFindingsAsync(profile, {206engine: 'browser',207phase: 'scan',208ruleId: 'browser-scan',209target: url,210}, async () => {211serializedGroups = await page.evaluate(() => {212if (!window.impeccableDetect) return [];213return window.impeccableDetect({ decorate: false, serialize: true });214});215return serializedGroups.flatMap(({ findings }) =>216findings.map(f => ({ id: f.type, snippet: f.detail, ignoreValue: f.ignoreValue || '' }))217);218});219const visualFindings = await runVisualContrastFallback(page, serializedGroups, options, profile, url);220results.push(...visualFindings);221} finally {222await profileStepAsync(profile, {223engine: 'browser',224phase: 'load',225ruleId: 'close-page',226target: url,227}, () => page.close().catch(() => {}));228if (!externalBrowser) {229await profileStepAsync(profile, {230engine: 'browser',231phase: 'load',232ruleId: 'close-browser',233target: url,234}, () => browser.close());235}236}237return filterByProviders(results.map(f => {238const item = finding(f.id, url, f.snippet);239if (f.ignoreValue) item.ignoreValue = f.ignoreValue;240return item;241}), options.providers);242}243244async function createBrowserDetector(options = {}) {245let puppeteer;246try {247puppeteer = await import('puppeteer');248} catch {249throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer');250}251const launchArgs = options.launchArgs || (process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : []);252const browser = options.browser || await puppeteer.default.launch({253headless: options.headless ?? true,254args: launchArgs,255});256const ownsBrowser = !options.browser;257const defaults = {258waitUntil: options.waitUntil || 'load',259settleMs: Number.isFinite(options.settleMs) ? options.settleMs : 100,260viewport: options.viewport || { width: 1280, height: 800 },261};262return {263browser,264async detectUrl(url, scanOptions = {}) {265return detectUrl(url, {266...defaults,267...scanOptions,268browser,269});270},271async close() {272if (ownsBrowser) await browser.close().catch(() => {});273},274};275}276277export { runVisualContrastFallback, detectUrl, createBrowserDetector };278