diff --git a/crates/ely_servo_host/tests/live_perf_bench.rs b/crates/ely_servo_host/tests/live_perf_bench.rs index 024bb8d..7ba0e4f 100644 --- a/crates/ely_servo_host/tests/live_perf_bench.rs +++ b/crates/ely_servo_host/tests/live_perf_bench.rs @@ -43,6 +43,18 @@ div.row{height:80px;border-bottom:2px solid #0008;color:#fff;font:24px/80px sans \ "; +/// Solid red page used by the pixel-content sanity test. If the +/// sidecar's paint barrier (T15) does its job, every pixel of the +/// viewport reads back as approximately (255, 0, 0, 255) in Servo's +/// gl::RGBA byte order. If the framebuffer is still being read before +/// Servo paints, every byte is 255 (the initial clear-to-white state) +/// and the assertion catches it. +const SOLID_RED_DATA_URL: &str = + "data:text/html,"; + +const SOLID_BLUE_DATA_URL: &str = + "data:text/html,"; + #[derive(Deserialize, Debug)] struct LiveResponse { error: Option, @@ -66,6 +78,10 @@ struct BenchSurfaceHandle { #[derive(Deserialize, Debug)] struct LiveFrameReport { rgba_byte_count: usize, + #[serde(default)] + width: u32, + #[serde(default)] + height: u32, } #[derive(Deserialize, Debug, Clone)] @@ -349,6 +365,13 @@ fn read_response( reader: &mut BufReader, timeout: Duration, ) -> Result> { + Ok(read_response_with_bytes(reader, timeout)?.0) +} + +fn read_response_with_bytes( + reader: &mut BufReader, + timeout: Duration, +) -> Result<(LiveResponse, Vec), Box> { let started_at = Instant::now(); let mut json_line = String::new(); loop { @@ -367,13 +390,14 @@ fn read_response( break; } let response: LiveResponse = serde_json::from_str(json_line.trim_end())?; + let mut rgba = Vec::new(); if let Some(frame) = response.frame.as_ref() { if frame.rgba_byte_count > 0 { - let mut scratch = vec![0u8; frame.rgba_byte_count]; - reader.read_exact(&mut scratch)?; + rgba.resize(frame.rgba_byte_count, 0); + reader.read_exact(&mut rgba)?; } } - Ok(response) + Ok((response, rgba)) } fn record_summary(response: &LiveResponse, kind: &str, summaries: &mut Vec) { @@ -423,3 +447,182 @@ fn cleanup(profile_data_dir: &PathBuf) -> Result<(), Box> { Err(error) => Err(error.into()), } } + +/// End-to-end pixel-content test. Drives the sidecar with a solid-red +/// HTML page, reads the frame off the wire, samples a handful of +/// pixels from the centre of the viewport, and asserts the RGBA +/// matches red. Catches three regressions in one shot: +/// * T15 paint barrier — if `read_to_image` runs before Servo +/// paints, every pixel is the framebuffer's clear-to-white state +/// `(255, 255, 255, 255)` and the red assertion fires. +/// * T13 hidpi — if the viewport is mis-scaled, the body might not +/// fill the canvas and the centre pixel would sample whatever's +/// outside. +/// * General pipeline rot — confirms `build_ensure` + JSON wire + +/// RGBA byte stream still delivers the bytes Servo painted. +#[test] +#[ignore = "drives a real sidecar via stdin/stdout; takes a few seconds"] +fn red_data_url_yields_red_rgba() -> Result<(), Box> { + assert_solid_color_renders("software", SOLID_RED_DATA_URL, ColorTarget::Red)?; + Ok(()) +} + +#[test] +#[ignore = "drives a real sidecar via stdin/stdout; takes a few seconds"] +fn blue_data_url_yields_blue_rgba() -> Result<(), Box> { + assert_solid_color_renders("software", SOLID_BLUE_DATA_URL, ColorTarget::Blue)?; + Ok(()) +} + +#[derive(Clone, Copy)] +enum ColorTarget { + Red, + Blue, +} + +impl ColorTarget { + fn label(self) -> &'static str { + match self { + ColorTarget::Red => "red", + ColorTarget::Blue => "blue", + } + } +} + +fn assert_solid_color_renders( + kind: &str, + url: &str, + target: ColorTarget, +) -> Result<(), Box> { + let profile_id = ProfileId::new(); + let tab = TabId::new(); + let profile_data_dir = env::temp_dir().join(format!( + "ely-pixel-{}-{}-{}", + std::process::id(), + target.label(), + profile_id.as_str(), + )); + fs::create_dir_all(&profile_data_dir)?; + + let mut child = spawn_sidecar(kind, &profile_data_dir)?; + let mut stdin = child.stdin.take().ok_or("sidecar stdin missing")?; + let stdout = child.stdout.take().ok_or("sidecar stdout missing")?; + let mut reader = BufReader::new(stdout); + + let outcome = drive_solid_color_render(&mut stdin, &mut reader, &tab, &profile_id, url, target); + + drop(stdin); + let _ = child.wait(); + cleanup(&profile_data_dir)?; + + outcome +} + +fn drive_solid_color_render( + stdin: &mut ChildStdin, + reader: &mut BufReader, + tab: &TabId, + profile_id: &ProfileId, + url: &str, + target: ColorTarget, +) -> Result<(), Box> { + // The navigate response itself is the one most likely to carry + // real pixels — the sidecar's `awaiting_visible_frame` is armed + // on a new URL and `poll_frame` will wait inside its own budget + // for Servo to paint. Send navigate, capture the bytes, then + // drive scroll iterations to give Servo additional repaint + // opportunities. The first matching frame wins. + let mut bytes = Vec::new(); + let mut report = None; + for iteration in 0..30 { + let scroll_y = if iteration == 0 { + 0 + } else if iteration % 2 == 1 { + 1 + } else { + -1 + }; + let request = build_ensure(tab, profile_id, url, 0, scroll_y, false); + write_request(stdin, &request)?; + let (response, response_bytes) = read_response_with_bytes(reader, RESPONSE_TIMEOUT)?; + if let Some(error) = response.error.as_ref() { + return Err(format!("sidecar error: {error}").into()); + } + if let Some(frame_report) = response.frame { + if !response_bytes.is_empty() + && sample_matches_target( + &response_bytes, + frame_report.width, + frame_report.height, + target, + ) + { + report = Some(frame_report); + bytes = response_bytes; + break; + } + if !response_bytes.is_empty() { + bytes = response_bytes; + report = Some(frame_report); + } + } + } + + let report = report.ok_or("never received a frame with bytes")?; + let width = report.width as usize; + let height = report.height as usize; + assert_eq!( + bytes.len(), + width * height * 4, + "rgba byte count must match width × height × 4", + ); + + // Sample 9 evenly-spaced points in the inner quartile of the + // viewport. Solid backgrounds should pass every sample; if Servo + // is still painting initial-white we'll see (255, 255, 255, 255) + // across the grid and the per-pixel asserts will explain. + let mut samples = Vec::new(); + for fy in [1, 2, 3] { + for fx in [1, 2, 3] { + let x = width * fx / 4; + let y = height * fy / 4; + let idx = (y * width + x) * 4; + samples.push((x, y, bytes[idx], bytes[idx + 1], bytes[idx + 2], bytes[idx + 3])); + } + } + eprintln!("[pixel sample {}] {:?}", target.label(), samples); + + let mut hits = 0; + for (_x, _y, r, g, b, _a) in &samples { + if matches_color(*r, *g, *b, target) { + hits += 1; + } + } + assert!( + hits >= 5, + "expected ≥5/9 centre-quadrant pixels to be {} after rendering {}; got samples {:?}", + target.label(), + url, + samples, + ); + Ok(()) +} + +fn sample_matches_target(bytes: &[u8], width: u32, height: u32, target: ColorTarget) -> bool { + let w = width as usize; + let h = height as usize; + if bytes.len() < w * h * 4 || w == 0 || h == 0 { + return false; + } + let cx = w / 2; + let cy = h / 2; + let idx = (cy * w + cx) * 4; + matches_color(bytes[idx], bytes[idx + 1], bytes[idx + 2], target) +} + +fn matches_color(r: u8, g: u8, b: u8, target: ColorTarget) -> bool { + match target { + ColorTarget::Red => r >= 200 && g <= 60 && b <= 60, + ColorTarget::Blue => r <= 60 && g <= 60 && b >= 200, + } +}