mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-15 23:56:29 +03:00
* 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>
791 lines
40 KiB
JavaScript
791 lines
40 KiB
JavaScript
/**
|
||
* 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();
|
||
}
|
||
});
|
||
});
|