Fix hosted component capture failures and misleading control diagnostics

Report undeclared preview dependencies before image decoding, route hosted capture through the review tool, and enforce configured component approval before page comparison. Preserve visual gates while correcting lettering advice for controls.

AI-assisted implementation and validation with Codex.
This commit is contained in:
Paul Bakaus
2026-09-21 08:41:43 -07:00
parent d49867771f
commit d881fe9a29
5 changed files with 191 additions and 48 deletions
+23
View File
@@ -99,6 +99,9 @@ pub fn serve(args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<String, String>) -> Result<Option<Self>, 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());
}
}
+66 -44
View File
@@ -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::<Vec<_>>().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#"<!doctype html><style>html,body{margin:0}#piece{width:40px;height:40px;background:red}</style><div id="piece"></div><img src="a.png"><img src="b.png">"#;
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();
+82 -4
View File
@@ -96,6 +96,34 @@ pub fn ink_color(img: &Image) -> Option<InkColor> {
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<InkColor> {
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<u8> = (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::<i32>() < 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<String>| -> 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<Value> {
}
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(&region, &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(&region,&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}");
}
}
@@ -57,6 +57,11 @@ pub fn run_with_capturer(
Some("prepare") | Some("capture") => {
let path = arg(args, "--manifest")
.ok_or("prepare needs --manifest <project-relative file>")?;
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)?;
@@ -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,