diff --git a/Cargo.lock b/Cargo.lock index 74ed358..e14e4e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2250,6 +2250,9 @@ dependencies = [ "gpui", "gpui-component", "gpui-component-assets", + "image", + "serde", + "serde_json", "sha2", "thiserror 2.0.18", "toml 1.1.2+spec-1.1.0", diff --git a/Cargo.toml b/Cargo.toml index aadf8f8..d186b66 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ ed25519-dalek = "2.2.0" gpui = "0.2.2" gpui-component = "0.5.1" gpui-component-assets = "0.5.1" +image = "0.25.10" servo = "0.1.0" sha2 = "0.10.9" semver = "1.0.28" diff --git a/crates/ely_app/Cargo.toml b/crates/ely_app/Cargo.toml index e8af6b3..049762a 100644 --- a/crates/ely_app/Cargo.toml +++ b/crates/ely_app/Cargo.toml @@ -14,6 +14,9 @@ ely_domain = { path = "../ely_domain" } gpui.workspace = true gpui-component.workspace = true gpui-component-assets.workspace = true +image.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true sha2.workspace = true thiserror.workspace = true diff --git a/crates/ely_app/src/services/mod.rs b/crates/ely_app/src/services/mod.rs index f22de69..5565620 100644 --- a/crates/ely_app/src/services/mod.rs +++ b/crates/ely_app/src/services/mod.rs @@ -3,6 +3,7 @@ pub mod download_files; pub mod plugin_package_store; pub mod plugin_packages; pub mod plugin_signatures; +pub mod servo_sidecar; #[cfg(test)] mod plugin_package_test_support; diff --git a/crates/ely_app/src/services/servo_sidecar.rs b/crates/ely_app/src/services/servo_sidecar.rs new file mode 100644 index 0000000..e9e303c --- /dev/null +++ b/crates/ely_app/src/services/servo_sidecar.rs @@ -0,0 +1,253 @@ +use std::{ + env, fs, io, + path::{Path, PathBuf}, + process::Command, + time::{SystemTime, SystemTimeError, UNIX_EPOCH}, +}; + +use ely_domain::UrlText; +use serde::Deserialize; +use thiserror::Error; + +#[derive(Clone, Debug)] +pub struct ServoSidecarClient { + binary_path: PathBuf, +} + +impl ServoSidecarClient { + pub fn new() -> Result { + Ok(Self { binary_path: default_sidecar_path()? }) + } + + pub fn snapshot( + &self, + request: SidecarSnapshotRequest, + ) -> Result { + if !self.binary_path.is_file() { + return Err(ServoSidecarError::SidecarBinaryUnavailable { + path: self.binary_path.clone(), + }); + } + + let rgba_path = temporary_rgba_path()?; + let output = self.run_snapshot_command(&request, &rgba_path)?; + + if !output.status.success() { + remove_temporary_file(&rgba_path)?; + return Err(ServoSidecarError::SidecarFailed { + status: output.status.to_string(), + stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), + }); + } + + let snapshot = read_sidecar_snapshot(&output.stdout, &rgba_path); + let cleanup = remove_temporary_file(&rgba_path); + match (snapshot, cleanup) { + (Ok(snapshot), Ok(())) => Ok(snapshot), + (Err(error), Ok(())) => Err(error), + (Ok(_), Err(error)) | (Err(_), Err(error)) => Err(error), + } + } + + fn run_snapshot_command( + &self, + request: &SidecarSnapshotRequest, + rgba_path: &Path, + ) -> Result { + Command::new(&self.binary_path) + .arg("snapshot") + .arg("--url") + .arg(request.url.as_str()) + .arg("--rgba-out") + .arg(rgba_path) + .arg("--width") + .arg(request.width.to_string()) + .arg("--height") + .arg(request.height.to_string()) + .output() + .map_err(ServoSidecarError::Command) + } +} + +#[derive(Clone, Debug)] +pub struct SidecarSnapshotRequest { + url: UrlText, + width: u32, + height: u32, +} + +impl SidecarSnapshotRequest { + #[must_use] + pub fn new(url: UrlText, width: u32, height: u32) -> Self { + Self { url, width, height } + } +} + +#[derive(Clone, Debug)] +pub struct SidecarSnapshot { + loaded_url: Option, + title: Option, + width: u32, + height: u32, + rgba_bytes: Vec, +} + +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" { + return Err(ServoSidecarError::IncompleteRender { state: report.state }); + } + if report.rgba_byte_count != expected_byte_count || rgba_bytes.len() != expected_byte_count + { + return Err(ServoSidecarError::RgbaByteCountMismatch { + expected: expected_byte_count, + reported: report.rgba_byte_count, + actual: rgba_bytes.len(), + }); + } + if report.non_white_pixel_count == 0 { + return Err(ServoSidecarError::BlankRenderedFrame { + requested_url: report.requested_url, + }); + } + + Ok(Self { + loaded_url: report.loaded_url, + title: report.title, + width: report.width, + height: report.height, + rgba_bytes, + }) + } + + #[must_use] + pub fn loaded_url(&self) -> Option<&str> { + self.loaded_url.as_deref() + } + + #[must_use] + pub fn title(&self) -> Option<&str> { + self.title.as_deref() + } + + #[must_use] + pub fn width(&self) -> u32 { + self.width + } + + #[must_use] + pub fn height(&self) -> u32 { + self.height + } + + #[must_use] + pub fn into_rgba_bytes(self) -> Vec { + self.rgba_bytes + } +} + +#[derive(Debug, Error)] +pub enum ServoSidecarError { + #[error("current executable path is unavailable: {0}")] + CurrentExecutable(#[source] io::Error), + + #[error("current executable directory is unavailable for {path}")] + CurrentExecutableDirectoryUnavailable { path: PathBuf }, + + #[error("servo sidecar binary is unavailable at {path}")] + SidecarBinaryUnavailable { path: PathBuf }, + + #[error("temporary frame directory is unavailable: {0}")] + TempDirectory(#[source] io::Error), + + #[error("system clock is before UNIX epoch")] + SystemClock(#[from] SystemTimeError), + + #[error("failed to run servo sidecar: {0}")] + Command(#[source] io::Error), + + #[error("servo sidecar exited with {status}: {stderr}")] + SidecarFailed { status: String, stderr: String }, + + #[error("failed to parse servo sidecar report: {0}")] + Report(#[from] serde_json::Error), + + #[error("failed to read servo frame file: {0}")] + FrameRead(#[source] io::Error), + + #[error("failed to remove servo frame file: {0}")] + FrameCleanup(#[source] io::Error), + + #[error("servo sidecar returned incomplete render state: {state}")] + IncompleteRender { state: String }, + + #[error( + "servo frame byte count mismatch: expected {expected}, reported {reported}, actual {actual}" + )] + RgbaByteCountMismatch { expected: usize, reported: usize, actual: usize }, + + #[error("servo frame dimensions overflow byte count: {width}x{height}")] + RgbaByteCountOverflow { width: u32, height: u32 }, + + #[error("servo rendered a blank frame for {requested_url}")] + BlankRenderedFrame { requested_url: String }, +} + +#[derive(Deserialize)] +struct SidecarReport { + requested_url: String, + loaded_url: Option, + title: Option, + state: String, + width: u32, + height: u32, + rgba_byte_count: usize, + non_white_pixel_count: u64, +} + +fn default_sidecar_path() -> Result { + let current_exe = env::current_exe().map_err(ServoSidecarError::CurrentExecutable)?; + let exe_dir = current_exe.parent().ok_or_else(|| { + ServoSidecarError::CurrentExecutableDirectoryUnavailable { path: current_exe.clone() } + })?; + + Ok(exe_dir.join(sidecar_binary_name())) +} + +fn sidecar_binary_name() -> String { + format!("ely_servo_sidecar{}", env::consts::EXE_SUFFIX) +} + +fn temporary_rgba_path() -> Result { + let directory = env::temp_dir().join("ely-browser-servo"); + fs::create_dir_all(&directory).map_err(ServoSidecarError::TempDirectory)?; + let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + Ok(directory.join(format!("frame-{}-{timestamp}.rgba", std::process::id()))) +} + +fn remove_temporary_file(path: &Path) -> Result<(), ServoSidecarError> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(ServoSidecarError::FrameCleanup(error)), + } +} + +fn read_sidecar_snapshot( + stdout: &[u8], + rgba_path: &Path, +) -> Result { + let report: SidecarReport = serde_json::from_slice(stdout)?; + let rgba_bytes = fs::read(rgba_path).map_err(ServoSidecarError::FrameRead)?; + SidecarSnapshot::from_report(report, rgba_bytes) +} + +fn expected_rgba_byte_count(width: u32, height: u32) -> Result { + let byte_count = u64::from(width) + .checked_mul(u64::from(height)) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or(ServoSidecarError::RgbaByteCountOverflow { width, height })?; + usize::try_from(byte_count) + .map_err(|_| ServoSidecarError::RgbaByteCountOverflow { width, height }) +} diff --git a/crates/ely_app/src/shell/focus.rs b/crates/ely_app/src/shell/focus.rs new file mode 100644 index 0000000..0b1d26d --- /dev/null +++ b/crates/ely_app/src/shell/focus.rs @@ -0,0 +1,24 @@ +use gpui::{App, Context, FocusHandle, Focusable, Window}; + +use super::{ElyShell, ShellState}; + +impl ElyShell { + pub(super) fn sync_address_input(&mut self, window: &mut Window, cx: &mut Context) { + let ShellState::Ready(core) = &mut self.state else { + return; + }; + + if let Ok(active_tab) = core.active_tab() { + let value = active_tab.url().as_str().to_string(); + self.command_input.update(cx, |input, cx| { + input.set_value(value, window, cx); + }); + } + } +} + +impl Focusable for ElyShell { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} diff --git a/crates/ely_app/src/shell/internal_pages.rs b/crates/ely_app/src/shell/internal_pages.rs index c4ad057..1feedfe 100644 --- a/crates/ely_app/src/shell/internal_pages.rs +++ b/crates/ely_app/src/shell/internal_pages.rs @@ -85,6 +85,9 @@ impl ElyShell { "ely://settings/profiles" => self.render_profiles_page(snapshot, cx), "ely://settings/sync" => self.render_sync_page(snapshot, cx), "ely://sync/status" => self.render_sync_page(snapshot, cx), + url if super::web_surface::is_external_web_url(url) => { + self.render_external_web_canvas(tab, cx) + } _ => render_default_page(tab), } } diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index 5bb06a3..339acfe 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -1,6 +1,7 @@ mod archive_labels; mod bookmarks; mod downloads; +mod focus; mod history; mod internal_pages; mod notes; @@ -13,6 +14,7 @@ mod spaces; mod splits; mod tab_groups; mod tab_lifecycle; +mod web_surface; use ely_browser_core::{BrowserCore, InitialBrowserConfig}; use ely_domain::{ @@ -20,13 +22,14 @@ use ely_domain::{ NewTabDestination, ProfileId, ProfileSyncPolicy, SearchEngine, SpaceId, SyncObjectKind, SyncObjectPolicy, TabId, UrlText, }; -use gpui::{App, AppContext, Context, Entity, FocusHandle, Focusable, Subscription, Window}; +use gpui::{AppContext, Context, Entity, FocusHandle, Subscription, Window}; use gpui_component::input::{InputEvent, InputState, SelectAll}; use bookmarks::PendingBookmarkEdit; use downloads::PendingDownloadFileAction; use history::{PendingHistoryDomainClear, PendingHistoryTimeClear}; use plugins::{PendingPluginInstall, PendingPluginUninstall}; +use web_surface::WebSurfaceStore; use crate::{ CloseCurrentTab, FocusAddressBar, FocusCommandMode, OpenDownloads, OpenHistory, OpenNewTab, @@ -57,6 +60,7 @@ pub struct ElyShell { plugin_install_error: Option, pending_plugin_install: Option, pending_plugin_uninstall: Option, + web_surfaces: WebSurfaceStore, _command_subscription: Subscription, } @@ -125,6 +129,7 @@ impl ElyShell { plugin_install_error: None, pending_plugin_install: None, pending_plugin_uninstall: None, + web_surfaces: WebSurfaceStore::new(), _command_subscription: command_subscription, } } @@ -477,23 +482,4 @@ impl ElyShell { ) { self.toggle_active_tab_pinned(cx); } - - fn sync_address_input(&mut self, window: &mut Window, cx: &mut Context) { - let ShellState::Ready(core) = &mut self.state else { - return; - }; - - if let Ok(active_tab) = core.active_tab() { - let value = active_tab.url().as_str().to_string(); - self.command_input.update(cx, |input, cx| { - input.set_value(value, window, cx); - }); - } - } -} - -impl Focusable for ElyShell { - fn focus_handle(&self, _: &App) -> FocusHandle { - self.focus_handle.clone() - } } diff --git a/crates/ely_app/src/shell/web_surface.rs b/crates/ely_app/src/shell/web_surface.rs new file mode 100644 index 0000000..07a7ccb --- /dev/null +++ b/crates/ely_app/src/shell/web_surface.rs @@ -0,0 +1,338 @@ +use std::{collections::BTreeMap, sync::Arc}; + +use ely_domain::{BrowserTab, TabId}; +use gpui::{ + AnyElement, Context, ImageSource, IntoElement, ObjectFit, ParentElement, RenderImage, Styled, + StyledImage, div, img, px, rgb, +}; +use gpui_component::StyledExt; +use image::{ImageBuffer, Rgba}; +use thiserror::Error; + +use crate::services::servo_sidecar::{ + ServoSidecarClient, ServoSidecarError, SidecarSnapshot, SidecarSnapshotRequest, +}; + +use super::ElyShell; +use ely_design_system::{colors, spacing}; + +const WEB_SURFACE_WIDTH: u32 = 1024; +const WEB_SURFACE_HEIGHT: u32 = 768; + +pub(super) struct WebSurfaceStore { + client: WebSurfaceClient, + states: BTreeMap, +} + +impl WebSurfaceStore { + pub(super) fn new() -> Self { + Self { client: WebSurfaceClient::new(), states: BTreeMap::new() } + } + + fn state(&self, tab_id: &TabId) -> Option<&WebSurfaceState> { + self.states.get(tab_id) + } + + fn prepare_request(&mut self, tab: &BrowserTab) -> Option { + if !is_external_web_url(tab.url().as_str()) { + return None; + } + + let requested_url = tab.url().as_str().to_string(); + if self.has_current_state(tab.id(), &requested_url) { + return None; + } + + let client = match &self.client { + WebSurfaceClient::Ready(client) => client.clone(), + WebSurfaceClient::Unavailable(message) => { + self.states.insert( + tab.id().clone(), + WebSurfaceState::Failed { requested_url, message: message.clone() }, + ); + return None; + } + }; + + self.states.insert( + tab.id().clone(), + WebSurfaceState::Loading { requested_url: requested_url.clone() }, + ); + + Some(WebSurfaceRequest { + tab_id: tab.id().clone(), + requested_url, + client, + snapshot_request: SidecarSnapshotRequest::new( + tab.url().clone(), + WEB_SURFACE_WIDTH, + WEB_SURFACE_HEIGHT, + ), + }) + } + + fn has_current_state(&self, tab_id: &TabId, requested_url: &str) -> bool { + match self.states.get(tab_id) { + Some(WebSurfaceState::Loading { requested_url: current_url }) => { + current_url == requested_url + } + Some(WebSurfaceState::Ready(frame)) => frame.requested_url == requested_url, + Some(WebSurfaceState::Failed { requested_url: current_url, .. }) => { + current_url == requested_url + } + None => false, + } + } + + fn is_loading(&self, tab_id: &TabId, requested_url: &str) -> bool { + matches!( + self.states.get(tab_id), + Some(WebSurfaceState::Loading { requested_url: current_url }) + if current_url == requested_url + ) + } + + fn finish(&mut self, tab_id: TabId, state: WebSurfaceState) { + self.states.insert(tab_id, state); + } +} + +enum WebSurfaceClient { + Ready(ServoSidecarClient), + Unavailable(String), +} + +impl WebSurfaceClient { + fn new() -> Self { + match ServoSidecarClient::new() { + Ok(client) => Self::Ready(client), + Err(error) => Self::Unavailable(error.to_string()), + } + } +} + +struct WebSurfaceRequest { + tab_id: TabId, + requested_url: String, + client: ServoSidecarClient, + snapshot_request: SidecarSnapshotRequest, +} + +enum WebSurfaceState { + Loading { requested_url: String }, + Ready(WebSurfaceFrame), + Failed { requested_url: String, message: String }, +} + +struct WebSurfaceFrame { + requested_url: String, + loaded_url: Option, + title: Option, + width: u32, + height: u32, + image: Arc, +} + +impl WebSurfaceFrame { + fn from_snapshot( + requested_url: String, + snapshot: SidecarSnapshot, + ) -> Result { + let width = snapshot.width(); + let height = snapshot.height(); + let loaded_url = snapshot.loaded_url().map(str::to_string); + let title = snapshot.title().map(str::to_string); + let rgba_bytes = snapshot.into_rgba_bytes(); + + let Some(buffer) = ImageBuffer::, _>::from_raw(width, height, rgba_bytes) else { + return Err(WebSurfaceError::InvalidFrameBuffer { width, height }); + }; + + Ok(Self { + requested_url, + loaded_url, + title, + width, + height, + image: Arc::new(RenderImage::new([image::Frame::new(buffer)])), + }) + } + + fn title_label(&self) -> String { + self.title.clone().unwrap_or_else(|| self.requested_url.clone()) + } + + fn url_label(&self) -> &str { + self.loaded_url.as_deref().unwrap_or(self.requested_url.as_str()) + } +} + +#[derive(Debug, Error)] +enum WebSurfaceError { + #[error("invalid servo frame buffer for {width}x{height}")] + InvalidFrameBuffer { width: u32, height: u32 }, +} + +impl ElyShell { + pub(super) fn render_external_web_canvas( + &mut self, + tab: &BrowserTab, + cx: &mut Context, + ) -> AnyElement { + self.ensure_external_web_frame(tab, cx); + + match self.web_surfaces.state(tab.id()) { + Some(WebSurfaceState::Ready(frame)) => render_ready_web_surface(frame), + Some(WebSurfaceState::Failed { message, .. }) => { + render_failed_web_surface(tab, message.as_str()) + } + Some(WebSurfaceState::Loading { .. }) | None => render_loading_web_surface(tab), + } + } + + fn ensure_external_web_frame(&mut self, tab: &BrowserTab, cx: &mut Context) { + let Some(request) = self.web_surfaces.prepare_request(tab) else { + return; + }; + + let WebSurfaceRequest { tab_id, requested_url, client, snapshot_request } = request; + cx.spawn(async move |shell, cx| { + let result = cx + .background_executor() + .spawn(async move { client.snapshot(snapshot_request) }) + .await; + + _ = shell.update(cx, |shell, cx| { + shell.handle_external_web_frame_result(tab_id, requested_url, result); + cx.notify(); + }); + }) + .detach(); + } + + fn handle_external_web_frame_result( + &mut self, + tab_id: TabId, + requested_url: String, + result: Result, + ) { + if !self.web_surfaces.is_loading(&tab_id, requested_url.as_str()) { + return; + } + + let state = match result { + Ok(snapshot) => match WebSurfaceFrame::from_snapshot(requested_url.clone(), snapshot) { + Ok(frame) => WebSurfaceState::Ready(frame), + Err(error) => WebSurfaceState::Failed { requested_url, message: error.to_string() }, + }, + Err(error) => WebSurfaceState::Failed { requested_url, message: error.to_string() }, + }; + self.web_surfaces.finish(tab_id, state); + } +} + +fn render_ready_web_surface(frame: &WebSurfaceFrame) -> AnyElement { + render_web_surface( + div() + .size_full() + .flex() + .flex_col() + .bg(rgb(colors::SURFACE_CARD)) + .child(render_web_surface_header(frame)) + .child( + div().flex_1().min_h_0().overflow_hidden().bg(rgb(colors::SURFACE_CARD)).child( + img(ImageSource::Render(frame.image.clone())) + .size_full() + .object_fit(ObjectFit::Contain), + ), + ), + ) +} + +fn render_web_surface_header(frame: &WebSurfaceFrame) -> AnyElement { + div() + .h(px(34.0)) + .px_3() + .gap_3() + .flex() + .items_center() + .border_b_1() + .border_color(rgb(colors::HAIRLINE)) + .bg(rgb(colors::CANVAS_SOFT)) + .child( + div() + .min_w_0() + .flex_1() + .truncate() + .text_sm() + .font_semibold() + .text_color(rgb(colors::INK)) + .child(frame.title_label()), + ) + .child( + div() + .text_xs() + .text_color(rgb(colors::MUTED)) + .child(format!("{}x{}", frame.width, frame.height)), + ) + .child( + div() + .max_w(px(420.0)) + .truncate() + .text_xs() + .text_color(rgb(colors::MUTED)) + .child(frame.url_label().to_string()), + ) + .into_any_element() +} + +fn render_loading_web_surface(tab: &BrowserTab) -> AnyElement { + render_web_surface(centered_status( + tab.title(), + tab.url().as_str(), + "Rendering page with Servo", + colors::BODY, + )) +} + +fn render_failed_web_surface(tab: &BrowserTab, message: &str) -> AnyElement { + render_web_surface(centered_status(tab.title(), tab.url().as_str(), message, colors::ERROR)) +} + +fn centered_status(title: &str, url: &str, detail: &str, detail_color: u32) -> impl IntoElement { + div() + .size_full() + .p_8() + .flex() + .flex_col() + .items_center() + .justify_center() + .gap_2() + .bg(rgb(colors::SURFACE_CARD)) + .child(div().text_size(px(26.0)).text_color(rgb(colors::INK)).child(title.to_string())) + .child(div().text_sm().text_color(rgb(colors::MUTED)).child(url.to_string())) + .child(div().text_sm().text_color(rgb(detail_color)).child(detail.to_string())) +} + +fn render_web_surface(content: impl IntoElement) -> AnyElement { + div() + .flex_1() + .h_full() + .p(px(spacing::SM)) + .bg(rgb(colors::CANVAS_SOFT)) + .child( + div() + .size_full() + .overflow_hidden() + .rounded_md() + .border_1() + .border_color(rgb(colors::HAIRLINE)) + .bg(rgb(colors::SURFACE_CARD)) + .child(content), + ) + .into_any_element() +} + +pub(super) fn is_external_web_url(url: &str) -> bool { + url.starts_with("https://") || url.starts_with("http://") +} diff --git a/crates/ely_servo_host/tests/sidecar.rs b/crates/ely_servo_host/tests/sidecar.rs index c2dd350..e7130f7 100644 --- a/crates/ely_servo_host/tests/sidecar.rs +++ b/crates/ely_servo_host/tests/sidecar.rs @@ -4,11 +4,25 @@ use std::{error::Error, process::Command}; const WIDTH: u64 = 640; const HEIGHT: u64 = 480; +const PRD_SITE_COMPATIBILITY_URLS: &[&str] = &["https://example.com", "https://servo.org"]; #[test] -fn sidecar_snapshots_prd_site_to_rgba_file() -> Result<(), Box> { - let output_path = - std::env::temp_dir().join(format!("ely-servo-sidecar-{}-example.rgba", std::process::id())); +fn sidecar_snapshots_prd_sites_to_rgba_files() -> Result<(), Box> { + for site_url in PRD_SITE_COMPATIBILITY_URLS { + snapshot_prd_site(site_url)?; + } + + Ok(()) +} + +fn snapshot_prd_site(site_url: &str) -> Result<(), Box> { + let site_name = site_url + .chars() + .map(|character| if character.is_ascii_alphanumeric() { character } else { '-' }) + .collect::(); + let output_path = std::env::temp_dir() + .join(format!("ely-servo-sidecar-{}-{site_name}.rgba", std::process::id())); + if output_path.exists() { std::fs::remove_file(&output_path)?; } @@ -16,7 +30,7 @@ fn sidecar_snapshots_prd_site_to_rgba_file() -> Result<(), Box> { let output = Command::new(env!("CARGO_BIN_EXE_ely_servo_sidecar")) .arg("snapshot") .arg("--url") - .arg("https://example.com") + .arg(site_url) .arg("--rgba-out") .arg(&output_path) .arg("--width") @@ -27,18 +41,18 @@ fn sidecar_snapshots_prd_site_to_rgba_file() -> Result<(), Box> { assert!( output.status.success(), - "status: {:?}\nstdout: {}\nstderr: {}", + "{site_url}\nstatus: {:?}\nstdout: {}\nstderr: {}", output.status.code(), String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); let report: serde_json::Value = serde_json::from_slice(&output.stdout)?; - assert_eq!(report_field_as_u64(&report, "width")?, WIDTH); - assert_eq!(report_field_as_u64(&report, "height")?, HEIGHT); - assert_eq!(report_field_as_u64(&report, "rgba_byte_count")?, WIDTH * HEIGHT * 4); - assert!(report_field_as_u64(&report, "non_white_pixel_count")? > 0); - assert!(report_field_as_u64(&report, "sample_hash")? > 0); + assert_eq!(report_field_as_u64(&report, "width")?, WIDTH, "{site_url}"); + assert_eq!(report_field_as_u64(&report, "height")?, HEIGHT, "{site_url}"); + assert_eq!(report_field_as_u64(&report, "rgba_byte_count")?, WIDTH * HEIGHT * 4, "{site_url}"); + assert!(report_field_as_u64(&report, "non_white_pixel_count")? > 0, "{site_url}"); + assert!(report_field_as_u64(&report, "sample_hash")? > 0, "{site_url}"); assert_eq!(std::fs::metadata(&output_path)?.len(), WIDTH * HEIGHT * 4); std::fs::remove_file(&output_path)?;