Files
pbakaus_impeccable/crates/browser/src/snapshot_engine.rs
T
Paul BakausandClaude Fable 5.1 1d214f1e48 Add detector.ignoreSelectors: one component-level opt-out instead of an attribute per instance
An author who wants a rule off for one component has had two choices: put
`data-impeccable-ignore` on every instance, or silence the rule (or the
file) for the whole project. On impeccable-site #34 that meant eleven
attributes on eleven copies of the same 10px label for `undersized-ui-text`,
25 opt-out attributes in all. The count is the problem: the markup carries
noise, and the reviewer never sees how much was waived.

`detector.ignoreSelectors` is the declared twin of that attribute. One entry,
`{ rule, selector }`, waives the rule for every element the selector matches
and for that element's subtree, the same waiver the attribute grants the
element carrying it:

    impeccable ignores add-selector undersized-ui-text ".ks-tag" \
      --reason "10px mono index labels, confirmed"

The waiver is never silent. The engines stamp a waived finding with
`ignoredBy: "<selector>"` instead of dropping it; the config layer drops and
counts, and every scan prints one line per entry on stderr, in `--json` runs
too, so stdout stays the findings array:

    3 undersized-ui-text hits ignored by detector.ignoreSelectors on .ks-tag.

Where it applies: the browser engine (`BrowserConfig.ignoreSelectors`, also
readable from `window.__IMPECCABLE_CONFIG__`), the static HTML engine
(`DetectHtmlOptions.ignore_selectors`, and the `ignoreSelectors` option of
the wasm `detect_html_source_json` export), the detect CLI, and the design
hook. The text engine has no DOM and ignores the key. Entries can be scoped
with `files` globs like `ignoreValues`; `--no-config` disables them; `doctor`
validates their rule ids alongside `ignoreRules`. Nothing changes for a
project without the key: the engines stamp nothing, the CLI prints nothing,
the config writer does not add an empty `ignoreSelectors`, and the
per-instance attribute keeps working exactly as before.

Coverage: `crates/html/tests/selector_ignores.rs` (component, subtree,
wrong-rule, `*`, attribute parity), driver tests over the fake DOM,
`crates/detect` config tests (normalize, merge, per-target narrowing, the
tally), and oracle cases `detect-selector-ignore-*` / `ignores-selector-*`
over a new workspace. The eight re-recorded context/doctor goldens differ
only in the recognized-detector-keys sentence, which now lists the new key.

Assisted-by: Claude Code
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQBUunp8QttxZqihybNmtL
2026-09-11 12:22:16 -07:00

571 lines
22 KiB
Rust

