diff --git a/crates/cli/src/capture_service.rs b/crates/cli/src/capture_service.rs index 176cb9d2a..3993a8f25 100644 --- a/crates/cli/src/capture_service.rs +++ b/crates/cli/src/capture_service.rs @@ -99,6 +99,9 @@ pub fn serve(args: &[String]) -> Result<(), Box> { return Err("capability too short".into()); } let renderer=ReviewedEntryRenderer{session:std::env::var_os("IMPECCABLE_CAPTURE_REVIEW_SESSION").map(PathBuf::from)}; + // Read host policy once. Requests cannot assert or manufacture approval. + let component_review_pending = std::env::var("IMPECCABLE_COMPONENT_REVIEW_PENDING").as_deref() == Ok("1"); + let review_tool = std::env::var("IMPECCABLE_COMPONENT_REVIEW_TOOL").unwrap_or_else(|_| "component_review".into()); let listener = TcpListener::bind("127.0.0.1:0")?; listener.set_nonblocking(true)?; std::fs::write( @@ -134,6 +137,7 @@ pub fn serve(args: &[String]) -> Result<(), Box> { let text = |k: &str| body[k].as_str().ok_or_else(|| format!("missing {k}")); match route.as_str() { "/capture" => { + require_component_review(component_review_pending, &review_tool)?; if active.len() >= 2 { return Err("active capture limit".into()); } @@ -325,6 +329,13 @@ pub struct RemoteEntryRenderer { port: u16, key: String, } + +fn require_component_review(pending: bool, tool: &str) -> Result<(), String> { + if pending { + return Err(format!("Component kit approval is pending. Call {tool} with the component manifest's manifest_path and wait for the user's decisions before assembled-page comparison. No page comparison was performed.")); + } + Ok(()) +} impl RemoteEntryRenderer { pub fn from_env(env: &HashMap) -> Result, String> { match ( @@ -563,3 +574,15 @@ mod tests { assert!(RemoteEntryRenderer::from_env(&env).is_err()); } } + +#[cfg(test)] +mod component_boundary_tests { + use super::*; + #[test] + fn page_capture_requires_configured_human_boundary_before_rendering() { + let error = require_component_review(true, "component_review").unwrap_err(); + assert!(error.contains("Component kit approval is pending")); + assert!(error.contains("No page comparison was performed")); + assert!(require_component_review(false, "component_review").is_ok()); + } +} diff --git a/crates/cli/src/component_capture.rs b/crates/cli/src/component_capture.rs index 388b995bb..baac66267 100644 --- a/crates/cli/src/component_capture.rs +++ b/crates/cli/src/component_capture.rs @@ -75,9 +75,58 @@ fn render_page( page.goto(&url, "networkidle0", Duration::from_secs(20)) .map_err(|e| e.message)?; let world = page.create_isolated_world().map_err(|e| e.message)?; + let supported = r#"(()=>{if((!ASSEMBLED&&document.scripts.length)||document.querySelector('iframe,frame,object,embed,canvas'))throw Error('Component capture requires static HTML/CSS/SVG; script, frame and canvas components need a supported capture adapter.');return true;})()"# + .replace("ASSEMBLED", if assembled { "true" } else { "false" }); + page.evaluate_value_in_world(&world, &supported).map_err(|e|e.message)?; + let urls = page.observed_response_urls().map_err(|e| e.message)?; + let evidence = page.response_evidence(&urls).map_err(|e| e.message)?; + if evidence.truncated + || evidence.changed_during_collection + || !evidence.missing_urls.is_empty() + { + return Err("component network evidence is incomplete".into()); + } + let mut responses = BTreeMap::new(); + let mut dependency_errors = std::collections::BTreeSet::new(); + for record in &evidence.responses { + if record.url.starts_with("data:image/") { + continue; + } + if record.url == format!("{origin}/favicon.ico") + && record.status == Some(404.) + && snapshot.bytes("favicon.ico").is_none() + { + continue; + } + let Some(target) = record.url.strip_prefix(&origin) else { + dependency_errors.insert(format!("external dependency: {}", record.url)); + continue; + }; + let Some(path) = snapshot.serve_path(target) else { + dependency_errors.insert(format!("undeclared dependency: {target}")); + continue; + }; + let expected = snapshot.bytes(&path).ok_or("missing frozen dependency")?; + if record.status != Some(200.) + || !record.complete + || record.from_service_worker + || record.ambiguous_url + || record.body.as_deref() != Some(expected) + { + dependency_errors.insert(format!("dependency did not match frozen bytes: {path}")); + continue; + } + responses.insert(path, hash(expected)); + } + if !dependency_errors.is_empty() { + let shown = dependency_errors.iter().take(16).cloned().collect::>().join("; "); + return Err(format!("Component resources failed ({}): {shown}. Declare missing files in this component's dependencies, including files used outside its selector; check that declared files load from their pinned bytes.", dependency_errors.len())); + } + if !responses.contains_key(snapshot.entry()) { + return Err("component document response is unverified".into()); + } let inspect = r#"(async()=>{ - if((!ASSEMBLED&&document.scripts.length)||document.querySelector('iframe,frame,object,embed,canvas'))throw Error('Component capture requires static HTML/CSS/SVG; script, frame and canvas components need a supported capture adapter.'); - await Promise.race([(async()=>{await document.fonts.ready;await Promise.all([...document.images].map(i=>i.decode()));})(),new Promise((_,reject)=>setTimeout(()=>reject(Error('component resources did not settle')),5000))]); + await Promise.race([(async()=>{await document.fonts.ready;const failed=await Promise.all([...document.images].map(async i=>{try{await i.decode();return null;}catch{const src=i.currentSrc||i.getAttribute('src')||'(missing src)';return src.startsWith('data:')?'(inline image)':new URL(src,location.href).pathname;}}));if(failed.some(Boolean))throw Error('Images failed to decode: '+[...new Set(failed.filter(Boolean))].join(', '));})(),new Promise((_,reject)=>setTimeout(()=>reject(Error('component resources did not settle')),5000))]); if([...document.fonts].some(f=>f.status==='error'))throw Error('A component font failed to load.'); if(document.getAnimations().some(a=>a.playState==='running'))throw Error('Component is animated; provide its static review state.'); return {html:document.documentElement.outerHTML,svg:document.querySelectorAll('svg').length,images:document.images.length,controls:document.querySelectorAll('button,input,select,textarea,a[href]').length}; @@ -106,48 +155,6 @@ fn render_page( let first = page .screenshot_viewport() .map_err(|e| e.message)?; - let urls = page.observed_response_urls().map_err(|e| e.message)?; - let evidence = page.response_evidence(&urls).map_err(|e| e.message)?; - if evidence.truncated - || evidence.changed_during_collection - || !evidence.missing_urls.is_empty() - { - return Err("component network evidence is incomplete".into()); - } - let mut responses = BTreeMap::new(); - for record in &evidence.responses { - if record.url.starts_with("data:image/") { - continue; - } - if record.url == format!("{origin}/favicon.ico") - && record.status == Some(404.) - && snapshot.bytes("favicon.ico").is_none() - { - continue; - } - let target = record - .url - .strip_prefix(&origin) - .ok_or("component requested an external dependency")?; - let path = snapshot - .serve_path(target) - .ok_or_else(|| format!("undeclared component dependency: {target}"))?; - let expected = snapshot.bytes(&path).ok_or("missing frozen dependency")?; - if record.status != Some(200.) - || !record.complete - || record.from_service_worker - || record.ambiguous_url - || record.body.as_deref() != Some(expected) - { - return Err(format!( - "component dependency did not match frozen bytes: {path}" - )); - } - responses.insert(path, hash(expected)); - } - if !responses.contains_key(snapshot.entry()) { - return Err("component document response is unverified".into()); - } let second = page .screenshot_viewport() .map_err(|e| e.message)?; @@ -314,6 +321,21 @@ mod tests { use super::*; #[test] #[ignore = "requires Chromium"] + fn shared_document_reports_all_missing_images_before_decode_and_names_corrupt_images() { + let image = impeccable_comp::raster::create_image(40,40,[255,255,255,255]); + let png = impeccable_comp::png_io::encode_png(&image,&[]).unwrap(); + let html = br#"
"#; + let inputs = BTreeMap::from([("comp.png".into(),png.clone()),("index.html".into(),html.to_vec()),("a.png".into(),png), ("b.png".into(),b"not an image".to_vec())]); + let packet = json!({"schemaVersion":2,"stage":"components","comp":{"url":"/files/comp.png","width":40,"height":40},"components":[{"id":"piece","box":{"x":0,"y":0,"w":1,"h":1},"preview":{"kind":"page","url":"/files/index.html","selector":"#piece"},"dependencies":[]}]}); + let error = NativeComponentCapturer.capture(&mut packet.clone(),&inputs).err().unwrap(); + assert!(error.contains("a.png") && error.contains("b.png") && error.contains("dependencies"), "{error}"); + assert!(!error.contains("EncodingError"), "{error}"); + let mut declared=packet;declared["components"][0]["dependencies"]=json!(["a.png","b.png"]); + let error = NativeComponentCapturer.capture(&mut declared,&inputs).err().unwrap(); + assert!(error.contains("b.png") && !error.contains("EncodingError"), "{error}"); + } + #[test] + #[ignore = "requires Chromium"] fn assembled_page_executes_pinned_script_but_component_capture_stays_static() { let image = impeccable_comp::raster::Image { width:40,height:40,data:vec![255;40*40*4] }; let reference = impeccable_comp::png_io::encode_png(&image,&[]).unwrap(); diff --git a/crates/comp/src/hero.rs b/crates/comp/src/hero.rs index 5afce73e6..31838f1fb 100644 --- a/crates/comp/src/hero.rs +++ b/crates/comp/src/hero.rs @@ -96,6 +96,34 @@ pub fn ink_color(img: &Image) -> Option { Some(InkColor { ground, ink }) } +// A secondary palette cluster may be the surrounding panel, not lettering. +// Refuse to prescribe that colour as ink when it spans an edge of the crop. +// This affects diagnostic advice only; full-region fidelity still uses every pixel. +fn text_ink_color(img: &Image) -> Option { + let cols = dominant_colors(img, 4, 3); + let ground = cols.first()?.clone(); + let polarity = fingerprint(img, &FpOpts::default()) + .filter(|fp| fp.glyphs >= 6 && fp.cap_height_px < img.height as f64 * 0.6) + .map(|fp| fp.ink_is_dark); + let ink = cols.iter().skip(1).find(|c| { + delta_e(c.lab, ground.lab) > 20.0 && !colour_spans_edge(img, &c.hex) + && polarity.is_none_or(|dark| (c.lab[0] < ground.lab[0]) == dark) + }).cloned(); + Some(InkColor { ground, ink }) +} + +fn colour_spans_edge(img: &Image, hex: &str) -> bool { + if img.width == 0 || img.height == 0 { return true; } + let rgb: Vec = (1..7).step_by(2).filter_map(|i| u8::from_str_radix(&hex[i..i+2], 16).ok()).collect(); + if rgb.len() != 3 { return true; } + let matches = |x: usize, y: usize| { + let p = &img.data[(y * img.width + x) * 4..]; + (0..3).map(|i| (p[i] as i32 - rgb[i] as i32).pow(2)).sum::() < 40 * 40 + }; + [0, img.height - 1].iter().any(|&y| (0..img.width).filter(|&x| matches(x,y)).count() * 4 > img.width) + || [0, img.width - 1].iter().any(|&x| (0..img.height).filter(|&y| matches(x,y)).count() * 4 > img.height) +} + /// A spec region (minimal: only the fields the pure checks read). pub struct Region { pub id: String, @@ -117,8 +145,8 @@ pub fn text_region_check(region: &Region, comp_crop: &Image, build_crop: &Image) let comp = fingerprint(comp_crop, &FpOpts::default()); let colour_only = |findings: &mut Vec| -> Value { - let ca = ink_color(comp_crop); - let cb = ink_color(build_crop); + let ca = text_ink_color(comp_crop); + let cb = text_ink_color(build_crop); if let (Some(ca), Some(cb)) = (&ca, &cb) { if let (Some(ci), Some(cbi)) = (&ca.ink, &cb.ink) { if delta_e(ci.lab, cbi.lab) > 22.0 { @@ -243,7 +271,7 @@ pub fn text_region_check(region: &Region, comp_crop: &Image, build_crop: &Image) } } if comp.cap_height_px >= 16.0 { - if let (Some(ca), Some(cb)) = (ink_color(comp_crop), ink_color(build_crop)) { + if let (Some(ca), Some(cb)) = (text_ink_color(comp_crop), text_ink_color(build_crop)) { if let (Some(ci), Some(cbi)) = (&ca.ink, &cb.ink) { if delta_e(ci.lab, cbi.lab) > 22.0 { findings.push(format!( @@ -254,7 +282,11 @@ pub fn text_region_check(region: &Region, comp_crop: &Image, build_crop: &Image) } } } - if let (Some(ba), Some(bb)) = (ink_box(comp_crop), ink_box(build_crop)) { + // A control's contrast bounds include its outline, fill edge, and icon. + // They are not the first line of its lettering. Whole-control placement + // and shape remain checked by the independent region comparison. + if region.kind != "control" { + if let (Some(ba), Some(bb)) = (ink_box(comp_crop), ink_box(build_crop)) { let dy = bb.y - ba.y; if (dy as f64).abs() > 12f64.max(comp_crop.height as f64 * 0.15) { findings.push(format!( @@ -277,6 +309,7 @@ pub fn text_region_check(region: &Region, comp_crop: &Image, build_crop: &Image) )); } } + } metrics["capDelta"] = json!(round_fixed(cap_delta, 3)); json!({ "findings": findings, "metrics": metrics }) } @@ -506,3 +539,48 @@ pub fn svg_illustrations(html: &str) -> Vec { } out } + +#[cfg(test)] +mod foreground_tests { + use super::*; + use crate::raster::create_image; + + fn button(ink: [u8;4]) -> Image { + let mut image = create_image(120, 40, [248,216,71,255]); + for y in 0..40 { for x in 0..120 { + let colour = if y < 3 || y >= 37 {[190,70,27,255]} + else if (12..28).contains(&y) && x > 15 && x < 105 && x % 8 < 3 {ink} + else {continue}; + image.data[(y*120+x)*4..(y*120+x)*4+4].copy_from_slice(&colour); + }} + image + } + #[test] + fn surrounding_panel_is_not_prescribed_as_control_lettering() { + let image = button([8,40,72,255]); + let chosen = text_ink_color(&image).unwrap().ink.unwrap(); + assert!(!colour_spans_edge(&image, &chosen.hex)); + assert_ne!(chosen.hex, "#be461b"); + assert!(chosen.hex.starts_with("#0"), "{}", chosen.hex); + } + #[test] + fn incorrect_interior_lettering_still_produces_colour_feedback() { + let region = Region { id:"action".into(), kind:"control".into(), chosen:None }; + let result = text_region_check(®ion, &button([8,40,72,255]), &button([180,20,180,255])); + assert!(result["findings"].as_array().unwrap().iter().any(|f|f.as_str().unwrap().contains("ink is")), "{result}"); + } + #[test] + fn control_outline_does_not_become_text_colour_or_first_line() { + let a = button([8,40,72,255]); + let mut b = a.clone(); + // A different outer surround must not move the lettering's first line. + for y in [0,1,2,37,38,39] { for x in 0..120 { + b.data[(y*120+x)*4..(y*120+x)*4+4].copy_from_slice(&[248,216,71,255]); + }} + let region = Region { id:"action".into(), kind:"control".into(), chosen:None }; + let result = text_region_check(®ion,&a,&b); + assert!(!result["findings"].as_array().unwrap().iter().any(|f| { + let s=f.as_str().unwrap();s.contains("first line") || s.contains("ink is") + }), "{result}"); + } +} diff --git a/crates/context/src/component_review/mod.rs b/crates/context/src/component_review/mod.rs index 6b9823a72..77775941f 100644 --- a/crates/context/src/component_review/mod.rs +++ b/crates/context/src/component_review/mod.rs @@ -57,6 +57,11 @@ pub fn run_with_capturer( Some("prepare") | Some("capture") => { let path = arg(args, "--manifest") .ok_or("prepare needs --manifest ")?; + if args[0] == "capture" { + if let Some(tool) = io.env("IMPECCABLE_COMPONENT_REVIEW_TOOL") { + return Err(format!("This session uses hosted human review. Call {tool} with manifest_path={path:?}; it captures the components and waits for the user's decisions. A failed capture is not approval.")); + } + } let project = io.cwd.canonicalize().map_err(|e| e.to_string())?; let renderer=if args[0]=="capture" {Some(capturer.take().ok_or("native component capturer unavailable")?)}else{None}; let dir=store::prepare_file(&store,&project,&path,renderer)?; diff --git a/crates/context/src/component_review/tests.rs b/crates/context/src/component_review/tests.rs index a4d48ebf5..eddf8b5ae 100644 --- a/crates/context/src/component_review/tests.rs +++ b/crates/context/src/component_review/tests.rs @@ -6,6 +6,21 @@ use std::{ sync::atomic::{AtomicUsize, Ordering}, }; static NEXT: AtomicUsize = AtomicUsize::new(0); + +#[test] +fn hosted_capture_routes_to_the_review_tool_before_browser_or_store_access() { + let f = Fixture::new(); + let (mut io, captured) = impeccable_common::Io::captured("", f.project.clone(), + std::collections::HashMap::from([ + ("HOME".into(), f.root.to_string_lossy().into_owned()), + ("IMPECCABLE_COMPONENT_REVIEW_TOOL".into(), "component_review".into()), + ])); + let args = vec!["capture".into(), "--manifest".into(), "review.json".into()]; + assert_eq!(super::run_with_capturer(&args, &mut io, None), 1); + let error = String::from_utf8(captured.stderr.borrow().clone()).unwrap(); + assert!(error.contains("Call component_review with manifest_path=\"review.json\""), "{error}"); + assert!(!f.root.join(".impeccable").exists()); +} struct Fixture { root: PathBuf, project: PathBuf,