Files
pbakaus_impeccable/tests/detect-antipatterns-browser.test.mjs
T
672517f76e Add automatic design hook install and exceptions (#170)
* docs: add PRD for design detector hook integration

Plans a PostToolUse hook for Claude Code and Codex that runs the
existing design detector after every relevant file write and feeds
findings back to the agent as advisory system-reminder context. No
implementation in this commit; covers UX, technical design, build
pipeline changes, distribution, coverage tradeoffs, and rollout.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs: revise hook PRD with best-practices review

Folds in the P0/P1/P2 findings from an online best-practices critique
against the official Claude Code and Codex hook references plus 10+
2026 community guides and similar prior-art tools (claw-hooks,
claude-code-hooks-mastery).

Key changes:
- Exec form everywhere (Codex snippet was shell form), with Windows
  rationale.
- Default timeout dropped from 10s to 5s.
- Re-entrancy guard (CLAUDE_HOOK_DEPTH) and per-file edit counter.
- Session-scoped finding dedup promoted from open question to v1.
- Per-language inline-ignore syntax map (HTML/JSX/CSS/JS).
- Hard-skip rules for sensitive paths and generated/lock files.
- Honest framing about Claude Code lacking per-plugin hook disable.
- Honest framing about Bash-written files being invisible in v1.
- Codex Windows-not-supported call-out, feature flag note, trust ceremony detail.
- Optional NDJSON audit log via IMPECCABLE_HOOK_LOG.
- Findings cap lowered 8 → 5 with attention-budget rationale.
- Versioned envelope ([impeccable@1]) on rendered template.
- Expanded test plan, decision log, and stdin payload appendix.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(hooks): ship the design detector hook for Claude Code and Codex

Implements docs/hooks-prd.md: a PostToolUse hook that runs the
impeccable design detector after every Edit/Write/MultiEdit on a UI
file and pushes findings into the agent's next-turn context as a
short system reminder. Silent on clean files. Never blocks an edit.

Why this matters: today, design slop (side-tab borders, gradient
text, purple/cyan palettes, bounce easing, etc.) only gets caught
when a human notices or someone explicitly runs /impeccable audit.
The hook closes the loop at the moment slop is written.

What ships in v1
- skill/scripts/hook.mjs: PostToolUse entry. Reads stdin, runs the
  detector in-process (no `npx impeccable` cold start), emits
  hookSpecificOutput.additionalContext when fresh findings exist.
- skill/scripts/hook-lib.mjs: extracted helpers (config, cache,
  filter, render, audit log, runHook orchestrator). 100% unit-testable.
- skill/scripts/hook-session-start.mjs: SessionStart greeting,
  gated by a project-scannable probe + 30-day throttle.
- skill/scripts/hook-admin.mjs: backs /impeccable hooks
  on/off/status/ignore-rule/ignore-file/reset.

Hardening built in
- Re-entrancy guard (IMPECCABLE_HOOK_DEPTH) so the hook can never
  recursively spawn itself.
- Hard-skip regexes for sensitive paths (.env, .pem, id_rsa,
  secrets, credentials, .git) and generated/lock/build output. These
  fire before the file is even read; cannot be turned off via config.
- Path-traversal check on the inbound file_path.
- Session-scoped dedup keyed by (session, file, rule, line) so the
  same finding never lands in context twice. Prevents the ~12.5K
  wasted tokens per chatty session called out in the PRD.
- Per-(session, file) edit counter with a one-shot suppression
  notice on the 7th edit, silent after.
- Fail-open contract: every error path returns exit 0 with no
  stdout. Optional NDJSON audit log via IMPECCABLE_HOOK_LOG.

Three kill switches (precedence high to low):
1. IMPECCABLE_HOOK_DISABLED env var (1/true/yes/on, case-insensitive)
2. .impeccable/hook.json `enabled: false`
3. /impeccable hooks off slash command (writes the JSON)

Inline ignores are language-aware. `// impeccable: ignore <rule>` for
JS/TS, `<!-- impeccable: ignore <rule> -->` for HTML/Vue/Svelte/Astro,
`{/* impeccable: ignore <rule> */}` for JSX/TSX, `/* impeccable:
ignore <rule> */` for CSS. `*` matches any rule. Directive applies
to the next non-blank line. Same shape as ESLint, Stylelint, Biome.

Build pipeline
- scripts/lib/transformers/hooks.js: per-provider hooks.json
  builders, plus the slim .codex-plugin/plugin.json manifest.
- providers.js: emitHooks: 'claude' for claude-code, emitHooks:
  'codex' for codex and agents. Codex also emits emitCodexPlugin.
- factory.js: emits hooks/hooks.json next to the skills tree.
- build.js: syncs hooks/ into harness roots and into the slim
  plugin/ subtree; writes .codex-plugin/plugin.json. Build is
  idempotent (verified: 98 staged files unchanged across two runs).

Claude Code wiring uses exec form (command + args) and the
${CLAUDE_PLUGIN_ROOT} placeholder. Matcher: Edit|Write|MultiEdit.
`if:` glob filters to UI extensions before spawning Node. PostToolUse
timeout 5s, SessionStart timeout 3s.

Codex wiring uses ${PLUGIN_ROOT} (Codex's native placeholder),
matcher Edit|Write|apply_patch, no `if:` analog (the script does the
extension filter). macOS and Linux only; hooks are disabled on
Windows in current Codex builds. The trust ceremony and feature flag
are documented in README.md.

Routing
- /impeccable hooks lives outside the 23-command router table on
  purpose: it is plumbing, not a design skill. The hidden
  routing slot is added to SKILL.md alongside pin/unpin so the LLM
  knows to dispatch it. The 23-command count and all stale-count
  validators remain happy.

Tests
- tests/hook.test.mjs: 38 unit tests covering env parsing, config
  load + defaults + malformed, cache round-trip + GC,
  ignoreRules/minSeverity/inline ignores (all four languages),
  globbing with **/*/{a,b}, render template with cap + clamp + 0-line
  prefix drop, audit log NDJSON, payload event-name parameterization,
  re-entrancy, kill switches, sensitive-path + generated-path +
  traversal skips, allowlist filter, config ignoreFiles, edit
  counter cycle including the 7th-edit notice, MultiEdit and
  apply_patch payload shapes, detector throw swallow, malformed
  stdin, missing file race.
- tests/hook-build.test.mjs: 18 integration tests covering hook
  manifest shape (matcher, timeouts, exec form, if: glob, placeholders),
  Codex differences (${PLUGIN_ROOT}, no if:, no SessionStart),
  Codex plugin manifest (no inline hooks field to avoid the
  duplicate-file error), routing across the hooksJsonFor table, and
  presence of all three committed artifacts plus the bundled detector
  the runtime relative-import path depends on.

Full suite: 175 bun tests + 186 node tests, all green.

Docs
- README.md: new "Design hook" section explaining default behavior,
  per-project / global / inline disable paths, the JSON schema knobs,
  the audit log debug flag, and the slop / a11y coverage split.
- HARNESSES.md: flips the `hooks` row for Codex from No -> Yes
  (Claude was already Yes), adds a per-harness hook-surface table
  with the manifest location and matcher each provider uses.

Open questions from the PRD intentionally deferred to v2: Bash-write
blind spot, effort-aware suppression, Stop-hook session summary,
per-rule severity, async hook mode. None block v1.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix Codex hook scanning: apply_patch paths and co-located stylesheets

Parse file targets from Codex apply_patch command bodies, co-scan imported
and sibling CSS when UI components are edited, drop the git-sweep PostToolUse
group, and align Codex SessionStart manifest and trust docs with the official
hooks spec.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Gitignore hook session cache and drop local test HTML

Hook dedup/throttle state in .impeccable/hook.cache.json is per-project
runtime data like other .impeccable/ sidecars. Remove an untracked
bad-nested-flexbox scratch page from site/public/.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix Claude Code hook: drop Edit-only if filter so Write/MultiEdit fire

Claude's if permission rule binds to one tool name, so Edit(*.{…}) never
spawned the hook on Write or MultiEdit despite the matcher listing them.
Extension filtering now lives in hook-lib on both Claude and Codex.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Surface Cursor design findings via stop-hook followup

Replace dropped postToolUse additional_context with afterFileEdit recording
and a one-shot stop followup_message so anti-pattern nudges reach the agent.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix design hook packaging and scans

* Fix Cursor hook pending bucket fallback

* Fix Sass hook scan coverage

* Fix Cursor hook review findings

* Fix session start dead hook normalization

* Fix hook config and relative scan paths

* Remove SessionStart design hook

* Remove redundant afterFileEdit normalization

* Fix Cursor suppression and module style scans

* Fix sensitive path hook filter

* Fix disabled Cursor stop hook emission

* Refresh hook harness artifacts

* Fix Cursor hook manifest install

* Add hook ignore-value support

* Ignore hook runtime files locally

* Fix Codex plugin hook packaging

* fix: address PR review bot findings

Block numeric hook depth counters from re-entering.

Avoid following stylesheet imports from traversal-looking hook targets.

* fix: gate ignore-value suggestions by supported rules

Only render exact ignore-value commands when the same finding can be suppressed by ignoreValues.

* Package Codex plugin as hook-only

* Remove Codex plugin packaging

* Recover hook install probe plumbing

* Remove Codex hook packaging follow-up doc

* Remove extra hook docs and skill wording changes

* Install real design hooks via skills CLI

* Add provider hook smoke runner

* Fix Cursor hook delivery with preToolUse gate

* Simplify Cursor hook install to preToolUse

* Clarify confirmed hook exceptions

* Persist hook ignores in shared config

* Guard font hook exceptions

* Fix hook install after main rebase

* Fix hook scan target handling

* fix: address hook review findings

* Address hook review feedback

* Stabilize DeepSeek insert live fixture

* Fix Cursor hook Python shell write bypass

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 21:19:19 -07:00

791 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Puppeteer-backed fixture tests for browser-only detection rules.
*
* Some detection rules (cramped-padding, line-length, body-text-viewport-edge)
* need real browser layout — they read getBoundingClientRect and real
* getComputedStyle results that the static HTML/CSS engine intentionally
* does not invent.
*
* This file uses detectUrl() (Puppeteer) to load fixtures in headless Chrome
* via a temporary static HTTP server, so the fixtures can use absolute
* <script src="/js/..."> paths just like in development.
*
* Run via Node's built-in test runner:
* node --test tests/detect-antipatterns-browser.test.mjs
*/
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createBrowserDetector, detectUrl } from '../cli/engine/detect-antipatterns.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
};
let server;
let baseUrl;
before(async () => {
// Static server: maps /fixtures/* to tests/fixtures/* and
// /js/detect-antipatterns-browser.js to cli/engine/detect-antipatterns-browser.js
// (mirrors what Astro serves so fixtures can use absolute paths)
server = http.createServer((req, res) => {
let filePath;
if (req.url.startsWith('/fixtures/')) {
filePath = path.join(ROOT, 'tests', req.url);
} else if (req.url === '/js/detect-antipatterns-browser.js') {
filePath = path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js');
} else {
res.writeHead(404).end();
return;
}
try {
const body = fs.readFileSync(filePath);
const ext = path.extname(filePath);
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
res.end(body);
} catch {
res.writeHead(404).end();
}
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
server.off('error', reject);
baseUrl = `http://127.0.0.1:${server.address().port}`;
resolve();
});
});
});
after(async () => {
if (server?.listening) await new Promise((resolve) => server.close(resolve));
});
describe('detectUrl — browser-only fixtures', () => {
// Only two rules genuinely need real browser layout (getBoundingClientRect):
// line-length → reads rect.width to compute chars-per-line
// cramped-padding → reads rect.width/height to filter small badges
// Everything else in the quality.html fixture runs in static HTML/CSS and is asserted
// by tests/detect-antipatterns-fixtures.test.mjs.
it('cramped-padding: flag column triggers all 8 cramped cases, pass column adds none', async () => {
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/cramped-padding.html`);
const cramped = f.filter(r => r.antipattern === 'cramped-padding');
// Flag column has 8 cases that should fire under the asymmetric
// proportional rule (vertical: max(4, fs×0.3), horizontal: max(8, fs×0.5)):
// 1. 14px body / 4px all sides — V fail
// 2. 14px body / 2px all sides — both fail
// 3. 16px body / 4px all sides — both fail
// 4. 14px body / 1px V / 16px H — V fail
// 5. 14px body / 12px V / 4px H — H fail
// 6. 24px heading / 8px all sides — H fail (improvement over old 8px floor)
// 7. 32px hero / 6px V / 16px H — V fail
// 8. 14px <pre> / 2px all sides — both fail
// Pass column has 13 cases (small pills, inline code, standard cards, code blocks,
// buttons, inputs, big text with proportional padding) — none should fire.
assert.equal(cramped.length, 8, `expected 8 cramped-padding findings, got ${cramped.length}`);
});
it('cramped-padding wrapper: skips same-surface wrappers, full-bleed marquees, and inset inner text surfaces', async () => {
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/flush-against-border.html`);
const cramped = f.filter(r => r.antipattern === 'cramped-padding');
const snippets = cramped.map(r => r.snippet || '').join('\n');
for (const cls of ['flag-frameworks', 'flag-card-borders', 'flag-bg-only', 'flag-outline-only', 'flag-asym-leftflush']) {
assert.match(snippets, new RegExp(`"${cls}"`), `expected ".${cls}" to be flagged`);
}
for (const cls of ['pass-same-bg-child', 'pass-marquee-shell', 'pass-inner-text-surface']) {
assert.doesNotMatch(snippets, new RegExp(`"${cls}"`), `".${cls}" should not be flagged`);
}
});
it('line-length: flag column triggers, pass column adds none', async () => {
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/quality.html`);
assert.equal(f.filter(r => r.antipattern === 'line-length').length, 1);
});
it('clipped-overflow-container: utility-named popovers still flag when clipped', async () => {
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/clipped-overflow-container.html`);
const snippets = f
.filter(r => r.antipattern === 'clipped-overflow-container')
.map(r => r.snippet || '')
.join('\n');
assert.match(snippets, /flag-shadow-utility/, 'shadow-lg utility surfaces must not be skipped as decorative');
assert.match(snippets, /flag-overlay-surface/, 'overlay-named content surfaces must not be skipped as decorative');
assert.doesNotMatch(snippets, /pass-contained-overlay/, 'aria-hidden decorative overlays should remain skipped');
});
it('oversized-h1: requires the headline to dominate the viewport, not just be large', async () => {
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/oversized-h1-browser.html`);
const hits = f.filter(r => r.antipattern === 'oversized-h1');
assert.equal(
hits.length,
1,
`expected exactly one oversized-h1 finding, got ${hits.length}: ${hits.map(r => r.snippet).join('; ')}`,
);
assert.match(hits[0].snippet, /sprawls across the whole/i);
assert.equal(
hits.some(r => /missing design vocabulary/i.test(r.snippet || '')),
false,
'a large two-line homepage-style h1 must not flag unless it dominates the viewport',
);
});
it('typography side-by-side: element-level flag cases get regular overlays', async () => {
const puppeteer = await import('puppeteer');
const browser = await puppeteer.default.launch({
headless: true,
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(`${baseUrl}/fixtures/antipatterns/typography.html`, { waitUntil: 'load' });
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
await page.evaluate(browserScript);
const result = await page.evaluate(() => {
const groups = window.impeccableScan();
const types = groups.flatMap(group => group.findings.map(finding => finding.type || finding.id));
return {
types,
pageTypes: groups
.filter(group => group.el === document.body || group.el === document.documentElement)
.flatMap(group => group.findings.map(finding => finding.type || finding.id)),
hasBanner: Boolean(document.querySelector('.impeccable-banner')),
overlays: document.querySelectorAll('.impeccable-overlay:not(.impeccable-banner)').length,
};
});
for (const id of ['tight-leading', 'tiny-text', 'all-caps-body', 'wide-tracking', 'justified-text']) {
assert.ok(result.types.includes(id), `expected browser typography scan to include ${id}: ${JSON.stringify(result)}`);
}
assert.ok(result.pageTypes.includes('overused-font'), `expected browser typography scan to include page-level overused-font: ${JSON.stringify(result)}`);
assert.equal(result.hasBanner, true, `expected page-level typography banner: ${JSON.stringify(result)}`);
assert.ok(result.overlays >= 5, `expected visible typography overlays, got: ${JSON.stringify(result)}`);
await page.close();
} finally {
await browser.close().catch(() => {});
}
});
it('overused-font: hook inline-ignore comments do not suppress browser findings', async () => {
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/hook-inline-ignore.html`);
assert.ok(
f.some(r => r.antipattern === 'overused-font' || r.type === 'overused-font' || r.id === 'overused-font'),
`expected browser scan to include overused-font despite inline comments: ${JSON.stringify(f)}`,
);
});
it('body-text-viewport-edge: 3 flag paragraphs/list-items, 0 pass cases', async () => {
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/body-text-viewport-edge.html`);
const edges = f.filter(r => r.antipattern === 'body-text-viewport-edge');
// Fixture has 3 escape-styled <p>/<li> paragraphs that bleed to
// the viewport edges. The pass column has 5 paragraphs that
// should not fire (centered container, inside nav, inside header,
// inside section with own background, short label < 40 chars).
assert.equal(edges.length, 3, `expected 3 body-text-viewport-edge findings, got ${edges.length}: ${JSON.stringify(edges.map(e => e.snippet))}`);
});
it('text-overflow: flags content wider than its box, skips real scroll regions', async () => {
// Browser-only: needs scrollWidth vs clientWidth from real layout.
// Flag column: a nowrap line and an unbreakable token spilling past a
// fixed-width box (overflow visible). Pass column: a genuine
// overflow-x:auto scroll region, a <pre>, normally wrapping text, a long
// line living inside a scroll ancestor, and sr-only accessible text.
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/text-overflow.html`);
const hits = f.filter(r => r.antipattern === 'text-overflow');
const flagged = new Set();
for (const r of hits) {
const m = (r.snippet || '').match(/\.(flag-[\w-]+|pass-[\w-]+)/);
if (m) flagged.add(m[1]);
}
assert.ok(flagged.has('flag-nowrap'), 'expected the nowrap overflow case to flag');
assert.ok(flagged.has('flag-longword'), 'expected the unbreakable-token overflow case to flag');
for (const cls of [
'pass-scroll',
'pass-pre',
'pass-wrap',
'pass-inside-scroll',
'pass-sr-only-clip-path',
'pass-sr-only-legacy',
'pass-sr-only-tiny-hidden',
'pass-sr-only-clipped-wide',
'pass-hidden-slide-overflow',
]) {
assert.ok(!flagged.has(cls), `".${cls}" should NOT be flagged as text-overflow`);
}
assert.equal(hits.length, 2, `expected exactly 2 text-overflow findings, got ${hits.length}: ${JSON.stringify(hits.map(h => h.snippet))}`);
});
it('visual contrast: browser fallback catches low contrast on image backgrounds', async () => {
const analyticOnly = await detectUrl(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, {
waitUntil: 'load',
visualContrast: false,
});
assert.equal(
analyticOnly.some(r => r.antipattern === 'low-contrast' && /White text on light image/i.test(r.snippet || '')),
false,
'analytic contrast should not guess image-background contrast',
);
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, {
waitUntil: 'load',
visualContrast: true,
visualContrastMaxCandidates: 20,
});
const visualFindings = f.filter(r =>
r.antipattern === 'low-contrast' &&
/(?:browser|pixel) contrast/i.test(r.snippet || '')
);
assert.equal(
visualFindings.length,
4,
`expected 4 visual contrast findings, got ${visualFindings.length}: ${JSON.stringify(visualFindings.map(r => r.snippet))}`,
);
assert.ok(
f.some(r =>
r.antipattern === 'low-contrast' &&
/(?:browser|pixel) contrast/i.test(r.snippet || '') &&
/White text on light image/i.test(r.snippet || '')
),
`expected visual contrast finding for light image background, got: ${JSON.stringify(f.map(r => r.snippet))}`,
);
assert.ok(
f.some(r => r.antipattern === 'low-contrast' && /Dark text on dark image/i.test(r.snippet || '')),
'expected pixel contrast finding for dark text on dark image',
);
assert.ok(
f.some(r => r.antipattern === 'low-contrast' && /Translucent white text on a pale pattern/i.test(r.snippet || '')),
'expected pixel contrast finding for translucent text on pale pattern',
);
assert.ok(
f.some(r => r.antipattern === 'low-contrast' && /Muted gray text on a misty image/i.test(r.snippet || '')),
'expected pixel contrast finding for muted gray text on misty image',
);
assert.equal(
f.some(r => r.antipattern === 'low-contrast' && /White text on dark image/i.test(r.snippet || '')),
false,
'dark image background should keep enough contrast',
);
assert.equal(
f.some(r => r.antipattern === 'low-contrast' && /Dark text on light image/i.test(r.snippet || '')),
false,
'light image with dark text should keep enough contrast',
);
assert.equal(
f.some(r => r.antipattern === 'low-contrast' && /Hidden mockup text/i.test(r.snippet || '')),
false,
'aria-hidden decorative mockups should not produce visual contrast findings',
);
assert.equal(
f.some(r => r.antipattern === 'low-contrast' && /Should (?:flag|pass) after pixel sampling/i.test(r.snippet || '')),
false,
'fixture column headings should not be low-contrast findings',
);
});
it('browser API: visual contrast fallback resolves readable image backgrounds without overlays', async () => {
const puppeteer = await import('puppeteer');
const browser = await puppeteer.default.launch({
headless: true,
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, { waitUntil: 'load' });
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
await page.evaluate(browserScript);
const result = await page.evaluate(async () => {
const before = document.querySelectorAll('.impeccable-overlay, .impeccable-label, .impeccable-banner').length;
const analyses = await window.impeccableAnalyzeVisualContrast({ maxCandidates: 20, scrollOffscreen: true });
const after = document.querySelectorAll('.impeccable-overlay, .impeccable-label, .impeccable-banner').length;
return {
before,
after,
failed: analyses.filter(item => item.status === 'fail').map(item => item.finding?.snippet || ''),
passed: analyses.filter(item => item.status === 'pass').map(item => item.text || ''),
unresolved: analyses.filter(item => item.status === 'unresolved').map(item => item.reason || ''),
};
});
assert.equal(result.before, 0);
assert.equal(result.after, 0);
assert.equal(result.failed.length, 4, `expected 4 browser visual failures, got: ${JSON.stringify(result)}`);
assert.ok(result.failed.some(snippet => /White text on light image/i.test(snippet)));
assert.ok(result.failed.some(snippet => /Dark text on dark image/i.test(snippet)));
assert.ok(result.failed.every(snippet => /browser contrast/i.test(snippet)));
assert.ok(result.passed.some(text => /White text on dark image/i.test(text)));
assert.ok(result.passed.some(text => /Dark text on light image/i.test(text)));
} finally {
await browser.close().catch(() => {});
}
});
it('browser API: visual contrast scan decorates visible findings without scrolling by default', async () => {
const puppeteer = await import('puppeteer');
const browser = await puppeteer.default.launch({
headless: true,
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
});
try {
const page = await browser.newPage();
// Keep three failing visual-contrast cards in the no-scroll viewport;
// the offscreen cases are covered by the scrollOffscreen test above.
await page.setViewport({ width: 1280, height: 1000 });
await page.goto(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, { waitUntil: 'load' });
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
await page.evaluate(browserScript);
const result = await page.evaluate(async () => {
let scrollEvents = 0;
let maxScrollY = window.scrollY;
window.addEventListener('scroll', () => {
scrollEvents += 1;
maxScrollY = Math.max(maxScrollY, window.scrollY);
}, { passive: true });
const syncScanResult = window.impeccableScan({
visualContrast: true,
visualContrastMaxCandidates: 20,
});
const syncDetectResult = window.impeccableDetect({
visualContrast: true,
serialize: true,
});
const groups = await window.impeccableScanAsync({
visualContrast: true,
visualContrastMaxCandidates: 20,
});
await new Promise(resolve => setTimeout(resolve, 50));
return {
groups: groups.map(group => ({
text: group.el.textContent || '',
types: group.findings.map(finding => finding.type || finding.id),
})),
overlays: document.querySelectorAll('.impeccable-overlay:not(.impeccable-banner)').length,
labels: document.querySelectorAll('.impeccable-label').length,
analyses: window.impeccableGetLastVisualContrastAnalyses().filter(item => item.status === 'fail').length,
scrollEvents,
maxScrollY,
finalScrollY: window.scrollY,
syncScanIsArray: Array.isArray(syncScanResult),
syncDetectIsArray: Array.isArray(syncDetectResult),
hasAsyncApi: typeof window.impeccableScanAsync === 'function' && typeof window.impeccableDetectAsync === 'function',
};
});
const visualGroups = result.groups.filter(group =>
group.types.includes('low-contrast') &&
/(?:White text on light image|Dark text on dark image|Translucent white text|Muted gray text)/i.test(group.text)
);
assert.equal(result.analyses, 3, `expected 3 viewport visual failures, got: ${JSON.stringify(result)}`);
assert.equal(visualGroups.length, 3, `expected 3 viewport visual groups, got: ${JSON.stringify(result)}`);
assert.ok(result.overlays >= 3, `expected regular overlays for visible visual findings, got: ${JSON.stringify(result)}`);
assert.ok(result.labels >= 3, `expected regular labels for visible visual findings, got: ${JSON.stringify(result)}`);
assert.equal(result.maxScrollY, 0, `visual scan should not scroll the page by default: ${JSON.stringify(result)}`);
assert.equal(result.finalScrollY, 0, `visual scan should preserve scroll by default: ${JSON.stringify(result)}`);
assert.equal(result.syncScanIsArray, true, `impeccableScan should keep a synchronous Array return: ${JSON.stringify(result)}`);
assert.equal(result.syncDetectIsArray, true, `impeccableDetect should keep a synchronous Array return: ${JSON.stringify(result)}`);
assert.equal(result.hasAsyncApi, true, `visual contrast should expose explicit async APIs: ${JSON.stringify(result)}`);
const refreshedOverlayResult = await page.evaluate(async () => {
window.scrollTo(0, 0);
const target = [...document.querySelectorAll('p')]
.find(node => /White text on light image should be sampled/i.test(node.textContent || ''));
target.style.fontSize = '10px';
const initialGroups = window.impeccableScan({
visualContrast: true,
visualContrastMaxCandidates: 20,
});
const initialTargetGroup = initialGroups.find(group => group.el === target);
const deadline = Date.now() + 1000;
while (
Date.now() < deadline &&
!/low contrast/i.test(target?._impeccableOverlay?.textContent || '')
) {
const nextButton = target?._impeccableOverlay?.querySelector('button:last-of-type');
if (nextButton) nextButton.click();
await new Promise(resolve => setTimeout(resolve, 25));
}
const labelVariants = [];
const overlay = target?._impeccableOverlay;
for (let i = 0; i < 3; i++) {
labelVariants.push(overlay?.textContent || '');
overlay?.querySelector('button:last-of-type')?.click();
await new Promise(resolve => setTimeout(resolve, 0));
}
return {
initialTypes: initialTargetGroup?.findings.map(finding => finding.type || finding.id) || [],
labelText: target?._impeccableOverlay?.textContent || '',
labelVariants,
overlayConnected: Boolean(target?._impeccableOverlay?.isConnected),
};
});
assert.ok(refreshedOverlayResult.initialTypes.includes('tiny-text'), `test setup should create an initial sync overlay on the target: ${JSON.stringify(refreshedOverlayResult)}`);
assert.ok(refreshedOverlayResult.labelVariants.some(text => /tiny body text/i.test(text)), `expected refreshed overlay to keep the sync finding label: ${JSON.stringify(refreshedOverlayResult)}`);
assert.ok(refreshedOverlayResult.labelVariants.some(text => /low contrast/i.test(text)), `expected visual contrast to refresh the existing overlay label: ${JSON.stringify(refreshedOverlayResult)}`);
assert.equal(refreshedOverlayResult.overlayConnected, true, `expected refreshed overlay to stay connected: ${JSON.stringify(refreshedOverlayResult)}`);
const lazyResult = await page.evaluate(async () => {
const target = [...document.querySelectorAll('p')]
.find(node => /Muted gray text on a misty image/i.test(node.textContent || ''));
target?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
await new Promise(resolve => setTimeout(resolve, 250));
return {
overlays: document.querySelectorAll('.impeccable-overlay:not(.impeccable-banner)').length,
labels: document.querySelectorAll('.impeccable-label').length,
analyses: window.impeccableGetLastVisualContrastAnalyses().filter(item => item.status === 'fail').length,
targetHasOverlay: Boolean(target?._impeccableOverlay),
scrollY: window.scrollY,
};
});
assert.equal(lazyResult.analyses, 4, `expected lazy visual resolution after scrolling into view, got: ${JSON.stringify(lazyResult)}`);
assert.ok(lazyResult.overlays >= 4, `expected lazy visual overlay after scrolling into view, got: ${JSON.stringify(lazyResult)}`);
assert.ok(lazyResult.labels >= 4, `expected lazy visual label after scrolling into view, got: ${JSON.stringify(lazyResult)}`);
assert.equal(lazyResult.targetHasOverlay, true, `expected lazy visual target to get a regular overlay, got: ${JSON.stringify(lazyResult)}`);
assert.ok(lazyResult.scrollY > 0, `test should have naturally scrolled to the offscreen case: ${JSON.stringify(lazyResult)}`);
const staleOverlayResult = await page.evaluate(async () => {
const target = [...document.querySelectorAll('p')]
.find(node => /Muted gray text on a misty image/i.test(node.textContent || ''));
window.scrollTo(0, 0);
await new Promise(resolve => setTimeout(resolve, 50));
await window.impeccableScanAsync({
visualContrast: true,
visualContrastMaxCandidates: 20,
});
const staleCleared = !target?._impeccableOverlay;
target?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
await new Promise(resolve => setTimeout(resolve, 250));
return {
staleCleared,
targetHasOverlay: Boolean(target?._impeccableOverlay),
targetOverlayConnected: Boolean(target?._impeccableOverlay?.isConnected),
overlays: document.querySelectorAll('.impeccable-overlay:not(.impeccable-banner)').length,
analyses: window.impeccableGetLastVisualContrastAnalyses().filter(item => item.status === 'fail').length,
};
});
assert.equal(staleOverlayResult.staleCleared, true, `expected clearOverlays to remove stale target overlay refs, got: ${JSON.stringify(staleOverlayResult)}`);
assert.equal(staleOverlayResult.targetHasOverlay, true, `expected lazy visual target to be highlightable after a rescan, got: ${JSON.stringify(staleOverlayResult)}`);
assert.equal(staleOverlayResult.targetOverlayConnected, true, `expected lazy visual overlay after rescan to be connected, got: ${JSON.stringify(staleOverlayResult)}`);
const offscreenResult = await page.evaluate(async () => {
window.scrollTo(0, 0);
let maxScrollY = window.scrollY;
window.addEventListener('scroll', () => {
maxScrollY = Math.max(maxScrollY, window.scrollY);
}, { passive: true });
const groups = await window.impeccableScanAsync({
visualContrast: true,
visualContrastMaxCandidates: 20,
visualContrastScrollOffscreen: true,
});
await new Promise(resolve => setTimeout(resolve, 50));
return {
groups: groups.map(group => ({
text: group.el.textContent || '',
types: group.findings.map(finding => finding.type || finding.id),
})),
analyses: window.impeccableGetLastVisualContrastAnalyses().filter(item => item.status === 'fail').length,
maxScrollY,
finalScrollY: window.scrollY,
};
});
const offscreenVisualGroups = offscreenResult.groups.filter(group =>
group.types.includes('low-contrast') &&
/(?:White text on light image|Dark text on dark image|Translucent white text|Muted gray text)/i.test(group.text)
);
assert.equal(offscreenResult.analyses, 4, `expected 4 opt-in visual failures, got: ${JSON.stringify(offscreenResult)}`);
assert.equal(offscreenVisualGroups.length, 4, `expected 4 opt-in visual groups, got: ${JSON.stringify(offscreenResult)}`);
assert.ok(offscreenResult.maxScrollY > 0, `offscreen opt-in should be allowed to scroll: ${JSON.stringify(offscreenResult)}`);
assert.equal(offscreenResult.finalScrollY, 0, `offscreen opt-in should restore scroll: ${JSON.stringify(offscreenResult)}`);
} finally {
await browser.close().catch(() => {});
}
});
it('extension mode remove cancels pending lazy visual contrast work', async () => {
const puppeteer = await import('puppeteer');
const browser = await puppeteer.default.launch({
headless: true,
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, { waitUntil: 'load' });
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
await page.evaluate(() => {
document.documentElement.dataset.impeccableExtension = 'true';
window.__impeccableMessages = [];
window.addEventListener('message', event => {
if (event.source !== window || !event.data?.source?.startsWith('impeccable-')) return;
window.__impeccableMessages.push(event.data);
});
});
await page.evaluate(browserScript);
const result = await page.evaluate(async () => {
window.postMessage({
source: 'impeccable-command',
action: 'scan',
config: {
visualContrast: true,
visualContrastMaxCandidates: 20,
},
}, '*');
const scanDeadline = Date.now() + 1000;
while (
Date.now() < scanDeadline &&
!window.impeccableGetLastVisualContrastAnalyses()
.some(item => item.status === 'unresolved' && item.reason === 'text outside viewport')
) {
await new Promise(resolve => setTimeout(resolve, 25));
}
const unresolvedBeforeRemove = window.impeccableGetLastVisualContrastAnalyses()
.filter(item => item.status === 'unresolved' && item.reason === 'text outside viewport').length;
window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
await new Promise(resolve => setTimeout(resolve, 50));
const target = [...document.querySelectorAll('p')]
.find(node => /Muted gray text on a misty image/i.test(node.textContent || ''));
target?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
await new Promise(resolve => setTimeout(resolve, 300));
const resultsAfterRemove = window.__impeccableMessages
.filter(message => message.source === 'impeccable-results').length;
return {
unresolvedBeforeRemove,
overlayCount: document.querySelectorAll('.impeccable-overlay').length,
targetHasOverlay: Boolean(target?._impeccableOverlay),
resultsAfterRemove,
};
});
assert.ok(result.unresolvedBeforeRemove > 0, `test setup should leave lazy visual candidates pending: ${JSON.stringify(result)}`);
assert.equal(result.overlayCount, 0, `remove should not allow lazy visual overlays to reappear: ${JSON.stringify(result)}`);
assert.equal(result.targetHasOverlay, false, `remove should clear stale target overlay refs: ${JSON.stringify(result)}`);
await page.close();
} finally {
await browser.close().catch(() => {});
}
});
it('extension mode reports async visual contrast errors to the panel', async () => {
const puppeteer = await import('puppeteer');
const browser = await puppeteer.default.launch({
headless: true,
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, { waitUntil: 'load' });
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
await page.evaluate(() => {
document.documentElement.dataset.impeccableExtension = 'true';
window.__impeccableMessages = [];
window.addEventListener('message', event => {
if (event.source !== window || !event.data?.source?.startsWith('impeccable-')) return;
window.__impeccableMessages.push(event.data);
});
});
await page.evaluate(browserScript);
const result = await page.evaluate(async () => {
const originalGetContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function getContext() {
throw new Error('forced visual contrast canvas failure');
};
try {
window.postMessage({
source: 'impeccable-command',
action: 'scan',
config: {
visualContrast: true,
visualContrastMaxCandidates: 20,
},
}, '*');
const deadline = Date.now() + 1000;
while (
Date.now() < deadline &&
!window.__impeccableMessages.some(message => message.source === 'impeccable-error')
) {
await new Promise(resolve => setTimeout(resolve, 25));
}
return {
ready: window.__impeccableMessages.some(message => message.source === 'impeccable-ready'),
results: window.__impeccableMessages.some(message => message.source === 'impeccable-results'),
errors: window.__impeccableMessages
.filter(message => message.source === 'impeccable-error')
.map(message => message.message || ''),
};
} finally {
HTMLCanvasElement.prototype.getContext = originalGetContext;
}
});
assert.equal(result.ready, true, `expected extension ready message, got: ${JSON.stringify(result)}`);
assert.equal(result.results, true, `expected initial sync results before async visual error, got: ${JSON.stringify(result)}`);
assert.ok(
result.errors.some(message => /forced visual contrast canvas failure/.test(message)),
`expected extension visual contrast error message, got: ${JSON.stringify(result)}`,
);
await page.close();
} finally {
await browser.close().catch(() => {});
}
});
it('extension mode echoes scan ids on result messages', async () => {
const puppeteer = await import('puppeteer');
const browser = await puppeteer.default.launch({
headless: true,
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(`${baseUrl}/fixtures/antipatterns/should-pass.html`, { waitUntil: 'load' });
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
await page.evaluate(() => {
document.documentElement.dataset.impeccableExtension = 'true';
window.__impeccableMessages = [];
window.addEventListener('message', event => {
if (event.source !== window || !event.data?.source?.startsWith('impeccable-')) return;
window.__impeccableMessages.push(event.data);
});
});
await page.evaluate(browserScript);
const result = await page.evaluate(async () => {
window.postMessage({
source: 'impeccable-command',
action: 'scan',
config: { scanId: 'scan-2' },
}, '*');
const deadline = Date.now() + 1000;
while (
Date.now() < deadline &&
!window.__impeccableMessages.some(message =>
message.source === 'impeccable-results' &&
message.scanId === 'scan-2'
)
) {
await new Promise(resolve => setTimeout(resolve, 25));
}
const resultMessage = window.__impeccableMessages.find(message =>
message.source === 'impeccable-results' &&
message.scanId === 'scan-2'
);
return {
ready: window.__impeccableMessages.some(message => message.source === 'impeccable-ready'),
scanId: resultMessage?.scanId || null,
count: resultMessage?.count ?? null,
};
});
assert.equal(result.ready, true, `expected extension ready message, got: ${JSON.stringify(result)}`);
assert.equal(result.scanId, 'scan-2', `expected scan id echo in results, got: ${JSON.stringify(result)}`);
assert.equal(result.count, 0, `expected clean fixture to have no findings, got: ${JSON.stringify(result)}`);
await page.close();
} finally {
await browser.close().catch(() => {});
}
});
it('browser API: impeccableDetect is pure, impeccableScan decorates', async () => {
const puppeteer = await import('puppeteer');
const browser = await puppeteer.default.launch({
headless: true,
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(`${baseUrl}/fixtures/antipatterns/quality.html`, { waitUntil: 'load' });
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
await page.evaluate(browserScript);
const pure = await page.evaluate(() => {
const before = document.querySelectorAll('.impeccable-overlay, .impeccable-label, .impeccable-banner').length;
const findings = window.impeccableDetect({ decorate: false, serialize: true });
const after = document.querySelectorAll('.impeccable-overlay, .impeccable-label, .impeccable-banner').length;
return { before, after, count: findings.length };
});
assert.equal(pure.before, 0);
assert.equal(pure.after, 0);
assert.ok(pure.count > 0);
const decorated = await page.evaluate(() => {
const groups = window.impeccableScan();
const overlays = document.querySelectorAll('.impeccable-overlay, .impeccable-label, .impeccable-banner').length;
return { groups: groups.length, overlays };
});
assert.ok(decorated.groups > 0);
assert.ok(decorated.overlays > 0);
await page.close();
} finally {
await browser.close().catch(() => {});
}
});
it('browser API: async scan and detect reject instead of throwing synchronously', async () => {
const puppeteer = await import('puppeteer');
const browser = await puppeteer.default.launch({
headless: true,
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(`${baseUrl}/fixtures/antipatterns/quality.html`, { waitUntil: 'load' });
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
await page.evaluate(browserScript);
const result = await page.evaluate(async () => {
const originalQuerySelectorAll = Document.prototype.querySelectorAll;
Document.prototype.querySelectorAll = function querySelectorAll() {
throw new Error('forced query failure');
};
try {
const scan = await window.impeccableScanAsync().then(
() => ({ state: 'resolved' }),
error => ({ state: 'rejected', message: error?.message || String(error) }),
);
const detect = await window.impeccableDetectAsync().then(
() => ({ state: 'resolved' }),
error => ({ state: 'rejected', message: error?.message || String(error) }),
);
return { scan, detect };
} finally {
Document.prototype.querySelectorAll = originalQuerySelectorAll;
}
});
assert.deepEqual(result.scan, { state: 'rejected', message: 'forced query failure' });
assert.deepEqual(result.detect, { state: 'rejected', message: 'forced query failure' });
await page.close();
} finally {
await browser.close().catch(() => {});
}
});
it('createBrowserDetector reuses a browser and honors waitUntil overrides', async () => {
const detector = await createBrowserDetector({ waitUntil: 'load', settleMs: 0 });
try {
const first = await detector.detectUrl(`${baseUrl}/fixtures/antipatterns/quality.html`);
const second = await detector.detectUrl(`${baseUrl}/fixtures/antipatterns/body-text-viewport-edge.html`, {
waitUntil: 'domcontentloaded',
});
assert.ok(first.some(r => r.antipattern === 'line-length'));
assert.equal(second.filter(r => r.antipattern === 'body-text-viewport-edge').length, 3);
} finally {
await detector.close();
}
});
});