detect-antipatterns-browser.test.mjs

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