diff --git a/crates/ely_app/src/services/servo_sidecar.rs b/crates/ely_app/src/services/servo_sidecar.rs index 6defba0..fe406ff 100644 --- a/crates/ely_app/src/services/servo_sidecar.rs +++ b/crates/ely_app/src/services/servo_sidecar.rs @@ -10,7 +10,7 @@ use ely_domain::UrlText; use serde::Deserialize; use thiserror::Error; -const SIDECAR_COMMAND_TIMEOUT: Duration = Duration::from_secs(20); +const SIDECAR_COMMAND_TIMEOUT: Duration = Duration::from_secs(35); const SIDECAR_POLL_INTERVAL: Duration = Duration::from_millis(20); #[derive(Clone, Debug)] @@ -175,7 +175,7 @@ pub struct SidecarSnapshot { impl SidecarSnapshot { fn from_report(report: SidecarReport, rgba_bytes: Vec) -> Result { let expected_byte_count = expected_rgba_byte_count(report.width, report.height)?; - if report.state != "complete" { + if !is_renderable_state(&report.state) { return Err(ServoSidecarError::IncompleteRender { state: report.state }); } if report.rgba_byte_count != expected_byte_count || rgba_bytes.len() != expected_byte_count @@ -285,6 +285,10 @@ pub enum ServoSidecarError { ContentlessRenderedFrame { requested_url: String }, } +fn is_renderable_state(state: &str) -> bool { + matches!(state, "complete" | "loading") +} + #[derive(Deserialize)] struct SidecarReport { requested_url: String, @@ -354,3 +358,46 @@ fn expected_rgba_byte_count(width: u32, height: u32) -> Result Result<(), ServoSidecarError> { + let snapshot = SidecarSnapshot::from_report(report_with_state("loading"), visible_frame())?; + + assert_eq!(snapshot.loaded_url(), Some("https://example.com/")); + assert_eq!(snapshot.title(), Some("Example Domain")); + assert_eq!(snapshot.width(), 2); + assert_eq!(snapshot.height(), 1); + Ok(()) + } + + #[test] + fn rejects_created_report_with_visible_content() { + let result = SidecarSnapshot::from_report(report_with_state("created"), visible_frame()); + + assert!( + matches!(result, Err(ServoSidecarError::IncompleteRender { state }) if state == "created") + ); + } + + fn report_with_state(state: &str) -> SidecarReport { + SidecarReport { + requested_url: "https://example.com".to_string(), + loaded_url: Some("https://example.com/".to_string()), + title: Some("Example Domain".to_string()), + state: state.to_string(), + width: 2, + height: 1, + rgba_byte_count: 8, + non_white_pixel_count: 1, + content_pixel_count: 1, + } + } + + fn visible_frame() -> Vec { + vec![0, 0, 0, 255, 255, 255, 255, 255] + } +} diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs index b727133..e7e4045 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs @@ -1,12 +1,9 @@ use std::{ - env, - num::ParseIntError, - path::PathBuf, thread, time::{Duration, Instant}, }; -use ely_domain::{ProfileId, TabId, UrlText}; +use ely_domain::{ProfileId, TabId}; use ely_servo_host::{ KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest, ScrollRequest, ServoHost, ServoHostError, ServoSurfaceSize, SoftwareServoHost, TouchTapRequest, @@ -14,96 +11,33 @@ use ely_servo_host::{ }; use thiserror::Error; +#[path = "ely_servo_sidecar/args.rs"] +mod args; #[path = "ely_servo_sidecar/report.rs"] mod report; +use args::{SidecarCommand, SnapshotArgs}; use report::{SnapshotInputChanges, SnapshotReport}; const WAIT_ITERATIONS: usize = 5_000; const WAIT_INTERVAL: Duration = Duration::from_millis(2); const RENDER_TIMEOUT: Duration = Duration::from_secs(20); +const VISIBLE_FRAME_SETTLE_TIMEOUT: Duration = Duration::from_millis(700); const INPUT_SETTLE_TIMEOUT: Duration = Duration::from_millis(700); fn main() -> Result<(), SidecarError> { - match parse_command(env::args())? { + match args::parse_env_command()? { SidecarCommand::Snapshot(args) => run_snapshot(args), } } -enum SidecarCommand { - Snapshot(SnapshotArgs), -} - -struct SnapshotArgs { - url: UrlText, - rgba_out: PathBuf, - width: u32, - height: u32, - scroll_x: i32, - scroll_y: i32, - click_point: Option, - drag_points: Option, - touch_point: Option, - typed_text: Option, -} - -#[derive(Clone, Copy)] -struct ClickPoint { - x: u32, - y: u32, -} - -#[derive(Clone, Copy)] -struct DragPoints { - from: ClickPoint, - to: ClickPoint, -} - #[derive(Debug, Error)] enum SidecarError { - #[error("missing sidecar command")] - MissingCommand, - - #[error("unknown sidecar command: {value}")] - UnknownCommand { value: String }, - - #[error("missing argument value for {name}")] - MissingArgumentValue { name: &'static str }, - - #[error("missing required argument: {name}")] - MissingRequiredArgument { name: &'static str }, - - #[error("unknown argument: {value}")] - UnknownArgument { value: String }, - - #[error("{name} must be an integer: {value}")] - InvalidInteger { - name: &'static str, - value: String, - #[source] - source: ParseIntError, - }, - - #[error("{name} must be greater than zero")] - ZeroDimension { name: &'static str }, - - #[error("--click-x and --click-y must be provided together")] - IncompleteClickPoint, - - #[error("--drag-from-x, --drag-from-y, --drag-to-x, and --drag-to-y must be provided together")] - IncompleteDragPoints, - - #[error("--touch-x and --touch-y must be provided together")] - IncompleteTouchPoint, - - #[error("rgba output path is empty")] - EmptyRgbaOutputPath, - #[error("timed out rendering {url}: {snapshot:?}")] RenderTimeout { url: String, snapshot: Box }, #[error(transparent)] - Domain(#[from] ely_domain::DomainError), + Args(#[from] args::SidecarArgsError), #[error(transparent)] Host(#[from] ServoHostError), @@ -115,179 +49,6 @@ enum SidecarError { Json(#[from] serde_json::Error), } -fn parse_command(args: impl IntoIterator) -> Result { - let mut args = args.into_iter(); - let _program_name = args.next(); - let command = args.next().ok_or(SidecarError::MissingCommand)?; - - match command.as_str() { - "snapshot" => parse_snapshot_args(args).map(SidecarCommand::Snapshot), - _ => Err(SidecarError::UnknownCommand { value: command }), - } -} - -fn parse_snapshot_args( - args: impl IntoIterator, -) -> Result { - let mut args = args.into_iter(); - let mut url = None; - let mut rgba_out = None; - let mut width = None; - let mut height = None; - let mut scroll_x = 0; - let mut scroll_y = 0; - let mut click_x = None; - let mut click_y = None; - let mut drag_from_x = None; - let mut drag_from_y = None; - let mut drag_to_x = None; - let mut drag_to_y = None; - let mut touch_x = None; - let mut touch_y = None; - let mut typed_text = None; - - while let Some(name) = args.next() { - match name.as_str() { - "--url" => url = Some(UrlText::parse(next_argument(&mut args, "--url")?)?), - "--rgba-out" => { - rgba_out = Some(parse_output_path(next_argument(&mut args, "--rgba-out")?)?) - } - "--width" => { - width = Some(parse_dimension("--width", next_argument(&mut args, "--width")?)?) - } - "--height" => { - height = Some(parse_dimension("--height", next_argument(&mut args, "--height")?)?) - } - "--scroll-x" => { - scroll_x = - parse_scroll_delta("--scroll-x", next_argument(&mut args, "--scroll-x")?)? - } - "--scroll-y" => { - scroll_y = - parse_scroll_delta("--scroll-y", next_argument(&mut args, "--scroll-y")?)? - } - "--click-x" => { - click_x = Some(parse_click_coordinate( - "--click-x", - next_argument(&mut args, "--click-x")?, - )?) - } - "--click-y" => { - click_y = Some(parse_click_coordinate( - "--click-y", - next_argument(&mut args, "--click-y")?, - )?) - } - "--drag-from-x" => { - drag_from_x = Some(parse_click_coordinate( - "--drag-from-x", - next_argument(&mut args, "--drag-from-x")?, - )?) - } - "--drag-from-y" => { - drag_from_y = Some(parse_click_coordinate( - "--drag-from-y", - next_argument(&mut args, "--drag-from-y")?, - )?) - } - "--drag-to-x" => { - drag_to_x = Some(parse_click_coordinate( - "--drag-to-x", - next_argument(&mut args, "--drag-to-x")?, - )?) - } - "--drag-to-y" => { - drag_to_y = Some(parse_click_coordinate( - "--drag-to-y", - next_argument(&mut args, "--drag-to-y")?, - )?) - } - "--touch-x" => { - touch_x = Some(parse_click_coordinate( - "--touch-x", - next_argument(&mut args, "--touch-x")?, - )?) - } - "--touch-y" => { - touch_y = Some(parse_click_coordinate( - "--touch-y", - next_argument(&mut args, "--touch-y")?, - )?) - } - "--type-text" => typed_text = Some(next_argument(&mut args, "--type-text")?), - _ => return Err(SidecarError::UnknownArgument { value: name }), - } - } - - let click_point = match (click_x, click_y) { - (Some(x), Some(y)) => Some(ClickPoint { x, y }), - (None, None) => None, - _ => return Err(SidecarError::IncompleteClickPoint), - }; - let drag_points = match (drag_from_x, drag_from_y, drag_to_x, drag_to_y) { - (Some(from_x), Some(from_y), Some(to_x), Some(to_y)) => Some(DragPoints { - from: ClickPoint { x: from_x, y: from_y }, - to: ClickPoint { x: to_x, y: to_y }, - }), - (None, None, None, None) => None, - _ => return Err(SidecarError::IncompleteDragPoints), - }; - let touch_point = match (touch_x, touch_y) { - (Some(x), Some(y)) => Some(ClickPoint { x, y }), - (None, None) => None, - _ => return Err(SidecarError::IncompleteTouchPoint), - }; - - Ok(SnapshotArgs { - url: url.ok_or(SidecarError::MissingRequiredArgument { name: "--url" })?, - rgba_out: rgba_out.ok_or(SidecarError::MissingRequiredArgument { name: "--rgba-out" })?, - width: width.ok_or(SidecarError::MissingRequiredArgument { name: "--width" })?, - height: height.ok_or(SidecarError::MissingRequiredArgument { name: "--height" })?, - scroll_x, - scroll_y, - click_point, - drag_points, - touch_point, - typed_text, - }) -} - -fn next_argument( - args: &mut impl Iterator, - name: &'static str, -) -> Result { - args.next().ok_or(SidecarError::MissingArgumentValue { name }) -} - -fn parse_dimension(name: &'static str, value: String) -> Result { - let dimension = value.parse::().map_err(|source| SidecarError::InvalidInteger { - name, - value, - source, - })?; - if dimension == 0 { - return Err(SidecarError::ZeroDimension { name }); - } - - Ok(dimension) -} - -fn parse_scroll_delta(name: &'static str, value: String) -> Result { - value.parse::().map_err(|source| SidecarError::InvalidInteger { name, value, source }) -} - -fn parse_click_coordinate(name: &'static str, value: String) -> Result { - value.parse::().map_err(|source| SidecarError::InvalidInteger { name, value, source }) -} - -fn parse_output_path(value: String) -> Result { - if value.trim().is_empty() { - return Err(SidecarError::EmptyRgbaOutputPath); - } - - Ok(PathBuf::from(value)) -} - fn run_snapshot(args: SnapshotArgs) -> Result<(), SidecarError> { let mut host = SoftwareServoHost::new(ServoSurfaceSize::new(args.width, args.height))?; let tab_id = TabId::new(); @@ -434,6 +195,10 @@ fn wait_for_frame( url: &str, ) -> Result { let started_at = Instant::now(); + let mut latest_rendered_snapshot = None; + let mut visible_frame_hash = None; + let mut visible_frame_last_changed_at = None; + for _ in 0..WAIT_ITERATIONS { if started_at.elapsed() >= RENDER_TIMEOUT { break; @@ -446,15 +211,36 @@ fn wait_for_frame( } let snapshot = host.snapshot(webview_id)?; - let has_rendered_frame = - host.last_rendered_frame().is_ok_and(|frame| frame.non_white_pixel_count() > 0); - if snapshot.state() == &WebViewState::Complete && has_rendered_frame { - return Ok(snapshot); + if let Ok(frame) = host.last_rendered_frame() + && frame.non_white_pixel_count() > 0 + && frame.content_pixel_count() > 0 + { + let current_hash = frame.sample_hash(); + if visible_frame_hash != Some(current_hash) { + visible_frame_hash = Some(current_hash); + visible_frame_last_changed_at = Some(Instant::now()); + } + + if snapshot.state() == &WebViewState::Complete { + return Ok(snapshot); + } + + latest_rendered_snapshot = Some(snapshot.clone()); + if snapshot.url().is_some() + && visible_frame_last_changed_at + .is_some_and(|changed_at| changed_at.elapsed() >= VISIBLE_FRAME_SETTLE_TIMEOUT) + { + return Ok(snapshot); + } } thread::sleep(WAIT_INTERVAL); } + if let Some(snapshot) = latest_rendered_snapshot { + return Ok(snapshot); + } + Err(SidecarError::RenderTimeout { url: url.to_string(), snapshot: Box::new(host.snapshot(webview_id)?), diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/args.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/args.rs new file mode 100644 index 0000000..b1d0a42 --- /dev/null +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/args.rs @@ -0,0 +1,257 @@ +use std::{env, num::ParseIntError, path::PathBuf}; + +use ely_domain::UrlText; +use thiserror::Error; + +pub(super) enum SidecarCommand { + Snapshot(SnapshotArgs), +} + +pub(super) struct SnapshotArgs { + pub(super) url: UrlText, + pub(super) rgba_out: PathBuf, + pub(super) width: u32, + pub(super) height: u32, + pub(super) scroll_x: i32, + pub(super) scroll_y: i32, + pub(super) click_point: Option, + pub(super) drag_points: Option, + pub(super) touch_point: Option, + pub(super) typed_text: Option, +} + +#[derive(Clone, Copy)] +pub(super) struct ClickPoint { + pub(super) x: u32, + pub(super) y: u32, +} + +#[derive(Clone, Copy)] +pub(super) struct DragPoints { + pub(super) from: ClickPoint, + pub(super) to: ClickPoint, +} + +#[derive(Debug, Error)] +pub(super) enum SidecarArgsError { + #[error("missing sidecar command")] + MissingCommand, + + #[error("unknown sidecar command: {value}")] + UnknownCommand { value: String }, + + #[error("missing argument value for {name}")] + MissingArgumentValue { name: &'static str }, + + #[error("missing required argument: {name}")] + MissingRequiredArgument { name: &'static str }, + + #[error("unknown argument: {value}")] + UnknownArgument { value: String }, + + #[error("{name} must be an integer: {value}")] + InvalidInteger { + name: &'static str, + value: String, + #[source] + source: ParseIntError, + }, + + #[error("{name} must be greater than zero")] + ZeroDimension { name: &'static str }, + + #[error("--click-x and --click-y must be provided together")] + IncompleteClickPoint, + + #[error("--drag-from-x, --drag-from-y, --drag-to-x, and --drag-to-y must be provided together")] + IncompleteDragPoints, + + #[error("--touch-x and --touch-y must be provided together")] + IncompleteTouchPoint, + + #[error("rgba output path is empty")] + EmptyRgbaOutputPath, + + #[error(transparent)] + Domain(#[from] ely_domain::DomainError), +} + +pub(super) fn parse_env_command() -> Result { + parse_command(env::args()) +} + +fn parse_command( + args: impl IntoIterator, +) -> Result { + let mut args = args.into_iter(); + let _program_name = args.next(); + let command = args.next().ok_or(SidecarArgsError::MissingCommand)?; + + match command.as_str() { + "snapshot" => parse_snapshot_args(args).map(SidecarCommand::Snapshot), + _ => Err(SidecarArgsError::UnknownCommand { value: command }), + } +} + +fn parse_snapshot_args( + args: impl IntoIterator, +) -> Result { + let mut args = args.into_iter(); + let mut url = None; + let mut rgba_out = None; + let mut width = None; + let mut height = None; + let mut scroll_x = 0; + let mut scroll_y = 0; + let mut click_x = None; + let mut click_y = None; + let mut drag_from_x = None; + let mut drag_from_y = None; + let mut drag_to_x = None; + let mut drag_to_y = None; + let mut touch_x = None; + let mut touch_y = None; + let mut typed_text = None; + + while let Some(name) = args.next() { + match name.as_str() { + "--url" => url = Some(UrlText::parse(next_argument(&mut args, "--url")?)?), + "--rgba-out" => { + rgba_out = Some(parse_output_path(next_argument(&mut args, "--rgba-out")?)?) + } + "--width" => { + width = Some(parse_dimension("--width", next_argument(&mut args, "--width")?)?) + } + "--height" => { + height = Some(parse_dimension("--height", next_argument(&mut args, "--height")?)?) + } + "--scroll-x" => { + scroll_x = + parse_scroll_delta("--scroll-x", next_argument(&mut args, "--scroll-x")?)? + } + "--scroll-y" => { + scroll_y = + parse_scroll_delta("--scroll-y", next_argument(&mut args, "--scroll-y")?)? + } + "--click-x" => { + click_x = Some(parse_click_coordinate( + "--click-x", + next_argument(&mut args, "--click-x")?, + )?) + } + "--click-y" => { + click_y = Some(parse_click_coordinate( + "--click-y", + next_argument(&mut args, "--click-y")?, + )?) + } + "--drag-from-x" => { + drag_from_x = Some(parse_click_coordinate( + "--drag-from-x", + next_argument(&mut args, "--drag-from-x")?, + )?) + } + "--drag-from-y" => { + drag_from_y = Some(parse_click_coordinate( + "--drag-from-y", + next_argument(&mut args, "--drag-from-y")?, + )?) + } + "--drag-to-x" => { + drag_to_x = Some(parse_click_coordinate( + "--drag-to-x", + next_argument(&mut args, "--drag-to-x")?, + )?) + } + "--drag-to-y" => { + drag_to_y = Some(parse_click_coordinate( + "--drag-to-y", + next_argument(&mut args, "--drag-to-y")?, + )?) + } + "--touch-x" => { + touch_x = Some(parse_click_coordinate( + "--touch-x", + next_argument(&mut args, "--touch-x")?, + )?) + } + "--touch-y" => { + touch_y = Some(parse_click_coordinate( + "--touch-y", + next_argument(&mut args, "--touch-y")?, + )?) + } + "--type-text" => typed_text = Some(next_argument(&mut args, "--type-text")?), + _ => return Err(SidecarArgsError::UnknownArgument { value: name }), + } + } + + let click_point = match (click_x, click_y) { + (Some(x), Some(y)) => Some(ClickPoint { x, y }), + (None, None) => None, + _ => return Err(SidecarArgsError::IncompleteClickPoint), + }; + let drag_points = match (drag_from_x, drag_from_y, drag_to_x, drag_to_y) { + (Some(from_x), Some(from_y), Some(to_x), Some(to_y)) => Some(DragPoints { + from: ClickPoint { x: from_x, y: from_y }, + to: ClickPoint { x: to_x, y: to_y }, + }), + (None, None, None, None) => None, + _ => return Err(SidecarArgsError::IncompleteDragPoints), + }; + let touch_point = match (touch_x, touch_y) { + (Some(x), Some(y)) => Some(ClickPoint { x, y }), + (None, None) => None, + _ => return Err(SidecarArgsError::IncompleteTouchPoint), + }; + + Ok(SnapshotArgs { + url: url.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--url" })?, + rgba_out: rgba_out + .ok_or(SidecarArgsError::MissingRequiredArgument { name: "--rgba-out" })?, + width: width.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--width" })?, + height: height.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--height" })?, + scroll_x, + scroll_y, + click_point, + drag_points, + touch_point, + typed_text, + }) +} + +fn next_argument( + args: &mut impl Iterator, + name: &'static str, +) -> Result { + args.next().ok_or(SidecarArgsError::MissingArgumentValue { name }) +} + +fn parse_dimension(name: &'static str, value: String) -> Result { + let dimension = value.parse::().map_err(|source| SidecarArgsError::InvalidInteger { + name, + value, + source, + })?; + if dimension == 0 { + return Err(SidecarArgsError::ZeroDimension { name }); + } + + Ok(dimension) +} + +fn parse_scroll_delta(name: &'static str, value: String) -> Result { + value.parse::().map_err(|source| SidecarArgsError::InvalidInteger { name, value, source }) +} + +fn parse_click_coordinate(name: &'static str, value: String) -> Result { + value.parse::().map_err(|source| SidecarArgsError::InvalidInteger { name, value, source }) +} + +fn parse_output_path(value: String) -> Result { + if value.trim().is_empty() { + return Err(SidecarArgsError::EmptyRgbaOutputPath); + } + + Ok(PathBuf::from(value)) +} diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/report.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/report.rs index f1432b8..1a61cc5 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/report.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/report.rs @@ -1,7 +1,7 @@ use ely_servo_host::{RenderedFrame, WebViewSnapshot, WebViewState}; use serde::Serialize; -use super::SnapshotArgs; +use super::args::SnapshotArgs; pub(super) struct SnapshotInputChanges { pub(super) scroll: bool, diff --git a/crates/ely_servo_host/tests/sidecar.rs b/crates/ely_servo_host/tests/sidecar.rs index 0d858b5..f9727fd 100644 --- a/crates/ely_servo_host/tests/sidecar.rs +++ b/crates/ely_servo_host/tests/sidecar.rs @@ -18,6 +18,15 @@ fn sidecar_snapshots_prd_sites_to_rgba_files() -> Result<(), Box> { Ok(()) } +#[test] +fn sidecar_snapshots_prd_reference_sites_to_rgba_files() -> Result<(), Box> { + for case in PRD_REFERENCE_SITE_COMPATIBILITY_CASES { + snapshot_prd_site(case, PRD_REFERENCE_SITE_SIZE, ScrollOffset::ZERO)?; + } + + Ok(()) +} + #[test] fn sidecar_scrolls_prd_site_with_servo_input() -> Result<(), Box> { let initial_report = diff --git a/crates/ely_servo_host/tests/sidecar/support.rs b/crates/ely_servo_host/tests/sidecar/support.rs index 4b656ab..d541846 100644 --- a/crates/ely_servo_host/tests/sidecar/support.rs +++ b/crates/ely_servo_host/tests/sidecar/support.rs @@ -2,24 +2,105 @@ use std::{ error::Error, io, process::{Child, Command, Output, Stdio}, + sync::Mutex, thread, time::{Duration, Instant}, }; pub(super) const MINIMUM_CONTENT_PIXELS: u64 = 1_000; -const SIDECAR_TIMEOUT: Duration = Duration::from_secs(25); +const SIDECAR_TIMEOUT: Duration = Duration::from_secs(45); const SIDECAR_POLL_INTERVAL: Duration = Duration::from_millis(20); +const SIDECAR_RETRY_INTERVAL: Duration = Duration::from_millis(250); +static SIDECAR_COMMAND_LOCK: Mutex<()> = Mutex::new(()); pub(super) const PRD_SITE_COMPATIBILITY_CASES: &[PrdSiteCompatibilityCase] = &[ PrdSiteCompatibilityCase { url: "https://example.com", title_fragment: "Example Domain" }, - PrdSiteCompatibilityCase { url: "https://servo.org", title_fragment: "Servo" }, + PrdSiteCompatibilityCase { url: "https://servo.org/", title_fragment: "Servo" }, +]; +pub(super) const PRD_REFERENCE_SITE_COMPATIBILITY_CASES: &[PrdSiteCompatibilityCase] = &[ + PrdSiteCompatibilityCase { + url: "https://blog.google/products-and-platforms/products/chrome/new-chrome-productivity-features/", + title_fragment: "Chrome", + }, + PrdSiteCompatibilityCase { + url: "https://www.microsoft.com/en-us/edge/features/vertical-tabs", + title_fragment: "Microsoft Edge", + }, + PrdSiteCompatibilityCase { + url: "https://resources.arc.net/hc/en-us/articles/19230755904151-Favorites-Top-Tabs-Across-Every-Space", + title_fragment: "Favorites", + }, + PrdSiteCompatibilityCase { + url: "https://resources.arc.net/hc/en-us/articles/19228855311127-Auto-Archive-Clean-as-you-go", + title_fragment: "Auto Archive", + }, + PrdSiteCompatibilityCase { + url: "https://vivaldi.com/features/workspaces/", + title_fragment: "Workspaces", + }, + PrdSiteCompatibilityCase { + url: "https://help.vivaldi.com/desktop/tabs/tab-tiling/", + title_fragment: "Tab Tiling", + }, + PrdSiteCompatibilityCase { url: "https://www.gpui.rs/", title_fragment: "gpui" }, + PrdSiteCompatibilityCase { url: "https://docs.rs/gpui/latest/gpui/", title_fragment: "gpui" }, + PrdSiteCompatibilityCase { + url: "https://zed.dev/blog/videogame", + title_fragment: "Leveraging Rust", + }, + PrdSiteCompatibilityCase { + url: "https://github.com/longbridge/gpui-component/", + title_fragment: "gpui-component", + }, + PrdSiteCompatibilityCase { + url: "https://github.com/zed-industries/awesome-gpui/", + title_fragment: "awesome-gpui", + }, + PrdSiteCompatibilityCase { url: "https://servo.org/", title_fragment: "Servo" }, + PrdSiteCompatibilityCase { + url: "https://servo.org/blog/2026/04/13/servo-0.1.0-release/", + title_fragment: "Servo", + }, + PrdSiteCompatibilityCase { + url: "https://developers.cloudflare.com/d1/", + title_fragment: "Cloudflare", + }, + PrdSiteCompatibilityCase { + url: "https://developers.cloudflare.com/workers/platform/storage-options/", + title_fragment: "Cloudflare", + }, + PrdSiteCompatibilityCase { + url: "https://developers.cloudflare.com/kv/concepts/how-kv-works/", + title_fragment: "Cloudflare", + }, + PrdSiteCompatibilityCase { + url: "https://better-auth.com/blog/1-5", + title_fragment: "Better Auth", + }, + PrdSiteCompatibilityCase { + url: "https://developers.cloudflare.com/d1/platform/limits/", + title_fragment: "Cloudflare", + }, + PrdSiteCompatibilityCase { + url: "https://component-model.bytecodealliance.org/", + title_fragment: "WebAssembly Component Model", + }, + PrdSiteCompatibilityCase { + url: "https://docs.wasmtime.dev/api/wasmtime/component/index.html", + title_fragment: "wasmtime", + }, + PrdSiteCompatibilityCase { + url: "https://docs.wasmtime.dev/security.html", + title_fragment: "Wasmtime", + }, ]; pub(super) const PRD_SITE_COMPATIBILITY_SIZES: &[FrameSize] = &[ FrameSize { width: 640, height: 480 }, FrameSize { width: 934, height: 657 }, FrameSize { width: 1614, height: 980 }, ]; +pub(super) const PRD_REFERENCE_SITE_SIZE: FrameSize = FrameSize { width: 934, height: 657 }; pub(super) const SERVO_SCROLL_SITE: PrdSiteCompatibilityCase = - PrdSiteCompatibilityCase { url: "https://servo.org", title_fragment: "Servo" }; + PrdSiteCompatibilityCase { url: "https://servo.org/", title_fragment: "Servo" }; pub(super) const SERVO_SCROLL_SIZE: FrameSize = FrameSize { width: 934, height: 657 }; pub(super) const SERVO_SCROLL_OFFSET: ScrollOffset = ScrollOffset { x: 0, y: 480 }; const SERVO_CLICK_URL: &str = "data:text/html,%3C!doctype%20html%3E%3Ctitle%3EClick%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3B%7D%3C%2Fstyle%3E%3Cbutton%20onclick%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Clicked%27%3Bthis.textContent%3D%27Clicked%27%3B%22%3ETap%3C%2Fbutton%3E"; @@ -97,11 +178,7 @@ pub(super) fn snapshot_prd_site( scroll_offset.y )); - if output_path.exists() { - std::fs::remove_file(&output_path)?; - } - - let output = run_sidecar_snapshot( + let output = run_sidecar_snapshot_with_retry( case.url, &output_path, size, @@ -121,6 +198,7 @@ pub(super) fn snapshot_prd_site( ); let report: serde_json::Value = serde_json::from_slice(&output.stdout)?; + assert_report_state_is_renderable(&report)?; assert_eq!(report_field_as_u64(&report, "width")?, size.width, "{}", case.url); assert_eq!(report_field_as_u64(&report, "height")?, size.height, "{}", case.url); assert_eq!( @@ -209,7 +287,8 @@ fn snapshot_probe( std::fs::remove_file(&output_path)?; } - let output = run_sidecar_snapshot(url, &output_path, size, ScrollOffset::ZERO, input)?; + let output = + run_sidecar_snapshot_with_retry(url, &output_path, size, ScrollOffset::ZERO, input)?; assert!( output.status.success(), @@ -236,6 +315,9 @@ fn run_sidecar_snapshot( scroll_offset: ScrollOffset, input: SnapshotInput<'_>, ) -> Result> { + let _guard = SIDECAR_COMMAND_LOCK + .lock() + .map_err(|_| io::Error::other("sidecar command lock poisoned"))?; let mut command = Command::new(env!("CARGO_BIN_EXE_ely_servo_sidecar")); command .arg("snapshot") @@ -291,6 +373,32 @@ fn run_sidecar_snapshot( } } +fn run_sidecar_snapshot_with_retry( + site_url: &str, + output_path: &std::path::Path, + size: FrameSize, + scroll_offset: ScrollOffset, + input: SnapshotInput<'_>, +) -> Result> { + for attempt in 0..2 { + if output_path.exists() { + std::fs::remove_file(output_path)?; + } + + match run_sidecar_snapshot(site_url, output_path, size, scroll_offset, input) { + Ok(output) if output.status.success() => return Ok(output), + Ok(output) if attempt == 1 => return Ok(output), + Ok(_output) => {} + Err(error) if attempt == 1 => return Err(error), + Err(_error) => {} + } + + thread::sleep(SIDECAR_RETRY_INTERVAL); + } + + Err("sidecar snapshot retry did not produce output".into()) +} + fn terminate_child(mut child: Child) -> Result<(), Box> { match child.kill() { Ok(()) => { @@ -315,6 +423,15 @@ fn assert_report_text_contains( Ok(()) } +fn assert_report_state_is_renderable(report: &serde_json::Value) -> Result<(), Box> { + let state = report + .get("state") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "missing text report field: state".to_string())?; + assert!(matches!(state, "complete" | "loading"), "state: {state}"); + Ok(()) +} + pub(super) fn report_field_as_bool( report: &serde_json::Value, field: &'static str, diff --git a/crates/ely_servo_host/tests/software_host.rs b/crates/ely_servo_host/tests/software_host.rs index 7295fff..855f79c 100644 --- a/crates/ely_servo_host/tests/software_host.rs +++ b/crates/ely_servo_host/tests/software_host.rs @@ -16,7 +16,7 @@ const RESIZED_WIDTH: u32 = 934; const RESIZED_HEIGHT: u32 = 657; const PRD_SITE_COMPATIBILITY_CASES: &[PrdSiteCompatibilityCase] = &[ PrdSiteCompatibilityCase { url: "https://example.com", title_fragment: "Example Domain" }, - PrdSiteCompatibilityCase { url: "https://servo.org", title_fragment: "Servo" }, + PrdSiteCompatibilityCase { url: "https://servo.org/", title_fragment: "Servo" }, ]; const CLICK_PROBE_URL: &str = "data:text/html,%3C!doctype%20html%3E%3Ctitle%3EClick%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3B%7D%3C%2Fstyle%3E%3Cbutton%20onclick%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Clicked%27%3Bthis.textContent%3D%27Clicked%27%3B%22%3ETap%3C%2Fbutton%3E"; const DRAG_PROBE_URL: &str = "data:text/html,%3C%21doctype%20html%3E%3Ctitle%3EDrag%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3B%7D%3C%2Fstyle%3E%3Cbutton%20id%3Dbox%3EDrag%3C%2Fbutton%3E%3Cscript%3Elet%20dragging%3Dfalse%3Bconst%20box%3Ddocument.getElementById%28%27box%27%29%3BaddEventListener%28%27mousedown%27%2Cevent%3D%3E%7Bif%28event.target%3D%3D%3Dbox%29%7Bdragging%3Dtrue%3B%7D%7D%29%3BaddEventListener%28%27mousemove%27%2Cevent%3D%3E%7Bif%28dragging%26%26event.clientX%3E280%29%7Bdocument.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Dragged%27%3Bbox.textContent%3D%27Dragged%27%3B%7D%7D%29%3BaddEventListener%28%27mouseup%27%2C%28%29%3D%3E%7Bdragging%3Dfalse%3B%7D%29%3B%3C%2Fscript%3E";