//! Snapshot + native scan for the URL engine (triage D2).
//!
//! Instead of injecting the WebAssembly detector bundle and running the rules
//! inside the page (which needed `Page.setBypassCSP` so the page's CSP would
//! not refuse `WebAssembly.Module`), URL mode now injects only the plain-JS
//! snapshot producer (`browser-bundle/15-snapshot.js`), captures the page as
//! JSON, and runs the exact same rule core natively in this process over
//! [`SnapshotDom`] — the probe the Chrome extension already proved
//! (WASM-BUNDLE.md in the detector repo, "The snapshot route"). No WebAssembly is compiled
//! next to the page, so a strict-CSP site is scanned without bypassing CSP and
//! its blocked inline scripts stay blocked (the scan is passive again).
//!
//! Two things a snapshot cannot answer up front are supplied over CDP, exactly
//! as the extension's content script supplies them to its offscreen core:
//!
//! - **hit tests** (`elementFromPoint` / `elementsFromPoint`): a rule records a
//! miss, [`resolve_needs`] answers the points from the live page and re-runs
//! to a fixpoint (the text-occlusion grid converges in two rounds).
//! - **visual-contrast IO** (image loads, canvas pixel reads): the visual pass
//! ([`analyze_visual_contrast`], a port of `browser-bundle/35-visual.js`
//! `createVisualContrast(...).analyzeVisualContrast`) runs its decisions in
//! the core natively and its reads (`__impIO.loadImage` / `readPixel` from
//! `15-snapshot.js`'s `visualIO`) over CDP.
//!
//! The findings are identical to the in-page bundle's; the differential
//! (`crates/browser/tests/differential.rs`) is the gate.
use impeccable_core::browser::snapshot::{Facts, SnapshotDom};
use impeccable_core::browser::visual::{self, CssPlan, Prepared, StackNode};
use impeccable_core::browser::{BrowserConfig, Dom, ElId};
use impeccable_core::color::Rgba;
use serde_json::{json, Value};
use crate::cdp::{CdpError, CdpResult, Page};
/// `browser-bundle/15-snapshot.js` — the page-measurement producer. It defines
/// `const __impeccableSnapshot = {...}` plus its helpers; [`ensure_snapshot_js`]
/// wraps it so it installs `window.__impeccableSnapshot` once per page.
const SNAPSHOT_JS: &str = include_str!("../../../browser-bundle/15-snapshot.js");
/// Install `window.__impeccableSnapshot` from [`SNAPSHOT_JS`] (idempotent).
pub fn ensure_snapshot_js(page: &mut Page<'_>) -> CdpResult<()> {
let expr = format!(
"(function(){{ if (window.__impeccableSnapshot) return true;\n{SNAPSHOT_JS}\nwindow.__impeccableSnapshot = __impeccableSnapshot; return true; }})()"
);
page.evaluate_value(&expr)?;
Ok(())
}
/// `__impeccableSnapshot.capture()` in the page: serialize the current DOM,
/// keep the capture (`window.__impCap`) and its visual IO (`window.__impIO`)
/// alive for hit-test answering and pixel reads, and hand the JSON back to be
/// parsed into a [`SnapshotDom`]. Every re-capture (after a scroll) replaces
/// the page-side capture/IO; ids are assigned in document order and so stay
/// stable across scrolls (the DOM is unchanged), which is why an earlier
/// snapshot's ids keep matching the page's current `__impCap`.
pub fn capture_snapshot(page: &mut Page<'_>) -> CdpResult<SnapshotDom> {
let expr = "(function(){ const s = window.__impeccableSnapshot; const c = s.capture(); if (c.error) return { error: c.error }; window.__impCap = c; window.__impIO = s.visualIO(c); return { json: c.json }; })()";
let out = page.evaluate_value(expr)?;
if let Some(err) = out.get("error").and_then(Value::as_str) {
return Err(CdpError::new(format!("snapshot capture failed: {err}")));
}
let json = out
.get("json")
.and_then(Value::as_str)
.ok_or_else(|| CdpError::new("snapshot capture returned no json"))?;
SnapshotDom::from_json(json).map_err(|e| CdpError::new(format!("snapshot parse: {e}")))
}
/// Re-measure only the scroll-dependent geometry (`getBoundingClientRect`,
/// direct-text rect, `scrollX`/`scrollY`) of the already-captured page and
/// patch it onto a clone of `base`. A `scrollIntoView` moves the page but
/// leaves the DOM tree, attributes, computed styles, media, and keyframes
/// unchanged, and those are the only snapshot fields the visual path reads that
/// are *not* viewport-relative — so this reproduces a full re-capture's effect
/// on the visual pass at a fraction of the cost (no `getComputedStyle` sweep,
/// an ~80 KB payload instead of ~1.2 MB). Ids are document-order and so still
/// index the same elements; the page-side `__impCap` / `__impIO` (also
/// document-order) stay valid for hit-test answering and pixel reads.
pub fn recapture_geometry(page: &mut Page<'_>, base: &SnapshotDom) -> CdpResult<SnapshotDom> {
// Direct-text rect inlined from 15-snapshot.js `__snapDirectTextRect` so no
// change to the shared snapshot bundle is needed.
let expr = r#"(function () {
const cap = window.__impCap;
if (!cap || !cap.elements) return null;
const els = cap.elements;
const dtr = (node) => {
const rects = [];
for (const child of node.childNodes) {
if (child.nodeType !== 3 || !(child.textContent || '').trim()) continue;
const range = document.createRange();
range.selectNodeContents(child);
for (const rect of range.getClientRects()) {
if (rect.width >= 1 && rect.height >= 1) rects.push(rect);
}
if (range.detach) range.detach();
}
if (rects.length === 0) return null;
const left = Math.min(...rects.map(r => r.left));
const top = Math.min(...rects.map(r => r.top));
const right = Math.max(...rects.map(r => r.right));
const bottom = Math.max(...rects.map(r => r.bottom));
return [left, top, right - left, bottom - top];
};
const rects = new Array(els.length - 1);
const dtrs = new Array(els.length - 1);
for (let id = 1; id < els.length; id++) {
const el = els[id];
rects[id - 1] = (el && typeof el.getBoundingClientRect === 'function')
? (r => [r.x, r.y, r.width, r.height])(el.getBoundingClientRect()) : null;
dtrs[id - 1] = el ? dtr(el) : null;
}
return { scrollX: window.scrollX, scrollY: window.scrollY, rects, dtrs };
})()"#;
let out = page.evaluate_value(expr)?;
let mut snap = base.snap.clone();
if out.is_object() {
snap.scroll_x = out.get("scrollX").and_then(Value::as_f64).unwrap_or(snap.scroll_x);
snap.scroll_y = out.get("scrollY").and_then(Value::as_f64).unwrap_or(snap.scroll_y);
let rects = out.get("rects").and_then(Value::as_array);
let dtrs = out.get("dtrs").and_then(Value::as_array);
let rect4 = |v: Option<&Value>| -> Option<[f64; 4]> {
let a = v?.as_array()?;
if a.len() < 4 {
return None;
}
Some([a[0].as_f64()?, a[1].as_f64()?, a[2].as_f64()?, a[3].as_f64()?])
};
for (i, node) in snap.els.iter_mut().enumerate() {
if let Some(rects) = rects {
if let Some(cell) = rects.get(i) {
node.rect = rect4(Some(cell));
}
}
if let Some(dtrs) = dtrs {
if let Some(cell) = dtrs.get(i) {
node.direct_text_rect = rect4(Some(cell));
}
}
}
}
Ok(SnapshotDom::new(snap))
}
/// Answer the hit-test points a run recorded (`__impeccableSnapshot.answer`
/// over the live page and the held capture).
fn answer_needs(page: &mut Page<'_>, hit_tests: &[[f64; 2]]) -> CdpResult<Facts> {
if hit_tests.is_empty() {
return Ok(Facts::default());
}
let hits = serde_json::to_string(hit_tests).unwrap_or_else(|_| "[]".into());
let expr = format!(
"(function(){{ return window.__impeccableSnapshot.answer({{ hitTests: {hits} }}, window.__impCap); }})()"
);
let out = page.evaluate_value(&expr)?;
Ok(serde_json::from_value(out).unwrap_or_default())
}
/// Run `f` over the snapshot, and while it recorded hit-test misses, answer
/// them from the live page and re-run — the offscreen `core()` fixpoint
/// (60-offscreen.js). Deterministic runs converge in one or two rounds; the
/// cap only guards against a page that refuses to answer.
pub fn resolve_needs<T>(
dom: &SnapshotDom,
page: &mut Page<'_>,
f: impl Fn(&SnapshotDom) -> T,
) -> CdpResult<T> {
let mut out = f(dom);
let mut rounds = 0;
while dom.has_needs() && rounds < 12 {
let needs = dom.take_needs();
let facts = answer_needs(page, &needs.hit_tests)?;
dom.add_facts(&facts);
out = f(dom);
rounds += 1;
}
// Drain anything still pending so a later stage starts clean.
let _ = dom.take_needs();
Ok(out)
}
/// The design-system config the browser rules read, built the way
/// `configure-pure-detect` fed `window.__IMPECCABLE_CONFIG__` to the bundle,
/// plus the rule pack the caller installed (`None` in the `impeccable`
/// binary).
pub fn browser_config(
design_system: Value,
rule_pack: Option<&'static dyn impeccable_core::rule_pack::RulePack>,
ignore_selectors: Vec<impeccable_core::selector_ignores::SelectorIgnore>,
) -> BrowserConfig {
BrowserConfig {
extension_mode: false,
disabled_rules: Vec::new(),
disabled_values: Vec::new(),
ignore_selectors,
skip_scan: false,
design_system: if design_system.is_null() {
None
} else {
Some(design_system)
},
line_length_max: None,
rule_pack,
}
}
// ─── the visual-contrast pass (port of 35-visual.js) ───────────────────────
/// A loaded image the page holds for pixel reads.
struct LoadedImage {
/// The `ref` the page's `readPixel` maps back to the drawable (`{ url }`).
reference: Value,
w: f64,
h: f64,
}
/// `IO.loadImage(src)` in the page (the visual IO's image cache persists on
/// `window.__impIO`).
fn load_image(page: &mut Page<'_>, src: &str) -> CdpResult<Option<LoadedImage>> {
let expr = format!(
"(async () => {{ return await window.__impIO.loadImage({}); }})()",
json!(src)
);
let out = page.evaluate_value(&expr)?;
if out.is_null() {
return Ok(None);
}
let reference = out.get("ref").cloned().unwrap_or(Value::Null);
let w = out.get("w").and_then(Value::as_f64).unwrap_or(0.0);
let h = out.get("h").and_then(Value::as_f64).unwrap_or(0.0);
Ok(Some(LoadedImage { reference, w, h }))
}
/// `IO.readPixel(ref, plan, px, py)` in the page. `reference` is a snapshot id
/// (a page drawable) or a `loadImage` ref (`{ url }`).
fn read_pixel(
page: &mut Page<'_>,
reference: &Value,
plan: &Value,
px: f64,
py: f64,
) -> CdpResult<Value> {
let expr = format!(
"(async () => {{ return await window.__impIO.readPixel({}, {}, {}, {}); }})()",
reference,
plan,
json!(px),
json!(py)
);
page.evaluate_value(&expr)
}
fn live_scroll(page: &mut Page<'_>) -> CdpResult<(f64, f64)> {
let out = page.evaluate_value("({ x: window.scrollX, y: window.scrollY })")?;
Ok((
out.get("x").and_then(Value::as_f64).unwrap_or(0.0),
out.get("y").and_then(Value::as_f64).unwrap_or(0.0),
))
}
fn scroll_to(page: &mut Page<'_>, x: f64, y: f64) -> CdpResult<()> {
let expr = format!("(function(){{ window.scrollTo({}, {}); }})()", json!(x), json!(y));
page.evaluate_value(&expr)?;
Ok(())
}
fn scroll_into_view(page: &mut Page<'_>, selector: &str) -> CdpResult<bool> {
let expr = format!(
"(function(){{ let el; try {{ el = document.querySelector({}); }} catch {{ return false; }} if (!el || typeof el.scrollIntoView !== 'function') return false; el.scrollIntoView({{ block: 'center', inline: 'nearest', behavior: 'instant' }}); return true; }})()",
json!(selector)
);
Ok(page.evaluate_value(&expr)?.as_bool() == Some(true))
}
fn wait_for_paint(page: &mut Page<'_>) -> CdpResult<()> {
page.evaluate_value(
"new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(0))))",
)?;
Ok(())
}
fn media(dom: &SnapshotDom, el: ElId) -> impeccable_core::browser::snapshot::MediaInfo {
dom.snap
.get(el)
.and_then(|n| n.media.clone())
.unwrap_or_default()
}
/// `IO.intrinsicImg` over the snapshot (`naturalWidth || videoWidth || width`).
fn intrinsic_img(dom: &SnapshotDom, el: ElId) -> (f64, f64) {
let m = media(dom, el);
(
first_nonzero(&[m.nw, m.vw, m.w]),
first_nonzero(&[m.nh, m.vh, m.h]),
)
}
/// `IO.intrinsicRaster` over the snapshot (`width || videoWidth`).
fn intrinsic_raster(dom: &SnapshotDom, el: ElId) -> (f64, f64) {
let m = media(dom, el);
(first_nonzero(&[m.w, m.vw]), first_nonzero(&[m.h, m.vh]))
}
fn img_src(dom: &SnapshotDom, el: ElId) -> String {
let m = media(dom, el);
if !m.cur.is_empty() {
m.cur
} else {
m.src
}
}
/// JS `a || b || 0` over the media numbers (0/NaN are falsy).
fn first_nonzero(vals: &[f64]) -> f64 {
for &v in vals {
if v != 0.0 && !v.is_nan() {
return v;
}
}
0.0
}
fn is_sampled(sample: &Value) -> bool {
sample.get("status").and_then(Value::as_str) == Some("sampled")
}
fn sample_reason(sample: &Value) -> String {
sample
.get("reason")
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
}
/// Port of `sampleDrawablePixel`: the raster plan and pixel address come from
/// the core, the read from the page.
fn sample_drawable_pixel(
page: &mut Page<'_>,
reference: &Value,
intrinsic: (f64, f64),
source_x: f64,
source_y: f64,
) -> CdpResult<Value> {
let plan = visual::raster_plan(intrinsic.0, intrinsic.1);
let (rpx, rpy) = visual::raster_pixel(&plan, source_x, source_y);
let plan_json = serde_json::to_value(plan).unwrap_or(Value::Null);
let read = read_pixel(page, reference, &plan_json, rpx, rpy)?;
if read.get("noContext").and_then(Value::as_bool) == Some(true) {
return Ok(visual::raster_no_context_sample());
}
if let Some(err) = read.get("error") {
let reason = visual::raster_error_reason(err.as_str().unwrap_or(""));
return Ok(visual::raster_failure_sample(&reason));
}
let d = read.get("data").and_then(Value::as_array);
let ch = |i: usize| -> f64 {
d.and_then(|a| a.get(i))
.and_then(Value::as_f64)
.unwrap_or(0.0)
};
Ok(visual::pixel_sample(ch(0), ch(1), ch(2), ch(3)))
}
/// Port of `sampleImageElement`.
fn sample_image_element(
page: &mut Page<'_>,
dom: &SnapshotDom,
node: ElId,
px: f64,
py: f64,
) -> CdpResult<Value> {
let intrinsic = intrinsic_img(dom, node);
let (painted, source) = match visual::img_source_point(dom, node, intrinsic.0, intrinsic.1, px, py) {
Err(sample) => return Ok(sample),
Ok(v) => v,
};
let node_ref = json!(node);
let sample = sample_drawable_pixel(page, &node_ref, intrinsic, source.0, source.1)?;
let finished = visual::img_finish(sample.clone());
if is_sampled(&finished) {
return Ok(finished);
}
let src = img_src(dom, node);
if !src.is_empty() {
if let Some(loaded) = load_image(page, &src)? {
if let Some(point) = visual::img_loaded_source_point(&painted, loaded.w, loaded.h, px, py) {
let pixel = sample_drawable_pixel(
page,
&loaded.reference,
(loaded.w, loaded.h),
point.0,
point.1,
)?;
let loaded_sample = visual::img_finish(pixel);
if is_sampled(&loaded_sample) {
return Ok(loaded_sample);
}
}
}
}
Ok(sample)
}
/// Port of `sampleCssBackground`.
fn sample_css_background(
page: &mut Page<'_>,
dom: &SnapshotDom,
node: ElId,
px: f64,
py: f64,
text_color: &Rgba,
) -> CdpResult<Value> {
match visual::css_plan(dom, node, Some(text_color)) {
CssPlan::Sample { sample } => Ok(sample),
CssPlan::Url { url, size, position } => {
let Some(img) = load_image(page, &url)? else {
return Ok(visual::css_url_no_image());
};
match visual::css_url_source_point(dom, node, img.w, img.h, &size, &position, px, py) {
Err(sample) => Ok(sample),
Ok(source) => {
let pixel = sample_drawable_pixel(
page,
&img.reference,
(img.w, img.h),
source.0,
source.1,
)?;
Ok(visual::css_url_finish(pixel))
}
}
}
}
}
/// Port of `analyzeVisualContrastCandidate` over one snapshot.
fn analyze_candidate(
page: &mut Page<'_>,
dom: &SnapshotDom,
candidate: &Value,
) -> CdpResult<Value> {
let prepared = resolve_needs(dom, page, |d| visual::prepare_analysis(d, candidate))?;
let (el, points, text_color) = match prepared {
Prepared::Early { early } => return Ok(early),
Prepared::Ready { el, points, text_color } => (el, points, text_color),
};
let mut samples: Vec<Value> = Vec::with_capacity(points.len());
for point in &points {
let px = point.get("x").and_then(Value::as_f64).unwrap_or(0.0);
let py = point.get("y").and_then(Value::as_f64).unwrap_or(0.0);
samples.push(sample_background(page, dom, el, px, py, &text_color)?);
}
Ok(visual::finish_analysis(candidate, &text_color, &samples, points.len()))
}
/// The stack walk with the candidate's text color carried explicitly (the css
/// leaf needs it for gradient contrast picking / alpha compositing).
fn sample_background(
page: &mut Page<'_>,
dom: &SnapshotDom,
el: ElId,
px: f64,
py: f64,
text_color: &Rgba,
) -> CdpResult<Value> {
sample_background_impl(page, dom, el, px, py, 0.0, text_color)
}
fn sample_background_impl(
page: &mut Page<'_>,
dom: &SnapshotDom,
el: ElId,
px: f64,
py: f64,
depth: f64,
text_color: &Rgba,
) -> CdpResult<Value> {
let walk = resolve_needs(dom, page, |d| visual::stack_nodes(d, el, px, py, depth))?;
let nodes = match walk {
Err(unresolved) => return Ok(unresolved),
Ok(nodes) => nodes,
};
let mut unresolved: Vec<String> = Vec::new();
for StackNode { el: node, kind } in nodes {
match kind.as_str() {
"img" => {
let sample = sample_image_element(page, dom, node, px, py)?;
if is_sampled(&sample) {
return Ok(sample);
}
unresolved.push(sample_reason(&sample));
}
"raster" => {
let intrinsic = intrinsic_raster(dom, node);
if let Some(source) =
visual::raster_source_point(dom, node, intrinsic.0, intrinsic.1, px, py)
{
let node_ref = json!(node);
let pixel =
sample_drawable_pixel(page, &node_ref, intrinsic, source.0, source.1)?;
let sample = visual::raster_finish(dom, node, pixel);
if is_sampled(&sample) {
return Ok(sample);
}
unresolved.push(sample_reason(&sample));
}
}
_ => {
let sample = sample_css_background(page, dom, node, px, py, text_color)?;
if is_sampled(&sample) {
if visual::sample_is_opaque(&sample) {
return Ok(sample);
}
let parent = dom.parent(node).or_else(|| dom.body()).unwrap_or(0);
let under =
sample_background_impl(page, dom, parent, px, py, depth + 1.0, text_color)?;
return Ok(visual::alpha_composite(sample, &under));
}
unresolved.push(sample_reason(&sample));
}
}
}
Ok(visual::unresolved_from_reasons(&unresolved))
}
/// Port of `analyzeVisualContrast`: candidates from the core, one analysis per
/// candidate, with the `scrollOffscreen` restore/retry the URL engine uses.
/// `base` is the scroll-0 snapshot; a retry scrolls the element into view and
/// re-captures, then restores.
pub fn analyze_visual_contrast(
page: &mut Page<'_>,
base: &SnapshotDom,
max_candidates: f64,
scroll_offscreen: bool,
) -> CdpResult<Vec<Value>> {
let options = json!({ "maxCandidates": max_candidates });
let candidates = resolve_needs(base, page, |d| {
visual::collect_visual_contrast_candidates(d, &options)
})?;
let mut results: Vec<Value> = Vec::with_capacity(candidates.len());
let restore = live_scroll(page)?;
for candidate in &candidates {
if scroll_offscreen {
let now = live_scroll(page)?;
if now != restore {
scroll_to(page, restore.0, restore.1)?;
wait_for_paint(page)?;
}
}
let mut result = analyze_candidate(page, base, candidate)?;
if scroll_offscreen && visual::needs_scroll_retry(&result) {
let selector = candidate.get("selector").and_then(Value::as_str).unwrap_or("");
if scroll_into_view(page, selector)? {
wait_for_paint(page)?;
// Only geometry changed (the page scrolled); patch it onto the
// base snapshot rather than re-capturing the whole page.
let scrolled = recapture_geometry(page, base)?;
result = analyze_candidate(page, &scrolled, candidate)?;
}
}
results.push(result);
}
if scroll_offscreen {
let now = live_scroll(page)?;
if now != restore {
scroll_to(page, restore.0, restore.1)?;
}
}
Ok(results)
}