diff --git a/crates/ely_app/Cargo.toml b/crates/ely_app/Cargo.toml index 049762a..d4cb6a8 100644 --- a/crates/ely_app/Cargo.toml +++ b/crates/ely_app/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true license.workspace = true rust-version.workspace = true +[features] +live-site-smoke = [] + [dependencies] directories.workspace = true ed25519-dalek.workspace = true diff --git a/crates/ely_app/src/services/servo_sidecar.rs b/crates/ely_app/src/services/servo_sidecar.rs index 371bc21..f11ccf1 100644 --- a/crates/ely_app/src/services/servo_sidecar.rs +++ b/crates/ely_app/src/services/servo_sidecar.rs @@ -13,6 +13,9 @@ use thiserror::Error; const SIDECAR_BINARY_TIMEOUT: Duration = Duration::from_secs(35); const SIDECAR_CARGO_TIMEOUT: Duration = Duration::from_secs(180); const SIDECAR_POLL_INTERVAL: Duration = Duration::from_millis(20); +const SIDECAR_RETRY_INTERVAL: Duration = Duration::from_millis(250); +const SIDECAR_NAVIGATION_ATTEMPTS: usize = 3; +const SIDECAR_INTERACTION_ATTEMPTS: usize = 1; const SIDECAR_PATH_ENV: &str = "ELY_SERVO_SIDECAR"; #[derive(Clone, Debug)] @@ -33,8 +36,27 @@ impl ServoSidecarClient { return Err(ServoSidecarError::SidecarBinaryUnavailable { path: path.to_path_buf() }); } + let mut attempt = 0; + loop { + match self.snapshot_once(&request) { + Ok(snapshot) => return Ok(snapshot), + Err(error) => { + attempt += 1; + if attempt >= request.max_attempts() { + return Err(error); + } + thread::sleep(SIDECAR_RETRY_INTERVAL); + } + } + } + } + + fn snapshot_once( + &self, + request: &SidecarSnapshotRequest, + ) -> Result { let rgba_path = temporary_rgba_path()?; - let output = match self.run_snapshot_command(&request, &rgba_path) { + let output = match self.run_snapshot_command(request, &rgba_path) { Ok(output) => output, Err(error) => { remove_temporary_file(&rgba_path)?; @@ -204,6 +226,14 @@ impl SidecarSnapshotRequest { pub(crate) fn typed_text_for_test(&self) -> Option<&str> { self.typed_text.as_deref() } + + fn max_attempts(&self) -> usize { + if self.click_point.is_some() || self.typed_text.is_some() { + return SIDECAR_INTERACTION_ATTEMPTS; + } + + SIDECAR_NAVIGATION_ATTEMPTS + } } #[derive(Clone, Copy, Debug)] @@ -437,44 +467,5 @@ 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] - } -} +#[path = "servo_sidecar_tests.rs"] +mod tests; diff --git a/crates/ely_app/src/services/servo_sidecar_tests.rs b/crates/ely_app/src/services/servo_sidecar_tests.rs new file mode 100644 index 0000000..d6460c8 --- /dev/null +++ b/crates/ely_app/src/services/servo_sidecar_tests.rs @@ -0,0 +1,205 @@ +use std::error::Error; + +use super::*; + +#[cfg(feature = "live-site-smoke")] +const LIVE_SITE_WIDTH: u32 = 934; +#[cfg(feature = "live-site-smoke")] +const LIVE_SITE_HEIGHT: u32 = 657; +#[cfg(feature = "live-site-smoke")] +const PRD_TOP_SITE_CASES: &[LiveSiteCase] = &[ + LiveSiteCase { url: "https://github.com", title_fragment: "GitHub" }, + LiveSiteCase { url: "https://example.com", title_fragment: "Example Domain" }, + LiveSiteCase { url: "https://servo.org/", title_fragment: "Servo" }, +]; +#[cfg(feature = "live-site-smoke")] +const PRD_REFERENCE_SITE_CASES: &[LiveSiteCase] = &[ + LiveSiteCase { + url: "https://blog.google/products-and-platforms/products/chrome/new-chrome-productivity-features/", + title_fragment: "Chrome", + }, + LiveSiteCase { + url: "https://www.microsoft.com/en-us/edge/features/vertical-tabs", + title_fragment: "Microsoft Edge", + }, + LiveSiteCase { + url: "https://resources.arc.net/hc/en-us/articles/19230755904151-Favorites-Top-Tabs-Across-Every-Space", + title_fragment: "Favorites", + }, + LiveSiteCase { + url: "https://resources.arc.net/hc/en-us/articles/19228855311127-Auto-Archive-Clean-as-you-go", + title_fragment: "Auto Archive", + }, + LiveSiteCase { url: "https://vivaldi.com/features/workspaces/", title_fragment: "Workspaces" }, + LiveSiteCase { + url: "https://help.vivaldi.com/desktop/tabs/tab-tiling/", + title_fragment: "Tab Tiling", + }, + LiveSiteCase { url: "https://www.gpui.rs/", title_fragment: "gpui" }, + LiveSiteCase { url: "https://docs.rs/gpui/latest/gpui/", title_fragment: "gpui" }, + LiveSiteCase { url: "https://zed.dev/blog/videogame", title_fragment: "Leveraging Rust" }, + LiveSiteCase { + url: "https://github.com/longbridge/gpui-component/", + title_fragment: "gpui-component", + }, + LiveSiteCase { + url: "https://github.com/zed-industries/awesome-gpui/", + title_fragment: "awesome-gpui", + }, + LiveSiteCase { url: "https://servo.org/", title_fragment: "Servo" }, + LiveSiteCase { + url: "https://servo.org/blog/2026/04/13/servo-0.1.0-release/", + title_fragment: "Servo", + }, + LiveSiteCase { url: "https://developers.cloudflare.com/d1/", title_fragment: "Cloudflare" }, + LiveSiteCase { + url: "https://developers.cloudflare.com/workers/platform/storage-options/", + title_fragment: "Cloudflare", + }, + LiveSiteCase { + url: "https://developers.cloudflare.com/kv/concepts/how-kv-works/", + title_fragment: "Cloudflare", + }, + LiveSiteCase { url: "https://better-auth.com/blog/1-5", title_fragment: "Better Auth" }, + LiveSiteCase { + url: "https://developers.cloudflare.com/d1/platform/limits/", + title_fragment: "Cloudflare", + }, + LiveSiteCase { + url: "https://component-model.bytecodealliance.org/", + title_fragment: "WebAssembly Component Model", + }, + LiveSiteCase { + url: "https://docs.wasmtime.dev/api/wasmtime/component/index.html", + title_fragment: "wasmtime", + }, + LiveSiteCase { url: "https://docs.wasmtime.dev/security.html", title_fragment: "Wasmtime" }, +]; + +#[cfg(feature = "live-site-smoke")] +struct LiveSiteCase { + url: &'static str, + title_fragment: &'static str, +} + +#[test] +fn accepts_loading_report_with_visible_content() -> 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") + ); +} + +#[test] +fn retries_navigation_snapshots() -> Result<(), Box> { + let request = SidecarSnapshotRequest::new(UrlText::parse("https://example.com")?, 2, 1); + + assert_eq!(request.max_attempts(), SIDECAR_NAVIGATION_ATTEMPTS); + Ok(()) +} + +#[test] +fn keeps_page_interactions_single_attempt() -> Result<(), Box> { + let request = SidecarSnapshotRequest::new(UrlText::parse("https://example.com")?, 2, 1) + .with_click_point(1, 1) + .with_typed_text("ely".to_string()); + + assert_eq!(request.max_attempts(), SIDECAR_INTERACTION_ATTEMPTS); + Ok(()) +} + +#[cfg(feature = "live-site-smoke")] +#[test] +fn desktop_sidecar_opens_prd_top_sites() -> Result<(), Box> { + assert_live_sites_render(PRD_TOP_SITE_CASES) +} + +#[cfg(feature = "live-site-smoke")] +#[test] +fn desktop_sidecar_opens_prd_reference_sites() -> Result<(), Box> { + assert_live_sites_render(PRD_REFERENCE_SITE_CASES) +} + +#[cfg(feature = "live-site-smoke")] +fn assert_live_sites_render(cases: &[LiveSiteCase]) -> Result<(), Box> { + let client = ServoSidecarClient::new()?; + for case in cases { + let request = SidecarSnapshotRequest::new( + UrlText::parse(case.url)?, + LIVE_SITE_WIDTH, + LIVE_SITE_HEIGHT, + ); + let snapshot = client.snapshot(request)?; + + assert_eq!(snapshot.width(), LIVE_SITE_WIDTH, "{}", case.url); + assert_eq!(snapshot.height(), LIVE_SITE_HEIGHT, "{}", case.url); + assert_loaded_url_contains(&snapshot, case.url)?; + assert_title_contains(&snapshot, case.title_fragment)?; + + let rgba_bytes = snapshot.into_rgba_bytes(); + assert_eq!( + rgba_bytes.len(), + expected_rgba_byte_count(LIVE_SITE_WIDTH, LIVE_SITE_HEIGHT)?, + "{}", + case.url + ); + assert!(non_white_pixel_count(&rgba_bytes) > 0, "{}", case.url); + } + Ok(()) +} + +#[cfg(feature = "live-site-smoke")] +fn assert_loaded_url_contains( + snapshot: &SidecarSnapshot, + fragment: &str, +) -> Result<(), Box> { + let loaded_url = + snapshot.loaded_url().ok_or_else(|| format!("missing loaded URL for {fragment}"))?; + assert!(loaded_url.contains(fragment), "loaded_url: {loaded_url}"); + Ok(()) +} + +#[cfg(feature = "live-site-smoke")] +fn assert_title_contains(snapshot: &SidecarSnapshot, fragment: &str) -> Result<(), Box> { + let title = snapshot.title().ok_or_else(|| format!("missing title containing {fragment}"))?; + assert!(title.contains(fragment), "title: {title}"); + Ok(()) +} + +#[cfg(feature = "live-site-smoke")] +fn non_white_pixel_count(rgba_bytes: &[u8]) -> usize { + rgba_bytes + .chunks_exact(4) + .filter(|pixel| pixel[3] > 0 && (pixel[0] != 255 || pixel[1] != 255 || pixel[2] != 255)) + .count() +} + +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 e7e4045..27ebfec 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs @@ -1,4 +1,5 @@ use std::{ + io::Write, thread, time::{Duration, Instant}, }; @@ -75,8 +76,9 @@ fn run_snapshot(args: SnapshotArgs) -> Result<(), SidecarError> { let frame = host.last_rendered_frame()?; std::fs::write(&args.rgba_out, frame.rgba_bytes())?; + let mut stdout = std::io::stdout().lock(); serde_json::to_writer( - std::io::stdout().lock(), + &mut stdout, &SnapshotReport::new( &args, &snapshot, @@ -90,7 +92,9 @@ fn run_snapshot(args: SnapshotArgs) -> Result<(), SidecarError> { }, ), )?; - Ok(()) + stdout.write_all(b"\n")?; + stdout.flush()?; + std::process::exit(0); } fn apply_scroll_if_requested(