This commit is contained in:
2026-05-09 16:27:40 -04:00
parent 86f558c65f
commit 3caf207129
31 changed files with 2727 additions and 1597 deletions
+2 -3
View File
@@ -4,10 +4,9 @@ pub mod http_downloads;
pub mod plugin_package_store;
pub mod plugin_packages;
pub mod plugin_signatures;
mod servo_profile_data;
pub mod servo_sidecar;
pub mod servo_live;
pub(crate) mod servo_profile_data;
mod servo_sidecar_command;
mod servo_sidecar_request;
pub(crate) use servo_profile_data::ProfileDataMode;
@@ -11,16 +11,16 @@ const ELY_QUALIFIER: &str = "com";
const ELY_ORGANIZATION: &str = "elydora";
const ELY_APPLICATION: &str = "ELY Browser";
pub(super) fn default_profile_data_root() -> Option<PathBuf> {
pub(crate) fn default_profile_data_root() -> Option<PathBuf> {
ProjectDirs::from(ELY_QUALIFIER, ELY_ORGANIZATION, ELY_APPLICATION)
.map(|project_dirs| project_dirs.data_dir().join("profiles"))
}
pub(super) fn profile_data_dir(profile_data_root: &Path, profile_id: &ProfileId) -> PathBuf {
pub(crate) fn profile_data_dir(profile_data_root: &Path, profile_id: &ProfileId) -> PathBuf {
profile_data_root.join(profile_id.as_str()).join("servo")
}
pub(super) fn transient_profile_data_dir(
pub(crate) fn transient_profile_data_dir(
profile_id: &ProfileId,
) -> Result<PathBuf, SystemTimeError> {
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
@@ -1,472 +0,0 @@
use std::{
env, fs, io,
path::{Path, PathBuf},
process::{Command, Output, Stdio},
thread,
time::{Duration, Instant, SystemTime, SystemTimeError, UNIX_EPOCH},
};
use ely_domain::ProfileId;
use serde::Deserialize;
use thiserror::Error;
pub use super::servo_sidecar_request::{SidecarSitePermission, SidecarSnapshotRequest};
use super::{
servo_profile_data::{
ProfileDataMode, default_profile_data_root, profile_data_dir, transient_profile_data_dir,
},
servo_sidecar_command::{SidecarCommandTarget, default_sidecar_command},
};
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;
#[derive(Clone, Debug)]
pub struct ServoSidecarClient {
command_target: SidecarCommandTarget,
profile_data_root: PathBuf,
}
impl ServoSidecarClient {
pub fn new() -> Result<Self, ServoSidecarError> {
Ok(Self {
command_target: default_sidecar_command()?,
profile_data_root: default_profile_data_root()
.ok_or(ServoSidecarError::ProfileDataRootUnavailable)?,
})
}
pub fn snapshot(
&self,
request: SidecarSnapshotRequest,
) -> Result<SidecarSnapshot, ServoSidecarError> {
if let Some(path) = self.command_target.missing_binary_path() {
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<SidecarSnapshot, ServoSidecarError> {
let rgba_path = temporary_rgba_path()?;
let profile_data_dir = request.profile_data_dir(&self.profile_data_root)?;
let output = match self.run_snapshot_command(request, &rgba_path, &profile_data_dir) {
Ok(output) => output,
Err(error) => {
return cleanup_failed_snapshot(request, &rgba_path, &profile_data_dir, error);
}
};
if !output.status.success() {
let error = ServoSidecarError::SidecarFailed {
status: output.status.to_string(),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
};
return cleanup_failed_snapshot(request, &rgba_path, &profile_data_dir, error);
}
let snapshot = read_sidecar_snapshot(&output.stdout, &rgba_path, &request.profile_id);
let cleanup = remove_temporary_file(&rgba_path);
let profile_cleanup = request.cleanup_profile_data_dir(&profile_data_dir);
match (snapshot, cleanup, profile_cleanup) {
(Ok(snapshot), Ok(()), Ok(())) => Ok(snapshot),
(Err(error), Ok(()), Ok(())) => Err(error),
(Ok(_), Err(error), _) | (Err(_), Err(error), _) => Err(error),
(Ok(_), Ok(()), Err(error)) | (Err(_), Ok(()), Err(error)) => Err(error),
}
}
fn run_snapshot_command(
&self,
request: &SidecarSnapshotRequest,
rgba_path: &Path,
profile_data_dir: &Path,
) -> Result<Output, ServoSidecarError> {
let mut command = self.command_target.command();
append_snapshot_args(&mut command, request, rgba_path, profile_data_dir);
if let Some(click_point) = request.click_point {
command
.arg("--click-x")
.arg(click_point.x.to_string())
.arg("--click-y")
.arg(click_point.y.to_string());
}
if let Some(typed_text) = request.typed_text.as_deref() {
command.arg("--type-text").arg(typed_text);
}
for permission in &request.site_permissions {
command.arg("--site-permission").arg(permission.to_arg());
}
let mut child = command
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(ServoSidecarError::Command)?;
let started_at = Instant::now();
loop {
if child.try_wait().map_err(ServoSidecarError::Command)?.is_some() {
return child.wait_with_output().map_err(ServoSidecarError::Command);
}
let timeout = self.command_target.timeout();
if started_at.elapsed() >= timeout {
terminate_child(child)?;
return Err(ServoSidecarError::SidecarTimedOut {
url: request.url.as_str().to_string(),
seconds: timeout.as_secs(),
});
}
thread::sleep(SIDECAR_POLL_INTERVAL);
}
}
}
fn append_snapshot_args(
command: &mut Command,
request: &SidecarSnapshotRequest,
rgba_path: &Path,
profile_data_dir: &Path,
) {
command
.arg("snapshot")
.arg("--url")
.arg(request.url.as_str())
.arg("--profile-id")
.arg(request.profile_id.as_str())
.arg("--profile-data-dir")
.arg(profile_data_dir)
.arg("--rgba-out")
.arg(rgba_path)
.arg("--width")
.arg(request.width.to_string())
.arg("--height")
.arg(request.height.to_string())
.arg("--scroll-x")
.arg(request.scroll_x.to_string())
.arg("--scroll-y")
.arg(request.scroll_y.to_string())
.arg("--page-zoom-percent")
.arg(request.page_zoom_percent.to_string());
}
impl SidecarSnapshotRequest {
fn profile_data_dir(&self, profile_data_root: &Path) -> Result<PathBuf, ServoSidecarError> {
match self.profile_data_mode {
ProfileDataMode::Persistent => {
Ok(profile_data_dir(profile_data_root, &self.profile_id))
}
ProfileDataMode::Transient => {
transient_profile_data_dir(&self.profile_id).map_err(ServoSidecarError::SystemClock)
}
}
}
fn cleanup_profile_data_dir(&self, profile_data_dir: &Path) -> Result<(), ServoSidecarError> {
if self.profile_data_mode == ProfileDataMode::Persistent {
return Ok(());
}
remove_temporary_directory(profile_data_dir)
}
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, Debug)]
pub struct SidecarSnapshot {
loaded_url: Option<String>,
title: Option<String>,
render_state: String,
width: u32,
height: u32,
#[cfg(test)]
non_white_pixel_count: u64,
#[cfg(test)]
content_pixel_count: u64,
#[cfg(test)]
sample_hash: u64,
rgba_bytes: Vec<u8>,
}
impl SidecarSnapshot {
fn from_report(
report: SidecarReport,
expected_profile_id: &ProfileId,
rgba_bytes: Vec<u8>,
) -> Result<Self, ServoSidecarError> {
let expected_byte_count = expected_rgba_byte_count(report.width, report.height)?;
if report.profile_id != expected_profile_id.as_str() {
return Err(ServoSidecarError::ProfileMismatch {
expected: expected_profile_id.as_str().to_string(),
actual: report.profile_id,
});
}
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
{
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,
});
}
if report.content_pixel_count == 0 {
return Err(ServoSidecarError::ContentlessRenderedFrame {
requested_url: report.requested_url,
});
}
Ok(Self {
loaded_url: report.loaded_url,
title: report.title,
render_state: report.state,
width: report.width,
height: report.height,
#[cfg(test)]
non_white_pixel_count: report.non_white_pixel_count,
#[cfg(test)]
content_pixel_count: report.content_pixel_count,
#[cfg(test)]
sample_hash: report.sample_hash,
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 render_state(&self) -> &str {
self.render_state.as_str()
}
#[must_use]
pub fn width(&self) -> u32 {
self.width
}
#[must_use]
pub fn height(&self) -> u32 {
self.height
}
#[cfg(all(test, feature = "live-site-smoke"))]
#[must_use]
pub(crate) fn non_white_pixel_count(&self) -> u64 {
self.non_white_pixel_count
}
#[cfg(all(test, feature = "live-site-smoke"))]
#[must_use]
pub(crate) fn content_pixel_count(&self) -> u64 {
self.content_pixel_count
}
#[cfg(all(test, feature = "live-site-smoke"))]
#[must_use]
pub(crate) fn sample_hash(&self) -> u64 {
self.sample_hash
}
#[must_use]
pub fn into_rgba_bytes(self) -> Vec<u8> {
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("profile data root is unavailable")]
ProfileDataRootUnavailable,
#[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("servo sidecar timed out after {seconds}s while rendering {url}")]
SidecarTimedOut { url: String, seconds: u64 },
#[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("failed to remove transient servo profile data at {path}: {source}")]
ProfileDataCleanup { path: PathBuf, source: io::Error },
#[error("servo sidecar returned incomplete render state: {state}")]
IncompleteRender { state: String },
#[error("servo sidecar returned profile {actual}, expected {expected}")]
ProfileMismatch { expected: String, actual: 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 },
#[error("servo rendered a frame without visible content for {requested_url}")]
ContentlessRenderedFrame { requested_url: String },
}
fn is_renderable_state(state: &str) -> bool {
matches!(state, "complete" | "loading")
}
#[derive(Deserialize)]
struct SidecarReport {
requested_url: String,
profile_id: String,
loaded_url: Option<String>,
title: Option<String>,
state: String,
width: u32,
height: u32,
rgba_byte_count: usize,
non_white_pixel_count: u64,
content_pixel_count: u64,
#[cfg(test)]
sample_hash: u64,
}
fn temporary_rgba_path() -> Result<PathBuf, ServoSidecarError> {
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 remove_temporary_directory(path: &Path) -> Result<(), ServoSidecarError> {
match fs::remove_dir_all(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => {
Err(ServoSidecarError::ProfileDataCleanup { path: path.to_path_buf(), source: error })
}
}
}
fn cleanup_failed_snapshot(
request: &SidecarSnapshotRequest,
rgba_path: &Path,
profile_data_dir: &Path,
snapshot_error: ServoSidecarError,
) -> Result<SidecarSnapshot, ServoSidecarError> {
let frame_cleanup = remove_temporary_file(rgba_path);
let profile_cleanup = request.cleanup_profile_data_dir(profile_data_dir);
match (frame_cleanup, profile_cleanup) {
(Ok(()), Ok(())) => Err(snapshot_error),
(Err(error), _) => Err(error),
(Ok(()), Err(error)) => Err(error),
}
}
fn terminate_child(mut child: std::process::Child) -> Result<(), ServoSidecarError> {
match child.kill() {
Ok(()) => {
let _output = child.wait_with_output().map_err(ServoSidecarError::Command)?;
Ok(())
}
Err(error) if error.kind() == io::ErrorKind::InvalidInput => Ok(()),
Err(error) => Err(ServoSidecarError::Command(error)),
}
}
fn read_sidecar_snapshot(
stdout: &[u8],
rgba_path: &Path,
expected_profile_id: &ProfileId,
) -> Result<SidecarSnapshot, ServoSidecarError> {
let report: SidecarReport = serde_json::from_slice(stdout)?;
let rgba_bytes = fs::read(rgba_path).map_err(ServoSidecarError::FrameRead)?;
SidecarSnapshot::from_report(report, expected_profile_id, rgba_bytes)
}
fn expected_rgba_byte_count(width: u32, height: u32) -> Result<usize, ServoSidecarError> {
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 })
}
#[cfg(test)]
#[path = "servo_sidecar_tests.rs"]
mod tests;
@@ -1,14 +1,11 @@
use std::{
env,
env, io,
path::{Path, PathBuf},
process::Command,
time::Duration,
};
use super::servo_sidecar::ServoSidecarError;
use thiserror::Error;
const SIDECAR_BINARY_TIMEOUT: Duration = Duration::from_secs(45);
const SIDECAR_CARGO_TIMEOUT: Duration = Duration::from_secs(180);
const SIDECAR_PATH_ENV: &str = "ELY_SERVO_SIDECAR";
#[derive(Clone, Debug)]
@@ -40,13 +37,6 @@ impl SidecarCommandTarget {
}
}
pub(super) fn timeout(&self) -> Duration {
match self {
Self::Binary(_) => SIDECAR_BINARY_TIMEOUT,
Self::Cargo { .. } => SIDECAR_CARGO_TIMEOUT,
}
}
pub(super) fn missing_binary_path(&self) -> Option<&Path> {
match self {
Self::Binary(path) if !path.is_file() => Some(path.as_path()),
@@ -55,14 +45,23 @@ impl SidecarCommandTarget {
}
}
pub(super) fn default_sidecar_command() -> Result<SidecarCommandTarget, ServoSidecarError> {
#[derive(Debug, Error)]
pub(crate) enum SidecarCommandError {
#[error("current executable path is unavailable: {0}")]
CurrentExecutable(#[source] io::Error),
#[error("current executable directory is unavailable for {path}")]
CurrentExecutableDirectoryUnavailable { path: PathBuf },
}
pub(super) fn default_sidecar_command() -> Result<SidecarCommandTarget, SidecarCommandError> {
if let Some(path) = env::var_os(SIDECAR_PATH_ENV) {
return Ok(SidecarCommandTarget::Binary(PathBuf::from(path)));
}
let current_exe = env::current_exe().map_err(ServoSidecarError::CurrentExecutable)?;
let current_exe = env::current_exe().map_err(SidecarCommandError::CurrentExecutable)?;
let exe_dir = current_exe.parent().ok_or_else(|| {
ServoSidecarError::CurrentExecutableDirectoryUnavailable { path: current_exe.clone() }
SidecarCommandError::CurrentExecutableDirectoryUnavailable { path: current_exe.clone() }
})?;
let adjacent_sidecar = exe_dir.join(sidecar_binary_name());
if adjacent_sidecar.is_file() {
@@ -1,145 +0,0 @@
use ely_domain::{
DEFAULT_ZOOM_PERCENT, ProfileId, SiteOrigin, SitePermissionDecision, SitePermissionFeature,
UrlText,
};
use super::ProfileDataMode;
#[derive(Clone, Debug)]
pub struct SidecarSnapshotRequest {
pub(in crate::services) url: UrlText,
pub(in crate::services) profile_id: ProfileId,
pub(in crate::services) profile_data_mode: ProfileDataMode,
pub(in crate::services) width: u32,
pub(in crate::services) height: u32,
pub(in crate::services) scroll_x: i32,
pub(in crate::services) scroll_y: i32,
pub(in crate::services) page_zoom_percent: u16,
pub(in crate::services) click_point: Option<SidecarClickPoint>,
pub(in crate::services) typed_text: Option<String>,
pub(in crate::services) site_permissions: Vec<SidecarSitePermission>,
}
impl SidecarSnapshotRequest {
#[must_use]
pub fn new(url: UrlText, profile_id: ProfileId, width: u32, height: u32) -> Self {
Self {
url,
profile_id,
profile_data_mode: ProfileDataMode::Persistent,
width,
height,
scroll_x: 0,
scroll_y: 0,
page_zoom_percent: DEFAULT_ZOOM_PERCENT,
click_point: None,
typed_text: None,
site_permissions: Vec::new(),
}
}
#[must_use]
pub fn with_profile_data_mode(mut self, profile_data_mode: ProfileDataMode) -> Self {
self.profile_data_mode = profile_data_mode;
self
}
#[must_use]
pub fn with_scroll_offset(mut self, scroll_x: i32, scroll_y: i32) -> Self {
self.scroll_x = scroll_x;
self.scroll_y = scroll_y;
self
}
#[must_use]
pub fn with_page_zoom_percent(mut self, page_zoom_percent: u16) -> Self {
self.page_zoom_percent = page_zoom_percent;
self
}
#[must_use]
pub fn with_click_point(mut self, x: u32, y: u32) -> Self {
self.click_point = Some(SidecarClickPoint { x, y });
self
}
#[must_use]
pub fn with_typed_text(mut self, typed_text: String) -> Self {
self.typed_text = Some(typed_text);
self
}
#[must_use]
pub fn with_site_permissions(mut self, site_permissions: Vec<SidecarSitePermission>) -> Self {
self.site_permissions = site_permissions;
self
}
#[cfg(test)]
pub(crate) fn typed_text_for_test(&self) -> Option<&str> {
self.typed_text.as_deref()
}
#[cfg(test)]
pub(crate) fn profile_id_for_test(&self) -> &ProfileId {
&self.profile_id
}
#[cfg(test)]
pub(crate) fn profile_data_mode_for_test(&self) -> ProfileDataMode {
self.profile_data_mode
}
#[cfg(test)]
pub(crate) fn page_zoom_percent_for_test(&self) -> u16 {
self.page_zoom_percent
}
}
#[derive(Clone, Copy, Debug)]
pub(in crate::services) struct SidecarClickPoint {
pub(in crate::services) x: u32,
pub(in crate::services) y: u32,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SidecarSitePermission {
origin: SiteOrigin,
feature: SitePermissionFeature,
decision: SitePermissionDecision,
}
impl SidecarSitePermission {
#[must_use]
pub fn new(
origin: SiteOrigin,
feature: SitePermissionFeature,
decision: SitePermissionDecision,
) -> Self {
Self { origin, feature, decision }
}
pub(in crate::services) fn to_arg(&self) -> String {
serde_json::json!({
"origin": self.origin.as_str(),
"feature": self.feature.as_str(),
"decision": self.decision.as_str(),
})
.to_string()
}
#[cfg(test)]
pub(crate) fn origin(&self) -> &SiteOrigin {
&self.origin
}
#[cfg(test)]
pub(crate) fn feature(&self) -> SitePermissionFeature {
self.feature
}
#[cfg(test)]
pub(crate) fn decision(&self) -> SitePermissionDecision {
self.decision
}
}
@@ -1,260 +0,0 @@
use std::error::Error;
use ely_domain::UrlText;
use super::*;
#[cfg(feature = "live-site-smoke")]
use crate::services::prd_live_sites::{
LiveSiteCase, PRD_REFERENCE_SITE_CASES, PRD_TOP_SITE_CASES,
assert_prd_reference_urls_are_covered,
};
#[cfg(feature = "live-site-smoke")]
const LIVE_SITE_RENDER_ATTEMPTS: usize = 3;
#[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 MINIMUM_CONTENT_PIXELS: u64 = 1_000;
#[test]
fn accepts_loading_report_with_visible_content() -> Result<(), ServoSidecarError> {
let profile_id = ProfileId::new();
let snapshot = SidecarSnapshot::from_report(
report_with_state("loading", &profile_id),
&profile_id,
visible_frame(),
)?;
assert_eq!(snapshot.loaded_url(), Some("https://example.com/"));
assert_eq!(snapshot.title(), Some("Example Domain"));
assert_eq!(snapshot.render_state(), "loading");
assert_eq!(snapshot.width(), 2);
assert_eq!(snapshot.height(), 1);
assert_eq!(snapshot.non_white_pixel_count, 1);
assert_eq!(snapshot.content_pixel_count, 1);
assert_eq!(snapshot.sample_hash, 42);
Ok(())
}
#[test]
fn rejects_created_report_with_visible_content() {
let profile_id = ProfileId::new();
let result = SidecarSnapshot::from_report(
report_with_state("created", &profile_id),
&profile_id,
visible_frame(),
);
assert!(
matches!(result, Err(ServoSidecarError::IncompleteRender { state }) if state == "created")
);
}
#[test]
fn rejects_report_from_different_profile() {
let expected_profile_id = ProfileId::new();
let actual_profile_id = ProfileId::new();
let result = SidecarSnapshot::from_report(
report_with_state("loading", &actual_profile_id),
&expected_profile_id,
visible_frame(),
);
assert!(matches!(result, Err(ServoSidecarError::ProfileMismatch { expected, actual })
if expected == expected_profile_id.as_str() && actual == actual_profile_id.as_str()));
}
#[test]
fn retries_navigation_snapshots() -> Result<(), Box<dyn Error>> {
let request =
SidecarSnapshotRequest::new(UrlText::parse("https://example.com")?, ProfileId::new(), 2, 1);
assert_eq!(request.max_attempts(), SIDECAR_NAVIGATION_ATTEMPTS);
Ok(())
}
#[test]
fn keeps_page_interactions_single_attempt() -> Result<(), Box<dyn Error>> {
let request =
SidecarSnapshotRequest::new(UrlText::parse("https://example.com")?, ProfileId::new(), 2, 1)
.with_click_point(1, 1)
.with_typed_text("ely".to_string());
assert_eq!(request.max_attempts(), SIDECAR_INTERACTION_ATTEMPTS);
Ok(())
}
#[test]
fn persistent_profile_data_uses_profile_root() -> Result<(), Box<dyn Error>> {
let profile_id = ProfileId::new();
let request = SidecarSnapshotRequest::new(
UrlText::parse("https://example.com")?,
profile_id.clone(),
2,
1,
);
let root = std::env::temp_dir().join("ely-browser-profile-root-test");
assert_eq!(request.profile_data_mode_for_test(), ProfileDataMode::Persistent);
assert_eq!(request.profile_data_dir(&root)?, root.join(profile_id.as_str()).join("servo"));
Ok(())
}
#[test]
fn transient_profile_data_uses_temporary_directory_and_cleans_up() -> Result<(), Box<dyn Error>> {
let profile_id = ProfileId::new();
let request = SidecarSnapshotRequest::new(
UrlText::parse("https://example.com")?,
profile_id.clone(),
2,
1,
)
.with_profile_data_mode(ProfileDataMode::Transient);
let root = std::env::temp_dir().join("ely-browser-profile-root-test");
let profile_data_dir = request.profile_data_dir(&root)?;
assert!(profile_data_dir.starts_with(std::env::temp_dir().join("ely-browser-servo-profiles")));
assert!(profile_data_dir.to_string_lossy().contains(profile_id.as_str()));
std::fs::create_dir_all(&profile_data_dir)?;
std::fs::write(profile_data_dir.join("probe"), b"private")?;
request.cleanup_profile_data_dir(&profile_data_dir)?;
assert!(!profile_data_dir.exists());
Ok(())
}
#[cfg(feature = "live-site-smoke")]
#[test]
fn desktop_sidecar_opens_prd_top_sites() -> Result<(), Box<dyn Error>> {
assert_live_sites_render(PRD_TOP_SITE_CASES)
}
#[cfg(feature = "live-site-smoke")]
#[test]
fn desktop_sidecar_opens_prd_reference_sites() -> Result<(), Box<dyn Error>> {
assert_live_sites_render(PRD_REFERENCE_SITE_CASES)
}
#[cfg(feature = "live-site-smoke")]
#[test]
fn prd_reference_live_site_cases_cover_prd_urls() -> Result<(), Box<dyn Error>> {
assert_prd_reference_urls_are_covered()
}
#[cfg(feature = "live-site-smoke")]
fn assert_live_sites_render(cases: &[LiveSiteCase]) -> Result<(), Box<dyn Error>> {
let client = ServoSidecarClient::new()?;
for case in cases {
let snapshot = render_live_site_snapshot(&client, case)?;
let rgba_bytes = snapshot.into_rgba_bytes();
assert_eq!(
rgba_bytes.len(),
expected_rgba_byte_count(LIVE_SITE_WIDTH, LIVE_SITE_HEIGHT)?,
"{}",
case.url
);
}
Ok(())
}
#[cfg(feature = "live-site-smoke")]
fn render_live_site_snapshot(
client: &ServoSidecarClient,
case: &LiveSiteCase,
) -> Result<SidecarSnapshot, Box<dyn Error>> {
let mut last_error = String::new();
for attempt in 0..LIVE_SITE_RENDER_ATTEMPTS {
let request = SidecarSnapshotRequest::new(
UrlText::parse(case.url)?,
ProfileId::new(),
LIVE_SITE_WIDTH,
LIVE_SITE_HEIGHT,
);
match client.snapshot(request) {
Ok(snapshot) => match validate_live_site_snapshot(&snapshot, case) {
Ok(()) => return Ok(snapshot),
Err(error) => last_error = error,
},
Err(error) => last_error = error.to_string(),
}
if attempt + 1 < LIVE_SITE_RENDER_ATTEMPTS {
std::thread::sleep(std::time::Duration::from_millis(250));
}
}
Err(last_error.into())
}
#[cfg(feature = "live-site-smoke")]
fn validate_live_site_snapshot(
snapshot: &SidecarSnapshot,
case: &LiveSiteCase,
) -> Result<(), String> {
require(
snapshot.width() == LIVE_SITE_WIDTH,
format!("{} width: {}", case.url, snapshot.width()),
)?;
require(
snapshot.height() == LIVE_SITE_HEIGHT,
format!("{} height: {}", case.url, snapshot.height()),
)?;
require_render_state_is_open(snapshot.render_state(), case.url)?;
require_loaded_url_contains(snapshot, case.url)?;
require_title_contains(snapshot, case.title_fragment)?;
require(snapshot.non_white_pixel_count > 0, case.url.to_string())?;
require(
snapshot.content_pixel_count >= MINIMUM_CONTENT_PIXELS,
format!("{} content pixels: {}", case.url, snapshot.content_pixel_count),
)?;
require(snapshot.sample_hash > 0, case.url.to_string())
}
#[cfg(feature = "live-site-smoke")]
fn require_render_state_is_open(state: &str, url: &str) -> Result<(), String> {
require(matches!(state, "complete" | "loading"), format!("{url} state: {state}"))
}
#[cfg(feature = "live-site-smoke")]
fn require_loaded_url_contains(snapshot: &SidecarSnapshot, fragment: &str) -> Result<(), String> {
let loaded_url =
snapshot.loaded_url().ok_or_else(|| format!("missing loaded URL for {fragment}"))?;
require(loaded_url.contains(fragment), format!("loaded_url: {loaded_url}"))
}
#[cfg(feature = "live-site-smoke")]
fn require_title_contains(snapshot: &SidecarSnapshot, fragment: &str) -> Result<(), String> {
let title = snapshot.title().ok_or_else(|| format!("missing title containing {fragment}"))?;
require(title.contains(fragment), format!("title: {title}"))
}
#[cfg(feature = "live-site-smoke")]
fn require(condition: bool, message: String) -> Result<(), String> {
if condition { Ok(()) } else { Err(message) }
}
fn report_with_state(state: &str, profile_id: &ProfileId) -> SidecarReport {
SidecarReport {
requested_url: "https://example.com".to_string(),
profile_id: profile_id.as_str().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,
sample_hash: 42,
}
}
fn visible_frame() -> Vec<u8> {
vec![0, 0, 0, 255, 255, 255, 255, 255]
}