mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
Overlays are now created hidden and revealed by an IntersectionObserver (rootMargin: 99999px), so they automatically show/hide when their target becomes visible or invisible -- handles closed <details>, display:none, hidden modals, overflow:hidden clipping, etc. without polling. Adds overlay-positioning.html test fixture with 9 scenario groups covering transforms, closed details, sticky, overflow, position offsets, flex/grid, containing-block creators, and combinations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
267 lines
11 KiB
JavaScript
267 lines
11 KiB
JavaScript
/**
|
|
* Puppeteer-powered tests for the browser detection script.
|
|
* Verifies the browser visualizer finds the same anti-patterns as the CLI.
|
|
*
|
|
* These tests start a local server and use Puppeteer to load fixture pages,
|
|
* inject the browser script, and compare findings with the CLI's jsdom output.
|
|
*
|
|
* Requires: puppeteer (npx cache or npm install)
|
|
*/
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { spawn } from 'child_process';
|
|
import path from 'path';
|
|
import { detectHtml } from '../source/skills/critique/scripts/detect-antipatterns.mjs';
|
|
|
|
const FIXTURES = path.join(import.meta.dir, 'fixtures', 'antipatterns');
|
|
const PORT = 3099; // Use a different port to avoid conflicts with dev server
|
|
let serverProcess;
|
|
let puppeteer;
|
|
|
|
// Check if puppeteer is available
|
|
let hasPuppeteer = false;
|
|
try {
|
|
puppeteer = await import('puppeteer');
|
|
hasPuppeteer = true;
|
|
} catch {}
|
|
|
|
const describeIf = hasPuppeteer ? describe : describe.skip;
|
|
|
|
describeIf('browser script parity with CLI', () => {
|
|
beforeAll(async () => {
|
|
// Start a simple file server for fixtures + browser script
|
|
serverProcess = spawn('node', ['-e', `
|
|
const http = require('http');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const types = { '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript' };
|
|
http.createServer((req, res) => {
|
|
let filePath;
|
|
if (req.url.startsWith('/fixtures/')) {
|
|
filePath = path.join(${JSON.stringify(path.join(import.meta.dir))}, req.url);
|
|
} else if (req.url.startsWith('/js/')) {
|
|
// Check public/js/ first, then built artifacts
|
|
const basename = req.url.split('/').pop();
|
|
filePath = path.join(${JSON.stringify(path.join(import.meta.dir, '..', 'public'))}, req.url);
|
|
if (!fs.existsSync(filePath)) {
|
|
filePath = path.join(${JSON.stringify(path.join(import.meta.dir, '..', '.claude', 'skills', 'critique', 'scripts'))}, basename);
|
|
}
|
|
} else {
|
|
res.writeHead(404); res.end(); return;
|
|
}
|
|
try {
|
|
const content = fs.readFileSync(filePath);
|
|
const ext = path.extname(filePath);
|
|
res.writeHead(200, { 'Content-Type': types[ext] || 'text/plain' });
|
|
res.end(content);
|
|
} catch { res.writeHead(404); res.end(); }
|
|
}).listen(${PORT});
|
|
`], { stdio: 'ignore' });
|
|
|
|
// Wait for server to start
|
|
await new Promise(r => setTimeout(r, 500));
|
|
});
|
|
|
|
afterAll(() => {
|
|
if (serverProcess) serverProcess.kill();
|
|
});
|
|
|
|
async function scanWithBrowser(fixtureName) {
|
|
const browser = await puppeteer.default.launch({ headless: true });
|
|
const page = await browser.newPage();
|
|
await page.goto(`http://localhost:${PORT}/fixtures/antipatterns/${fixtureName}`, {
|
|
waitUntil: 'networkidle0',
|
|
timeout: 10000,
|
|
});
|
|
|
|
// Wait for the browser script to run (it uses setTimeout 100ms)
|
|
await new Promise(r => setTimeout(r, 300));
|
|
|
|
const results = await page.evaluate(() => {
|
|
if (!window.impeccableScan) return [];
|
|
const allFindings = window.impeccableScan();
|
|
return allFindings.flatMap(({ findings }) =>
|
|
findings.map(f => ({ type: f.type, detail: f.detail }))
|
|
);
|
|
});
|
|
|
|
await browser.close();
|
|
return results;
|
|
}
|
|
|
|
function getTypes(findings) {
|
|
return [...new Set(findings.map(f => f.antipattern || f.type))].sort();
|
|
}
|
|
|
|
test('should-flag.html: browser finds border anti-patterns', async () => {
|
|
const browserFindings = await scanWithBrowser('should-flag.html');
|
|
const types = [...new Set(browserFindings.map(f => f.type))];
|
|
expect(types).toContain('side-tab');
|
|
expect(types).toContain('border-accent-on-rounded');
|
|
}, 15000);
|
|
|
|
test('should-pass.html: browser finds no border anti-patterns', async () => {
|
|
const browserFindings = await scanWithBrowser('should-pass.html');
|
|
const borderFindings = browserFindings.filter(f =>
|
|
f.type === 'side-tab' || f.type === 'border-accent-on-rounded'
|
|
);
|
|
expect(borderFindings).toHaveLength(0);
|
|
}, 15000);
|
|
|
|
test('color-should-flag.html: browser finds color anti-patterns', async () => {
|
|
const browserFindings = await scanWithBrowser('color-should-flag.html');
|
|
const types = [...new Set(browserFindings.map(f => f.type))];
|
|
expect(types).toContain('low-contrast');
|
|
expect(types).toContain('gray-on-color');
|
|
}, 15000);
|
|
|
|
test('color-should-pass.html: browser finds zero findings', async () => {
|
|
const browserFindings = await scanWithBrowser('color-should-pass.html');
|
|
expect(browserFindings).toHaveLength(0);
|
|
}, 15000);
|
|
|
|
test('layout-should-flag.html: browser finds nested cards', async () => {
|
|
const browserFindings = await scanWithBrowser('layout-should-flag.html');
|
|
const nested = browserFindings.filter(f => f.type === 'nested-cards');
|
|
expect(nested.length).toBeGreaterThanOrEqual(3);
|
|
}, 15000);
|
|
|
|
test('layout-should-pass.html: browser finds no nested cards', async () => {
|
|
const browserFindings = await scanWithBrowser('layout-should-pass.html');
|
|
const nested = browserFindings.filter(f => f.type === 'nested-cards');
|
|
expect(nested).toHaveLength(0);
|
|
}, 15000);
|
|
|
|
test('typography-should-flag.html: browser finds typography issues', async () => {
|
|
const browserFindings = await scanWithBrowser('typography-should-flag.html');
|
|
const types = [...new Set(browserFindings.map(f => f.type))];
|
|
expect(types).toContain('overused-font');
|
|
expect(types).toContain('flat-type-hierarchy');
|
|
}, 15000);
|
|
|
|
test('partial-component.html: browser skips page-level checks', async () => {
|
|
const browserFindings = await scanWithBrowser('partial-component.html');
|
|
// Should find border issues but not typography
|
|
const hasBorder = browserFindings.some(f => f.type === 'side-tab');
|
|
const hasTypo = browserFindings.some(f =>
|
|
f.type === 'flat-type-hierarchy' || f.type === 'single-font'
|
|
);
|
|
expect(hasBorder).toBe(true);
|
|
// Browser script doesn't have isFullPage check, so we just verify borders work
|
|
}, 15000);
|
|
});
|
|
|
|
describeIf('overlay positioning accuracy', () => {
|
|
let browser, page;
|
|
const TOLERANCE = 6; // px tolerance for position comparison (2px border offset + rounding)
|
|
|
|
beforeAll(async () => {
|
|
// Reuse same server setup — these tests run in the same suite
|
|
serverProcess = spawn('node', ['-e', `
|
|
const http = require('http');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const types = { '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript' };
|
|
http.createServer((req, res) => {
|
|
let filePath;
|
|
if (req.url.startsWith('/fixtures/')) {
|
|
filePath = path.join(${JSON.stringify(path.join(import.meta.dir))}, req.url);
|
|
} else if (req.url.startsWith('/js/')) {
|
|
const basename = req.url.split('/').pop();
|
|
filePath = path.join(${JSON.stringify(path.join(import.meta.dir, '..', 'public'))}, req.url);
|
|
if (!fs.existsSync(filePath)) {
|
|
filePath = path.join(${JSON.stringify(path.join(import.meta.dir, '..', '.claude', 'skills', 'critique', 'scripts'))}, basename);
|
|
}
|
|
} else {
|
|
res.writeHead(404); res.end(); return;
|
|
}
|
|
try {
|
|
const content = fs.readFileSync(filePath);
|
|
const ext = path.extname(filePath);
|
|
res.writeHead(200, { 'Content-Type': types[ext] || 'text/plain' });
|
|
res.end(content);
|
|
} catch { res.writeHead(404); res.end(); }
|
|
}).listen(${PORT + 1});
|
|
`], { stdio: 'ignore' });
|
|
|
|
await new Promise(r => setTimeout(r, 500));
|
|
|
|
browser = await puppeteer.default.launch({ headless: true });
|
|
page = await browser.newPage();
|
|
await page.setViewport({ width: 1280, height: 800 });
|
|
await page.goto(`http://localhost:${PORT + 1}/fixtures/antipatterns/overlay-positioning.html`, {
|
|
waitUntil: 'networkidle0',
|
|
timeout: 10000,
|
|
});
|
|
await new Promise(r => setTimeout(r, 300));
|
|
await page.evaluate(() => {
|
|
if (window.impeccableScan) window.impeccableScan();
|
|
});
|
|
await new Promise(r => setTimeout(r, 200));
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (browser) await browser.close();
|
|
if (serverProcess) serverProcess.kill();
|
|
});
|
|
|
|
async function getOverlayPositions() {
|
|
return page.evaluate(() => {
|
|
const overlays = document.querySelectorAll('.impeccable-overlay:not(.impeccable-banner)');
|
|
return Array.from(overlays).map(o => {
|
|
const t = o._targetEl;
|
|
if (!t) return null;
|
|
const tRect = t.getBoundingClientRect();
|
|
const oRect = o.getBoundingClientRect();
|
|
const label = o.querySelector('.impeccable-label')?.textContent || '';
|
|
return {
|
|
label,
|
|
target: { top: tRect.top, left: tRect.left, width: tRect.width, height: tRect.height },
|
|
overlay: { top: oRect.top, left: oRect.left, width: oRect.width, height: oRect.height },
|
|
overlayHidden: o.style.display === 'none',
|
|
targetVisible: tRect.width > 0 && tRect.height > 0,
|
|
inClosedDetails: !!t.closest('details:not([open])'),
|
|
};
|
|
}).filter(Boolean);
|
|
});
|
|
}
|
|
|
|
test('visible overlays are positioned within tolerance of their targets', async () => {
|
|
const positions = await getOverlayPositions();
|
|
const visible = positions.filter(p => !p.overlayHidden);
|
|
expect(visible.length).toBeGreaterThan(0);
|
|
|
|
for (const p of visible) {
|
|
const topDiff = Math.abs(p.overlay.top - p.target.top);
|
|
const leftDiff = Math.abs(p.overlay.left - p.target.left);
|
|
const widthDiff = Math.abs(p.overlay.width - p.target.width);
|
|
const heightDiff = Math.abs(p.overlay.height - p.target.height);
|
|
|
|
expect(topDiff).toBeLessThanOrEqual(TOLERANCE);
|
|
expect(leftDiff).toBeLessThanOrEqual(TOLERANCE);
|
|
expect(widthDiff).toBeLessThanOrEqual(TOLERANCE);
|
|
expect(heightDiff).toBeLessThanOrEqual(TOLERANCE);
|
|
}
|
|
}, 20000);
|
|
|
|
test('overlays for non-rendered elements are hidden', async () => {
|
|
const positions = await getOverlayPositions();
|
|
// Elements inside closed <details> should have hidden overlays
|
|
const closedDetailsOverlays = positions.filter(p => p.inClosedDetails);
|
|
expect(closedDetailsOverlays.length).toBeGreaterThan(0);
|
|
for (const p of closedDetailsOverlays) {
|
|
expect(p.overlayHidden).toBe(true);
|
|
}
|
|
}, 20000);
|
|
|
|
test('overlays inside transform ancestors are accurately positioned', async () => {
|
|
const positions = await getOverlayPositions();
|
|
const visible = positions.filter(p => !p.overlayHidden);
|
|
for (const p of visible) {
|
|
const topDiff = Math.abs(p.overlay.top - p.target.top);
|
|
const leftDiff = Math.abs(p.overlay.left - p.target.left);
|
|
expect(topDiff).toBeLessThanOrEqual(TOLERANCE);
|
|
expect(leftDiff).toBeLessThanOrEqual(TOLERANCE);
|
|
}
|
|
}, 20000);
|
|
});
|