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