1/**
2 * Puppeteer-backed fixture tests for browser-only detection rules.
3 *
4 * Some detection rules (cramped-padding, line-length, body-text-viewport-edge)
5 * need real browser layout — they read getBoundingClientRect and real
6 * getComputedStyle results that the static HTML/CSS engine intentionally
7 * does not invent.
8 *
9 * This file uses detectUrl() (Puppeteer) to load fixtures in headless Chrome
10 * via a temporary static HTTP server, so the fixtures can use absolute
11 * <script src="/js/..."> paths just like in development.
12 *
13 * Run via Node's built-in test runner:
14 * node --test tests/detect-antipatterns-browser.test.mjs
15 */
16import { describe, it, before, after } from 'node:test';
17import assert from 'node:assert/strict';
18import http from 'node:http';
19import fs from 'node:fs';
20import path from 'node:path';
21import { fileURLToPath } from 'node:url';
22import { createBrowserDetector, detectUrl } from '../cli/engine/detect-antipatterns.mjs';
23
24const __dirname = path.dirname(fileURLToPath(import.meta.url));
25const ROOT = path.resolve(__dirname, '..');
26
27const MIME = {
28 '.html': 'text/html; charset=utf-8',
29 '.js': 'text/javascript; charset=utf-8',
30 '.css': 'text/css; charset=utf-8',
31 '.svg': 'image/svg+xml',
32 '.png': 'image/png',
33 '.jpg': 'image/jpeg',
34};
35
36let server;
37let baseUrl;
38
39before(async () => {
40 // Static server: maps /fixtures/* to tests/fixtures/* and
41 // /js/detect-antipatterns-browser.js to cli/engine/detect-antipatterns-browser.js
42 // (mirrors what Astro serves so fixtures can use absolute paths)
43 server = http.createServer((req, res) => {
44 let filePath;
45 if (req.url.startsWith('/fixtures/')) {
46 filePath = path.join(ROOT, 'tests', req.url);
47 } else if (req.url === '/js/detect-antipatterns-browser.js') {
48 filePath = path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js');
49 } else {
50 res.writeHead(404).end();
51 return;
52 }
53 try {
54 const body = fs.readFileSync(filePath);
55 const ext = path.extname(filePath);
56 res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
57 res.end(body);
58 } catch {
59 res.writeHead(404).end();
60 }
61 });
62 await new Promise((resolve, reject) => {
63 server.once('error', reject);
64 server.listen(0, '127.0.0.1', () => {
65 server.off('error', reject);
66 baseUrl = `http://127.0.0.1:${server.address().port}`;
67 resolve();
68 });
69 });
70});
71
72after(async () => {
73 if (server?.listening) await new Promise((resolve) => server.close(resolve));
74});
75
76describe('detectUrl — browser-only fixtures', () => {
77 // Only two rules genuinely need real browser layout (getBoundingClientRect):
78 // line-length → reads rect.width to compute chars-per-line
79 // cramped-padding → reads rect.width/height to filter small badges
80 // Everything else in the quality.html fixture runs in static HTML/CSS and is asserted
81 // by tests/detect-antipatterns-fixtures.test.mjs.
82
83 it('cramped-padding: flag column triggers all 8 cramped cases, pass column adds none', async () => {
84 const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/cramped-padding.html`);
85 const cramped = f.filter(r => r.antipattern === 'cramped-padding');
86 // Flag column has 8 cases that should fire under the asymmetric
87 // proportional rule (vertical: max(4, fs×0.3), horizontal: max(8, fs×0.5)):
88 // 1. 14px body / 4px all sides — V fail
89 // 2. 14px body / 2px all sides — both fail
90 // 3. 16px body / 4px all sides — both fail
91 // 4. 14px body / 1px V / 16px H — V fail
92 // 5. 14px body / 12px V / 4px H — H fail
93 // 6. 24px heading / 8px all sides — H fail (improvement over old 8px floor)
94 // 7. 32px hero / 6px V / 16px H — V fail
95 // 8. 14px <pre> / 2px all sides — both fail
96 // Pass column has 12 cases (small pills, standard cards, code blocks,
97 // buttons, inputs, big text with proportional padding) — none should fire.
98 assert.equal(cramped.length, 8, `expected 8 cramped-padding findings, got ${cramped.length}`);
99 });
100
101 it('line-length: flag column triggers, pass column adds none', async () => {
102 const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/quality.html`);
103 assert.equal(f.filter(r => r.antipattern === 'line-length').length, 1);
104 });
105
106 it('typography side-by-side: element-level flag cases get regular overlays', async () => {
107 const puppeteer = await import('puppeteer');
108 const browser = await puppeteer.default.launch({
109 headless: true,
110 args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
111 });
112 try {
113 const page = await browser.newPage();
114 await page.setViewport({ width: 1280, height: 800 });
115 await page.goto(`${baseUrl}/fixtures/antipatterns/typography.html`, { waitUntil: 'load' });
116 const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
117 await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
118 await page.evaluate(browserScript);
119 const result = await page.evaluate(() => {
120 const groups = window.impeccableScan();
121 const types = groups.flatMap(group => group.findings.map(finding => finding.type || finding.id));
122 return {
123 types,
124 pageTypes: groups
125 .filter(group => group.el === document.body || group.el === document.documentElement)
126 .flatMap(group => group.findings.map(finding => finding.type || finding.id)),
127 hasBanner: Boolean(document.querySelector('.impeccable-banner')),
128 overlays: document.querySelectorAll('.impeccable-overlay:not(.impeccable-banner)').length,
129 };
130 });
131 for (const id of ['tight-leading', 'tiny-text', 'all-caps-body', 'wide-tracking', 'justified-text']) {
132 assert.ok(result.types.includes(id), `expected browser typography scan to include ${id}: ${JSON.stringify(result)}`);
133 }
134 assert.ok(result.pageTypes.includes('overused-font'), `expected browser typography scan to include page-level overused-font: ${JSON.stringify(result)}`);
135 assert.equal(result.hasBanner, true, `expected page-level typography banner: ${JSON.stringify(result)}`);
136 assert.ok(result.overlays >= 5, `expected visible typography overlays, got: ${JSON.stringify(result)}`);
137 await page.close();
138 } finally {
139 await browser.close().catch(() => {});
140 }
141 });
142
143 it('body-text-viewport-edge: 3 flag paragraphs/list-items, 0 pass cases', async () => {
144 const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/body-text-viewport-edge.html`);
145 const edges = f.filter(r => r.antipattern === 'body-text-viewport-edge');
146 // Fixture has 3 escape-styled <p>/<li> paragraphs that bleed to
147 // the viewport edges. The pass column has 5 paragraphs that
148 // should not fire (centered container, inside nav, inside header,
149 // inside section with own background, short label < 40 chars).
150 assert.equal(edges.length, 3, `expected 3 body-text-viewport-edge findings, got ${edges.length}: ${JSON.stringify(edges.map(e => e.snippet))}`);
151 });
152
153 it('visual contrast: browser fallback catches low contrast on image backgrounds', async () => {
154 const analyticOnly = await detectUrl(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, {
155 waitUntil: 'load',
156 visualContrast: false,
157 });
158 assert.equal(
159 analyticOnly.some(r => r.antipattern === 'low-contrast' && /White text on light image/i.test(r.snippet || '')),
160 false,
161 'analytic contrast should not guess image-background contrast',
162 );
163
164 const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, {
165 waitUntil: 'load',
166 visualContrast: true,
167 visualContrastMaxCandidates: 20,
168 });
169 const visualFindings = f.filter(r =>
170 r.antipattern === 'low-contrast' &&
171 /(?:browser|pixel) contrast/i.test(r.snippet || '')
172 );
173 assert.equal(
174 visualFindings.length,
175 4,
176 `expected 4 visual contrast findings, got ${visualFindings.length}: ${JSON.stringify(visualFindings.map(r => r.snippet))}`,
177 );
178 assert.ok(
179 f.some(r =>
180 r.antipattern === 'low-contrast' &&
181 /(?:browser|pixel) contrast/i.test(r.snippet || '') &&
182 /White text on light image/i.test(r.snippet || '')
183 ),
184 `expected visual contrast finding for light image background, got: ${JSON.stringify(f.map(r => r.snippet))}`,
185 );
186 assert.ok(
187 f.some(r => r.antipattern === 'low-contrast' && /Dark text on dark image/i.test(r.snippet || '')),
188 'expected pixel contrast finding for dark text on dark image',
189 );
190 assert.ok(
191 f.some(r => r.antipattern === 'low-contrast' && /Translucent white text on a pale pattern/i.test(r.snippet || '')),
192 'expected pixel contrast finding for translucent text on pale pattern',
193 );
194 assert.ok(
195 f.some(r => r.antipattern === 'low-contrast' && /Muted gray text on a misty image/i.test(r.snippet || '')),
196 'expected pixel contrast finding for muted gray text on misty image',
197 );
198 assert.equal(
199 f.some(r => r.antipattern === 'low-contrast' && /White text on dark image/i.test(r.snippet || '')),
200 false,
201 'dark image background should keep enough contrast',
202 );
203 assert.equal(
204 f.some(r => r.antipattern === 'low-contrast' && /Dark text on light image/i.test(r.snippet || '')),
205 false,
206 'light image with dark text should keep enough contrast',
207 );
208 assert.equal(
209 f.some(r => r.antipattern === 'low-contrast' && /Should (?:flag|pass) after pixel sampling/i.test(r.snippet || '')),
210 false,
211 'fixture column headings should not be low-contrast findings',
212 );
213 });
214
215 it('browser API: visual contrast fallback resolves readable image backgrounds without overlays', async () => {
216 const puppeteer = await import('puppeteer');
217 const browser = await puppeteer.default.launch({
218 headless: true,
219 args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
220 });
221 try {
222 const page = await browser.newPage();
223 await page.setViewport({ width: 1280, height: 800 });
224 await page.goto(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, { waitUntil: 'load' });
225 const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
226 await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
227 await page.evaluate(browserScript);
228 const result = await page.evaluate(async () => {
229 const before = document.querySelectorAll('.impeccable-overlay, .impeccable-label, .impeccable-banner').length;
230 const analyses = await window.impeccableAnalyzeVisualContrast({ maxCandidates: 20, scrollOffscreen: true });
231 const after = document.querySelectorAll('.impeccable-overlay, .impeccable-label, .impeccable-banner').length;
232 return {
233 before,
234 after,
235 failed: analyses.filter(item => item.status === 'fail').map(item => item.finding?.snippet || ''),
236 passed: analyses.filter(item => item.status === 'pass').map(item => item.text || ''),
237 unresolved: analyses.filter(item => item.status === 'unresolved').map(item => item.reason || ''),
238 };
239 });
240 assert.equal(result.before, 0);
241 assert.equal(result.after, 0);
242 assert.equal(result.failed.length, 4, `expected 4 browser visual failures, got: ${JSON.stringify(result)}`);
243 assert.ok(result.failed.some(snippet => /White text on light image/i.test(snippet)));
244 assert.ok(result.failed.some(snippet => /Dark text on dark image/i.test(snippet)));
245 assert.ok(result.failed.every(snippet => /browser contrast/i.test(snippet)));
246 assert.ok(result.passed.some(text => /White text on dark image/i.test(text)));
247 assert.ok(result.passed.some(text => /Dark text on light image/i.test(text)));
248 } finally {
249 await browser.close().catch(() => {});
250 }
251 });
252
253 it('browser API: visual contrast scan decorates visible findings without scrolling by default', async () => {
254 const puppeteer = await import('puppeteer');
255 const browser = await puppeteer.default.launch({
256 headless: true,
257 args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
258 });
259 try {
260 const page = await browser.newPage();
261 await page.setViewport({ width: 1280, height: 800 });
262 await page.goto(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, { waitUntil: 'load' });
263 const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
264 await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
265 await page.evaluate(browserScript);
266 const result = await page.evaluate(async () => {
267 let scrollEvents = 0;
268 let maxScrollY = window.scrollY;
269 window.addEventListener('scroll', () => {
270 scrollEvents += 1;
271 maxScrollY = Math.max(maxScrollY, window.scrollY);
272 }, { passive: true });
273 const syncScanResult = window.impeccableScan({
274 visualContrast: true,
275 visualContrastMaxCandidates: 20,
276 });
277 const syncDetectResult = window.impeccableDetect({
278 visualContrast: true,
279 serialize: true,
280 });
281 const groups = await window.impeccableScanAsync({
282 visualContrast: true,
283 visualContrastMaxCandidates: 20,
284 });
285 await new Promise(resolve => setTimeout(resolve, 50));
286 return {
287 groups: groups.map(group => ({
288 text: group.el.textContent || '',
289 types: group.findings.map(finding => finding.type || finding.id),
290 })),
291 overlays: document.querySelectorAll('.impeccable-overlay:not(.impeccable-banner)').length,
292 labels: document.querySelectorAll('.impeccable-label').length,
293 analyses: window.impeccableGetLastVisualContrastAnalyses().filter(item => item.status === 'fail').length,
294 scrollEvents,
295 maxScrollY,
296 finalScrollY: window.scrollY,
297 syncScanIsArray: Array.isArray(syncScanResult),
298 syncDetectIsArray: Array.isArray(syncDetectResult),
299 hasAsyncApi: typeof window.impeccableScanAsync === 'function' && typeof window.impeccableDetectAsync === 'function',
300 };
301 });
302 const visualGroups = result.groups.filter(group =>
303 group.types.includes('low-contrast') &&
304 /(?:White text on light image|Dark text on dark image|Translucent white text|Muted gray text)/i.test(group.text)
305 );
306 assert.equal(result.analyses, 3, `expected 3 viewport visual failures, got: ${JSON.stringify(result)}`);
307 assert.equal(visualGroups.length, 3, `expected 3 viewport visual groups, got: ${JSON.stringify(result)}`);
308 assert.ok(result.overlays >= 3, `expected regular overlays for visible visual findings, got: ${JSON.stringify(result)}`);
309 assert.ok(result.labels >= 3, `expected regular labels for visible visual findings, got: ${JSON.stringify(result)}`);
310 assert.equal(result.maxScrollY, 0, `visual scan should not scroll the page by default: ${JSON.stringify(result)}`);
311 assert.equal(result.finalScrollY, 0, `visual scan should preserve scroll by default: ${JSON.stringify(result)}`);
312 assert.equal(result.syncScanIsArray, true, `impeccableScan should keep a synchronous Array return: ${JSON.stringify(result)}`);
313 assert.equal(result.syncDetectIsArray, true, `impeccableDetect should keep a synchronous Array return: ${JSON.stringify(result)}`);
314 assert.equal(result.hasAsyncApi, true, `visual contrast should expose explicit async APIs: ${JSON.stringify(result)}`);
315
316 const refreshedOverlayResult = await page.evaluate(async () => {
317 window.scrollTo(0, 0);
318 const target = [...document.querySelectorAll('p')]
319 .find(node => /White text on light image should be sampled/i.test(node.textContent || ''));
320 target.style.fontSize = '10px';
321 const initialGroups = window.impeccableScan({
322 visualContrast: true,
323 visualContrastMaxCandidates: 20,
324 });
325 const initialTargetGroup = initialGroups.find(group => group.el === target);
326 const deadline = Date.now() + 1000;
327 while (
328 Date.now() < deadline &&
329 !/low contrast/i.test(target?._impeccableOverlay?.textContent || '')
330 ) {
331 const nextButton = target?._impeccableOverlay?.querySelector('button:last-of-type');
332 if (nextButton) nextButton.click();
333 await new Promise(resolve => setTimeout(resolve, 25));
334 }
335 const labelVariants = [];
336 const overlay = target?._impeccableOverlay;
337 for (let i = 0; i < 3; i++) {
338 labelVariants.push(overlay?.textContent || '');
339 overlay?.querySelector('button:last-of-type')?.click();
340 await new Promise(resolve => setTimeout(resolve, 0));
341 }
342 return {
343 initialTypes: initialTargetGroup?.findings.map(finding => finding.type || finding.id) || [],
344 labelText: target?._impeccableOverlay?.textContent || '',
345 labelVariants,
346 overlayConnected: Boolean(target?._impeccableOverlay?.isConnected),
347 };
348 });
349 assert.ok(refreshedOverlayResult.initialTypes.includes('tiny-text'), `test setup should create an initial sync overlay on the target: ${JSON.stringify(refreshedOverlayResult)}`);
350 assert.ok(refreshedOverlayResult.labelVariants.some(text => /tiny body text/i.test(text)), `expected refreshed overlay to keep the sync finding label: ${JSON.stringify(refreshedOverlayResult)}`);
351 assert.ok(refreshedOverlayResult.labelVariants.some(text => /low contrast/i.test(text)), `expected visual contrast to refresh the existing overlay label: ${JSON.stringify(refreshedOverlayResult)}`);
352 assert.equal(refreshedOverlayResult.overlayConnected, true, `expected refreshed overlay to stay connected: ${JSON.stringify(refreshedOverlayResult)}`);
353
354 const lazyResult = await page.evaluate(async () => {
355 const target = [...document.querySelectorAll('p')]
356 .find(node => /Muted gray text on a misty image/i.test(node.textContent || ''));
357 target?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
358 await new Promise(resolve => setTimeout(resolve, 250));
359 return {
360 overlays: document.querySelectorAll('.impeccable-overlay:not(.impeccable-banner)').length,
361 labels: document.querySelectorAll('.impeccable-label').length,
362 analyses: window.impeccableGetLastVisualContrastAnalyses().filter(item => item.status === 'fail').length,
363 targetHasOverlay: Boolean(target?._impeccableOverlay),
364 scrollY: window.scrollY,
365 };
366 });
367 assert.equal(lazyResult.analyses, 4, `expected lazy visual resolution after scrolling into view, got: ${JSON.stringify(lazyResult)}`);
368 assert.ok(lazyResult.overlays >= 4, `expected lazy visual overlay after scrolling into view, got: ${JSON.stringify(lazyResult)}`);
369 assert.ok(lazyResult.labels >= 4, `expected lazy visual label after scrolling into view, got: ${JSON.stringify(lazyResult)}`);
370 assert.equal(lazyResult.targetHasOverlay, true, `expected lazy visual target to get a regular overlay, got: ${JSON.stringify(lazyResult)}`);
371 assert.ok(lazyResult.scrollY > 0, `test should have naturally scrolled to the offscreen case: ${JSON.stringify(lazyResult)}`);
372
373 const staleOverlayResult = await page.evaluate(async () => {
374 const target = [...document.querySelectorAll('p')]
375 .find(node => /Muted gray text on a misty image/i.test(node.textContent || ''));
376 window.scrollTo(0, 0);
377 await new Promise(resolve => setTimeout(resolve, 50));
378 await window.impeccableScanAsync({
379 visualContrast: true,
380 visualContrastMaxCandidates: 20,
381 });
382 const staleCleared = !target?._impeccableOverlay;
383 target?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
384 await new Promise(resolve => setTimeout(resolve, 250));
385 return {
386 staleCleared,
387 targetHasOverlay: Boolean(target?._impeccableOverlay),
388 targetOverlayConnected: Boolean(target?._impeccableOverlay?.isConnected),
389 overlays: document.querySelectorAll('.impeccable-overlay:not(.impeccable-banner)').length,
390 analyses: window.impeccableGetLastVisualContrastAnalyses().filter(item => item.status === 'fail').length,
391 };
392 });
393 assert.equal(staleOverlayResult.staleCleared, true, `expected clearOverlays to remove stale target overlay refs, got: ${JSON.stringify(staleOverlayResult)}`);
394 assert.equal(staleOverlayResult.targetHasOverlay, true, `expected lazy visual target to be highlightable after a rescan, got: ${JSON.stringify(staleOverlayResult)}`);
395 assert.equal(staleOverlayResult.targetOverlayConnected, true, `expected lazy visual overlay after rescan to be connected, got: ${JSON.stringify(staleOverlayResult)}`);
396
397 const offscreenResult = await page.evaluate(async () => {
398 window.scrollTo(0, 0);
399 let maxScrollY = window.scrollY;
400 window.addEventListener('scroll', () => {
401 maxScrollY = Math.max(maxScrollY, window.scrollY);
402 }, { passive: true });
403 const groups = await window.impeccableScanAsync({
404 visualContrast: true,
405 visualContrastMaxCandidates: 20,
406 visualContrastScrollOffscreen: true,
407 });
408 await new Promise(resolve => setTimeout(resolve, 50));
409 return {
410 groups: groups.map(group => ({
411 text: group.el.textContent || '',
412 types: group.findings.map(finding => finding.type || finding.id),
413 })),
414 analyses: window.impeccableGetLastVisualContrastAnalyses().filter(item => item.status === 'fail').length,
415 maxScrollY,
416 finalScrollY: window.scrollY,
417 };
418 });
419 const offscreenVisualGroups = offscreenResult.groups.filter(group =>
420 group.types.includes('low-contrast') &&
421 /(?:White text on light image|Dark text on dark image|Translucent white text|Muted gray text)/i.test(group.text)
422 );
423 assert.equal(offscreenResult.analyses, 4, `expected 4 opt-in visual failures, got: ${JSON.stringify(offscreenResult)}`);
424 assert.equal(offscreenVisualGroups.length, 4, `expected 4 opt-in visual groups, got: ${JSON.stringify(offscreenResult)}`);
425 assert.ok(offscreenResult.maxScrollY > 0, `offscreen opt-in should be allowed to scroll: ${JSON.stringify(offscreenResult)}`);
426 assert.equal(offscreenResult.finalScrollY, 0, `offscreen opt-in should restore scroll: ${JSON.stringify(offscreenResult)}`);
427 } finally {
428 await browser.close().catch(() => {});
429 }
430 });
431
432 it('extension mode remove cancels pending lazy visual contrast work', async () => {
433 const puppeteer = await import('puppeteer');
434 const browser = await puppeteer.default.launch({
435 headless: true,
436 args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
437 });
438 try {
439 const page = await browser.newPage();
440 await page.setViewport({ width: 1280, height: 800 });
441 await page.goto(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, { waitUntil: 'load' });
442 const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
443 await page.evaluate(() => {
444 document.documentElement.dataset.impeccableExtension = 'true';
445 window.__impeccableMessages = [];
446 window.addEventListener('message', event => {
447 if (event.source !== window || !event.data?.source?.startsWith('impeccable-')) return;
448 window.__impeccableMessages.push(event.data);
449 });
450 });
451 await page.evaluate(browserScript);
452 const result = await page.evaluate(async () => {
453 window.postMessage({
454 source: 'impeccable-command',
455 action: 'scan',
456 config: {
457 visualContrast: true,
458 visualContrastMaxCandidates: 20,
459 },
460 }, '*');
461 const scanDeadline = Date.now() + 1000;
462 while (
463 Date.now() < scanDeadline &&
464 !window.impeccableGetLastVisualContrastAnalyses()
465 .some(item => item.status === 'unresolved' && item.reason === 'text outside viewport')
466 ) {
467 await new Promise(resolve => setTimeout(resolve, 25));
468 }
469 const unresolvedBeforeRemove = window.impeccableGetLastVisualContrastAnalyses()
470 .filter(item => item.status === 'unresolved' && item.reason === 'text outside viewport').length;
471 window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
472 await new Promise(resolve => setTimeout(resolve, 50));
473 const target = [...document.querySelectorAll('p')]
474 .find(node => /Muted gray text on a misty image/i.test(node.textContent || ''));
475 target?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
476 await new Promise(resolve => setTimeout(resolve, 300));
477 const resultsAfterRemove = window.__impeccableMessages
478 .filter(message => message.source === 'impeccable-results').length;
479 return {
480 unresolvedBeforeRemove,
481 overlayCount: document.querySelectorAll('.impeccable-overlay').length,
482 targetHasOverlay: Boolean(target?._impeccableOverlay),
483 resultsAfterRemove,
484 };
485 });
486 assert.ok(result.unresolvedBeforeRemove > 0, `test setup should leave lazy visual candidates pending: ${JSON.stringify(result)}`);
487 assert.equal(result.overlayCount, 0, `remove should not allow lazy visual overlays to reappear: ${JSON.stringify(result)}`);
488 assert.equal(result.targetHasOverlay, false, `remove should clear stale target overlay refs: ${JSON.stringify(result)}`);
489 await page.close();
490 } finally {
491 await browser.close().catch(() => {});
492 }
493 });
494
495 it('extension mode reports async visual contrast errors to the panel', async () => {
496 const puppeteer = await import('puppeteer');
497 const browser = await puppeteer.default.launch({
498 headless: true,
499 args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
500 });
501 try {
502 const page = await browser.newPage();
503 await page.setViewport({ width: 1280, height: 800 });
504 await page.goto(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, { waitUntil: 'load' });
505 const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
506 await page.evaluate(() => {
507 document.documentElement.dataset.impeccableExtension = 'true';
508 window.__impeccableMessages = [];
509 window.addEventListener('message', event => {
510 if (event.source !== window || !event.data?.source?.startsWith('impeccable-')) return;
511 window.__impeccableMessages.push(event.data);
512 });
513 });
514 await page.evaluate(browserScript);
515 const result = await page.evaluate(async () => {
516 const originalGetContext = HTMLCanvasElement.prototype.getContext;
517 HTMLCanvasElement.prototype.getContext = function getContext() {
518 throw new Error('forced visual contrast canvas failure');
519 };
520 try {
521 window.postMessage({
522 source: 'impeccable-command',
523 action: 'scan',
524 config: {
525 visualContrast: true,
526 visualContrastMaxCandidates: 20,
527 },
528 }, '*');
529 const deadline = Date.now() + 1000;
530 while (
531 Date.now() < deadline &&
532 !window.__impeccableMessages.some(message => message.source === 'impeccable-error')
533 ) {
534 await new Promise(resolve => setTimeout(resolve, 25));
535 }
536 return {
537 ready: window.__impeccableMessages.some(message => message.source === 'impeccable-ready'),
538 results: window.__impeccableMessages.some(message => message.source === 'impeccable-results'),
539 errors: window.__impeccableMessages
540 .filter(message => message.source === 'impeccable-error')
541 .map(message => message.message || ''),
542 };
543 } finally {
544 HTMLCanvasElement.prototype.getContext = originalGetContext;
545 }
546 });
547 assert.equal(result.ready, true, `expected extension ready message, got: ${JSON.stringify(result)}`);
548 assert.equal(result.results, true, `expected initial sync results before async visual error, got: ${JSON.stringify(result)}`);
549 assert.ok(
550 result.errors.some(message => /forced visual contrast canvas failure/.test(message)),
551 `expected extension visual contrast error message, got: ${JSON.stringify(result)}`,
552 );
553 await page.close();
554 } finally {
555 await browser.close().catch(() => {});
556 }
557 });
558
559 it('browser API: impeccableDetect is pure, impeccableScan decorates', async () => {
560 const puppeteer = await import('puppeteer');
561 const browser = await puppeteer.default.launch({
562 headless: true,
563 args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
564 });
565 try {
566 const page = await browser.newPage();
567 await page.setViewport({ width: 1280, height: 800 });
568 await page.goto(`${baseUrl}/fixtures/antipatterns/quality.html`, { waitUntil: 'load' });
569 const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
570 await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
571 await page.evaluate(browserScript);
572 const pure = await page.evaluate(() => {
573 const before = document.querySelectorAll('.impeccable-overlay, .impeccable-label, .impeccable-banner').length;
574 const findings = window.impeccableDetect({ decorate: false, serialize: true });
575 const after = document.querySelectorAll('.impeccable-overlay, .impeccable-label, .impeccable-banner').length;
576 return { before, after, count: findings.length };
577 });
578 assert.equal(pure.before, 0);
579 assert.equal(pure.after, 0);
580 assert.ok(pure.count > 0);
581
582 const decorated = await page.evaluate(() => {
583 const groups = window.impeccableScan();
584 const overlays = document.querySelectorAll('.impeccable-overlay, .impeccable-label, .impeccable-banner').length;
585 return { groups: groups.length, overlays };
586 });
587 assert.ok(decorated.groups > 0);
588 assert.ok(decorated.overlays > 0);
589 await page.close();
590 } finally {
591 await browser.close().catch(() => {});
592 }
593 });
594
595 it('browser API: async scan and detect reject instead of throwing synchronously', async () => {
596 const puppeteer = await import('puppeteer');
597 const browser = await puppeteer.default.launch({
598 headless: true,
599 args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
600 });
601 try {
602 const page = await browser.newPage();
603 await page.setViewport({ width: 1280, height: 800 });
604 await page.goto(`${baseUrl}/fixtures/antipatterns/quality.html`, { waitUntil: 'load' });
605 const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
606 await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
607 await page.evaluate(browserScript);
608 const result = await page.evaluate(async () => {
609 const originalQuerySelectorAll = Document.prototype.querySelectorAll;
610 Document.prototype.querySelectorAll = function querySelectorAll() {
611 throw new Error('forced query failure');
612 };
613 try {
614 const scan = await window.impeccableScanAsync().then(
615 () => ({ state: 'resolved' }),
616 error => ({ state: 'rejected', message: error?.message || String(error) }),
617 );
618 const detect = await window.impeccableDetectAsync().then(
619 () => ({ state: 'resolved' }),
620 error => ({ state: 'rejected', message: error?.message || String(error) }),
621 );
622 return { scan, detect };
623 } finally {
624 Document.prototype.querySelectorAll = originalQuerySelectorAll;
625 }
626 });
627 assert.deepEqual(result.scan, { state: 'rejected', message: 'forced query failure' });
628 assert.deepEqual(result.detect, { state: 'rejected', message: 'forced query failure' });
629 await page.close();
630 } finally {
631 await browser.close().catch(() => {});
632 }
633 });
634
635 it('createBrowserDetector reuses a browser and honors waitUntil overrides', async () => {
636 const detector = await createBrowserDetector({ waitUntil: 'load', settleMs: 0 });
637 try {
638 const first = await detector.detectUrl(`${baseUrl}/fixtures/antipatterns/quality.html`);
639 const second = await detector.detectUrl(`${baseUrl}/fixtures/antipatterns/body-text-viewport-edge.html`, {
640 waitUntil: 'domcontentloaded',
641 });
642 assert.ok(first.some(r => r.antipattern === 'line-length'));
643 assert.equal(second.filter(r => r.antipattern === 'body-text-viewport-edge').length, 3);
644 } finally {
645 await detector.close();
646 }
647 });
648});