252 lines
8.6 KiB
JavaScript
252 lines
8.6 KiB
JavaScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { finding } from '../../findings.mjs';
|
|
import { filterByProviders } from '../../registry/antipatterns.mjs';
|
|
import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
|
|
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
|
|
|
|
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,
|
|
}, () => puppeteer.default.launch({ headless: true, args: launchArgs }));
|
|
const page = await profileStepAsync(profile, {
|
|
engine: 'browser',
|
|
phase: 'load',
|
|
ruleId: 'new-page',
|
|
target: url,
|
|
}, () => browser.newPage());
|
|
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
|
|
await profileStepAsync(profile, {
|
|
engine: 'browser',
|
|
phase: 'scan',
|
|
ruleId: 'configure-pure-detect',
|
|
target: url,
|
|
}, () => page.evaluate(() => {
|
|
window.__IMPECCABLE_CONFIG__ = {
|
|
...(window.__IMPECCABLE_CONFIG__ || {}),
|
|
autoScan: false,
|
|
};
|
|
}));
|
|
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 }))
|
|
);
|
|
});
|
|
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 filterByProviders(results.map(f => finding(f.id, url, f.snippet)), options.providers);
|
|
}
|
|
|
|
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 puppeteer.default.launch({
|
|
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 };
|