update
This commit is contained in:
@@ -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]
|
||||
}
|
||||
@@ -26,15 +26,17 @@ mod web_surface;
|
||||
mod web_surface_controller;
|
||||
mod web_surface_frame;
|
||||
mod web_surface_geometry;
|
||||
mod web_surface_image;
|
||||
mod web_surface_keyboard;
|
||||
mod web_surface_permissions;
|
||||
mod web_surface_runtime;
|
||||
mod web_surface_state;
|
||||
mod web_surface_view;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use ely_browser_core::{BrowserCore, InitialBrowserConfig};
|
||||
use ely_domain::{ProfileId, SpaceId, TabId};
|
||||
use gpui::{AppContext, Context, Entity, FocusHandle, Subscription, Window};
|
||||
use gpui::{AppContext, Context, Entity, FocusHandle, Subscription, Timer, Window};
|
||||
use gpui_component::input::{InputEvent, InputState};
|
||||
|
||||
use crate::shortcuts::ShortcutProfile;
|
||||
@@ -144,7 +146,7 @@ impl ElyShell {
|
||||
Err(error) => ShellState::StartupError(error.to_string()),
|
||||
};
|
||||
|
||||
Self {
|
||||
let shell = Self {
|
||||
state,
|
||||
focus_handle: cx.focus_handle(),
|
||||
command_input,
|
||||
@@ -172,7 +174,9 @@ impl ElyShell {
|
||||
pending_plugin_uninstall: None,
|
||||
web_surfaces: WebSurfaceStore::new(),
|
||||
_command_subscription: command_subscription,
|
||||
}
|
||||
};
|
||||
start_external_web_surface_timer(cx);
|
||||
shell
|
||||
}
|
||||
|
||||
fn focus_command_mode(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
@@ -432,3 +436,20 @@ impl ElyShell {
|
||||
self.zoom_active_tab_out(cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn start_external_web_surface_timer(cx: &mut Context<ElyShell>) {
|
||||
cx.spawn(async move |shell, cx| {
|
||||
loop {
|
||||
Timer::after(Duration::from_millis(16)).await;
|
||||
let result = shell.update(cx, |shell, cx| {
|
||||
if shell.tick_external_web_surfaces() {
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
if result.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
@@ -4,14 +4,14 @@ use gpui::RenderImage;
|
||||
use image::{ImageBuffer, Rgba};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::services::servo_sidecar::SidecarSnapshot;
|
||||
use crate::services::servo_live::ServoLiveFrame;
|
||||
|
||||
use super::{
|
||||
web_surface_geometry::{WebSurfaceClickPoint, WebSurfaceScrollOffset, WebSurfaceSize},
|
||||
web_surface_image::renderable_image_buffer,
|
||||
};
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
use super::web_surface_geometry::WebSurfaceSize;
|
||||
use super::web_surface_geometry::{WebSurfaceClickPoint, WebSurfaceScrollOffset};
|
||||
|
||||
#[derive(Clone)]
|
||||
#[cfg_attr(not(all(test, feature = "live-site-smoke")), allow(dead_code))]
|
||||
pub(super) struct WebSurfaceFrame {
|
||||
pub(super) requested_url: String,
|
||||
loaded_url: Option<String>,
|
||||
@@ -33,62 +33,75 @@ pub(super) struct WebSurfaceFrame {
|
||||
}
|
||||
|
||||
impl WebSurfaceFrame {
|
||||
pub(super) fn from_snapshot(
|
||||
pub(super) fn from_live_frame(
|
||||
requested_url: String,
|
||||
scroll_offset: WebSurfaceScrollOffset,
|
||||
zoom_percent: u16,
|
||||
click_point: Option<WebSurfaceClickPoint>,
|
||||
typed_text: Option<String>,
|
||||
snapshot: SidecarSnapshot,
|
||||
frame: ServoLiveFrame,
|
||||
) -> Result<Self, WebSurfaceError> {
|
||||
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 render_state = snapshot.render_state().to_string();
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
let non_white_pixel_count = snapshot.non_white_pixel_count();
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
let content_pixel_count = snapshot.content_pixel_count();
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
let sample_hash = snapshot.sample_hash();
|
||||
let rgba_bytes = snapshot.into_rgba_bytes();
|
||||
|
||||
let Some(buffer) = ImageBuffer::<Rgba<u8>, _>::from_raw(width, height, rgba_bytes) else {
|
||||
return Err(WebSurfaceError::InvalidFrameBuffer { width, height });
|
||||
};
|
||||
|
||||
let image_buffer = renderable_image_buffer(buffer);
|
||||
|
||||
Ok(Self {
|
||||
Self::from_parts(WebSurfaceFrameParts {
|
||||
requested_url,
|
||||
loaded_url,
|
||||
title,
|
||||
render_state,
|
||||
width,
|
||||
height,
|
||||
loaded_url: frame.loaded_url().map(str::to_string),
|
||||
title: frame.title().map(str::to_string),
|
||||
render_state: frame.render_state().to_string(),
|
||||
width: frame.width(),
|
||||
height: frame.height(),
|
||||
scroll_offset,
|
||||
zoom_percent,
|
||||
click_point,
|
||||
typed_text,
|
||||
click_point: None,
|
||||
typed_text: None,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
non_white_pixel_count,
|
||||
non_white_pixel_count: frame.non_white_pixel_count(),
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
content_pixel_count,
|
||||
content_pixel_count: frame.content_pixel_count(),
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
sample_hash,
|
||||
sample_hash: frame.sample_hash(),
|
||||
rgba_bytes: frame.into_rgba_bytes(),
|
||||
})
|
||||
}
|
||||
|
||||
fn from_parts(parts: WebSurfaceFrameParts) -> Result<Self, WebSurfaceError> {
|
||||
let Some(image_buffer) =
|
||||
ImageBuffer::<Rgba<u8>, _>::from_raw(parts.width, parts.height, parts.rgba_bytes)
|
||||
else {
|
||||
return Err(WebSurfaceError::InvalidFrameBuffer {
|
||||
width: parts.width,
|
||||
height: parts.height,
|
||||
});
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
requested_url: parts.requested_url,
|
||||
loaded_url: parts.loaded_url,
|
||||
title: parts.title,
|
||||
render_state: parts.render_state,
|
||||
width: parts.width,
|
||||
height: parts.height,
|
||||
scroll_offset: parts.scroll_offset,
|
||||
zoom_percent: parts.zoom_percent,
|
||||
click_point: parts.click_point,
|
||||
typed_text: parts.typed_text,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
non_white_pixel_count: parts.non_white_pixel_count,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
content_pixel_count: parts.content_pixel_count,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
sample_hash: parts.sample_hash,
|
||||
image: Arc::new(RenderImage::new([image::Frame::new(image_buffer)])),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
pub(super) fn title_label(&self) -> String {
|
||||
self.title.clone().unwrap_or_else(|| self.requested_url.clone())
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
pub(super) fn url_label(&self) -> &str {
|
||||
self.loaded_url.as_deref().unwrap_or(self.requested_url.as_str())
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
pub(super) fn detail_label(&self) -> String {
|
||||
let mut detail =
|
||||
format!("{} {}", self.render_state(), self.scroll_offset.detail_label(self.size()));
|
||||
@@ -104,14 +117,17 @@ impl WebSurfaceFrame {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
pub(super) fn size(&self) -> WebSurfaceSize {
|
||||
WebSurfaceSize { width: self.width, height: self.height }
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
pub(super) fn render_state(&self) -> &str {
|
||||
self.render_state.as_str()
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
pub(super) fn scroll_offset(&self) -> WebSurfaceScrollOffset {
|
||||
self.scroll_offset
|
||||
}
|
||||
@@ -120,10 +136,12 @@ impl WebSurfaceFrame {
|
||||
self.zoom_percent
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
pub(super) fn click_point(&self) -> Option<WebSurfaceClickPoint> {
|
||||
self.click_point
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
pub(super) fn typed_text(&self) -> Option<&str> {
|
||||
self.typed_text.as_deref()
|
||||
}
|
||||
@@ -144,6 +162,26 @@ impl WebSurfaceFrame {
|
||||
}
|
||||
}
|
||||
|
||||
struct WebSurfaceFrameParts {
|
||||
requested_url: String,
|
||||
loaded_url: Option<String>,
|
||||
title: Option<String>,
|
||||
render_state: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
scroll_offset: WebSurfaceScrollOffset,
|
||||
zoom_percent: u16,
|
||||
click_point: Option<WebSurfaceClickPoint>,
|
||||
typed_text: Option<String>,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
non_white_pixel_count: u64,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
content_pixel_count: u64,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
sample_hash: u64,
|
||||
rgba_bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(super) enum WebSurfaceError {
|
||||
#[error("invalid servo frame buffer for {width}x{height}")]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use gpui::{Bounds, Pixels, Point};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub(super) struct WebSurfaceSize {
|
||||
pub(super) width: u32,
|
||||
pub(super) height: u32,
|
||||
@@ -32,6 +32,7 @@ impl WebSurfaceClickPoint {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
pub(super) fn detail_label(self) -> String {
|
||||
format!("click={},{}", self.x, self.y)
|
||||
}
|
||||
@@ -59,6 +60,7 @@ impl WebSurfaceScrollOffset {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
pub(super) fn detail_label(self, size: WebSurfaceSize) -> String {
|
||||
match (self.x, self.y) {
|
||||
(0, 0) => format!("{}x{}", size.width, size.height),
|
||||
@@ -67,10 +69,7 @@ impl WebSurfaceScrollOffset {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn x(self) -> i32 {
|
||||
self.x
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(super) fn y(self) -> i32 {
|
||||
self.y
|
||||
}
|
||||
@@ -92,6 +91,18 @@ impl WebSurfaceScrollDelta {
|
||||
|
||||
Some(Self { x, y })
|
||||
}
|
||||
|
||||
pub(super) fn x(self) -> i32 {
|
||||
self.x
|
||||
}
|
||||
|
||||
pub(super) fn y(self) -> i32 {
|
||||
self.y
|
||||
}
|
||||
|
||||
pub(super) fn combined_with(self, next: Self) -> Self {
|
||||
Self { x: combined_scroll_delta(self.x, next.x), y: combined_scroll_delta(self.y, next.y) }
|
||||
}
|
||||
}
|
||||
|
||||
fn viewport_dimension(pixels: Pixels) -> Option<u32> {
|
||||
@@ -133,3 +144,8 @@ fn positive_scroll_component(current: i32, delta: i32) -> i32 {
|
||||
let clamped = value.clamp(0, i64::from(i32::MAX));
|
||||
clamped as i32
|
||||
}
|
||||
|
||||
fn combined_scroll_delta(current: i32, next: i32) -> i32 {
|
||||
let value = i64::from(current) + i64::from(next);
|
||||
value.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
use image::{ImageBuffer, Rgba, imageops::FilterType};
|
||||
|
||||
const WEB_SURFACE_IMAGE_MAX_EDGE: u32 = 1024;
|
||||
|
||||
pub(super) fn renderable_image_buffer(
|
||||
buffer: ImageBuffer<Rgba<u8>, Vec<u8>>,
|
||||
) -> ImageBuffer<Rgba<u8>, Vec<u8>> {
|
||||
let largest_edge = buffer.width().max(buffer.height());
|
||||
if largest_edge <= WEB_SURFACE_IMAGE_MAX_EDGE {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
image::imageops::resize(
|
||||
&buffer,
|
||||
scaled_image_dimension(buffer.width(), largest_edge),
|
||||
scaled_image_dimension(buffer.height(), largest_edge),
|
||||
FilterType::Triangle,
|
||||
)
|
||||
}
|
||||
|
||||
fn scaled_image_dimension(dimension: u32, largest_edge: u32) -> u32 {
|
||||
let numerator = u64::from(dimension) * u64::from(WEB_SURFACE_IMAGE_MAX_EDGE);
|
||||
let rounded = (numerator + u64::from(largest_edge / 2)) / u64::from(largest_edge);
|
||||
match u32::try_from(rounded.max(1)) {
|
||||
Ok(value) => value,
|
||||
Err(_) => WEB_SURFACE_IMAGE_MAX_EDGE,
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
use std::error::Error;
|
||||
use std::{
|
||||
env,
|
||||
error::Error,
|
||||
process::{Command, Stdio},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
|
||||
use gpui::{Bounds, point, px, size};
|
||||
|
||||
use crate::{
|
||||
services::ProfileDataMode,
|
||||
services::prd_live_sites::{
|
||||
LiveSiteCase, PRD_REFERENCE_SITE_CASES, PRD_TOP_SITE_CASES,
|
||||
assert_prd_reference_urls_are_covered,
|
||||
services::{
|
||||
ProfileDataMode,
|
||||
prd_live_sites::{
|
||||
LiveSiteCase, PRD_REFERENCE_SITE_CASES, PRD_TOP_SITE_CASES,
|
||||
assert_prd_reference_urls_are_covered,
|
||||
},
|
||||
},
|
||||
shell::{
|
||||
web_surface_frame::WebSurfaceFrame,
|
||||
@@ -22,6 +30,9 @@ const LIVE_SURFACE_WIDTH: u32 = 934;
|
||||
const LIVE_SURFACE_HEIGHT: u32 = 657;
|
||||
const MINIMUM_CONTENT_PIXELS: u64 = 1_000;
|
||||
const LIVE_SITE_RENDER_ATTEMPTS: usize = 3;
|
||||
const LIVE_SITE_WAIT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const LIVE_SITE_WAIT_INTERVAL: Duration = Duration::from_millis(2);
|
||||
const LIVE_SITE_CHILD_ENV: &str = "ELY_APP_WEB_SURFACE_LIVE_CHILD";
|
||||
|
||||
#[test]
|
||||
fn web_surface_cases_cover_prd_reference_urls() -> Result<(), Box<dyn Error>> {
|
||||
@@ -30,63 +41,110 @@ fn web_surface_cases_cover_prd_reference_urls() -> Result<(), Box<dyn Error>> {
|
||||
|
||||
#[test]
|
||||
fn web_surface_opens_and_renders_prd_top_sites() -> Result<(), Box<dyn Error>> {
|
||||
assert_web_surfaces_render(PRD_TOP_SITE_CASES)
|
||||
run_isolated_live_site_test("web_surface_opens_and_renders_prd_top_sites", || {
|
||||
assert_web_surfaces_render(PRD_TOP_SITE_CASES)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_surface_opens_and_renders_prd_reference_sites() -> Result<(), Box<dyn Error>> {
|
||||
assert_web_surfaces_render(PRD_REFERENCE_SITE_CASES)
|
||||
run_isolated_live_site_test("web_surface_opens_and_renders_prd_reference_sites", || {
|
||||
assert_web_surfaces_render(PRD_REFERENCE_SITE_CASES)
|
||||
})
|
||||
}
|
||||
|
||||
fn run_isolated_live_site_test(
|
||||
test_name: &str,
|
||||
test: impl FnOnce() -> Result<(), Box<dyn Error>>,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
if env::var_os(LIVE_SITE_CHILD_ENV).is_some() {
|
||||
return test();
|
||||
}
|
||||
|
||||
let output = Command::new(env::current_exe()?)
|
||||
.arg(test_name)
|
||||
.env(LIVE_SITE_CHILD_ENV, "1")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"isolated web surface live-site test failed\nstatus: {}\nstdout: {}\nstderr: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
fn assert_web_surfaces_render(cases: &[LiveSiteCase]) -> Result<(), Box<dyn Error>> {
|
||||
let mut store = WebSurfaceStore::new();
|
||||
let profile_id = ProfileId::new();
|
||||
|
||||
for case in cases {
|
||||
let frame = render_web_surface_frame(case)?;
|
||||
let frame = render_web_surface_frame(&mut store, &profile_id, case)?;
|
||||
log_prd_frame("web-surface", &frame, case);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_web_surface_frame(case: &LiveSiteCase) -> Result<WebSurfaceFrame, Box<dyn Error>> {
|
||||
fn render_web_surface_frame(
|
||||
store: &mut WebSurfaceStore,
|
||||
profile_id: &ProfileId,
|
||||
case: &LiveSiteCase,
|
||||
) -> Result<WebSurfaceFrame, Box<dyn Error>> {
|
||||
let mut last_error = String::new();
|
||||
|
||||
for attempt in 0..LIVE_SITE_RENDER_ATTEMPTS {
|
||||
let mut store = WebSurfaceStore::new();
|
||||
let tab = web_tab(case.url)?;
|
||||
let tab = web_tab(profile_id.clone(), case.url)?;
|
||||
assert!(store.record_viewport_size(tab.id(), live_surface_bounds()), "{}", case.url);
|
||||
let request = store
|
||||
.prepare_request(&tab, ProfileDataMode::Persistent)
|
||||
.ok_or_else(|| format!("missing web surface request for {}", case.url))?;
|
||||
let tab_id = request.tab_id.clone();
|
||||
let snapshot = request.client.snapshot(request.snapshot_request)?;
|
||||
let frame = WebSurfaceFrame::from_snapshot(
|
||||
request.requested_url,
|
||||
request.scroll_offset,
|
||||
request.zoom_percent,
|
||||
request.click_point,
|
||||
request.typed_text,
|
||||
snapshot,
|
||||
)?;
|
||||
store.ensure_surface(&tab, ProfileDataMode::Transient, &[]);
|
||||
|
||||
match validate_prd_frame(&frame, case) {
|
||||
Ok(()) => {
|
||||
store.finish(tab_id, WebSurfaceState::Ready(frame.clone()));
|
||||
let Some(WebSurfaceState::Ready(stored_frame)) = store.state(tab.id()) else {
|
||||
return Err(format!("web surface state is not ready for {}", case.url).into());
|
||||
};
|
||||
validate_prd_frame(stored_frame, case)?;
|
||||
return Ok(frame);
|
||||
}
|
||||
match wait_for_ready_frame(store, tab.id(), case) {
|
||||
Ok(frame) => return Ok(frame),
|
||||
Err(error) => last_error = error,
|
||||
}
|
||||
|
||||
if attempt + 1 < LIVE_SITE_RENDER_ATTEMPTS {
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error.into())
|
||||
}
|
||||
|
||||
fn wait_for_ready_frame(
|
||||
store: &mut WebSurfaceStore,
|
||||
tab_id: &TabId,
|
||||
case: &LiveSiteCase,
|
||||
) -> Result<WebSurfaceFrame, String> {
|
||||
let started_at = Instant::now();
|
||||
|
||||
loop {
|
||||
if started_at.elapsed() >= LIVE_SITE_WAIT_TIMEOUT {
|
||||
return Err(format!("timed out rendering {}", case.url));
|
||||
}
|
||||
|
||||
store.tick();
|
||||
match store.state(tab_id) {
|
||||
Some(WebSurfaceState::Ready(frame)) => {
|
||||
validate_prd_frame(frame, case)?;
|
||||
return Ok(frame.clone());
|
||||
}
|
||||
Some(WebSurfaceState::Failed { message, .. }) => {
|
||||
return Err(format!("{} failed: {message}", case.url));
|
||||
}
|
||||
Some(WebSurfaceState::Loading { .. }) | None => {}
|
||||
}
|
||||
|
||||
thread::sleep(LIVE_SITE_WAIT_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_prd_frame(frame: &WebSurfaceFrame, case: &LiveSiteCase) -> Result<(), String> {
|
||||
require(
|
||||
frame.size() == WebSurfaceSize { width: LIVE_SURFACE_WIDTH, height: LIVE_SURFACE_HEIGHT },
|
||||
@@ -147,8 +205,8 @@ fn live_surface_bounds() -> Bounds<gpui::Pixels> {
|
||||
)
|
||||
}
|
||||
|
||||
fn web_tab(url: &str) -> Result<BrowserTab, Box<dyn Error>> {
|
||||
Ok(BrowserTab::new(TabId::new(), SpaceId::new(), ProfileId::new(), "Web", UrlText::parse(url)?))
|
||||
fn web_tab(profile_id: ProfileId, url: &str) -> Result<BrowserTab, Box<dyn Error>> {
|
||||
Ok(BrowserTab::new(TabId::new(), SpaceId::new(), profile_id, "Web", UrlText::parse(url)?))
|
||||
}
|
||||
|
||||
fn normalized_url(url: &str) -> &str {
|
||||
|
||||
@@ -1,12 +1,39 @@
|
||||
use ely_browser_core::BrowserSnapshot;
|
||||
use ely_domain::{BrowserTab, SiteOrigin};
|
||||
use ely_domain::{BrowserTab, SiteOrigin, SitePermissionDecision, SitePermissionFeature};
|
||||
|
||||
use crate::services::servo_sidecar::SidecarSitePermission;
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(super) struct WebSurfaceSitePermission {
|
||||
origin: SiteOrigin,
|
||||
feature: SitePermissionFeature,
|
||||
decision: SitePermissionDecision,
|
||||
}
|
||||
|
||||
pub(super) fn sidecar_site_permissions_for_tab(
|
||||
impl WebSurfaceSitePermission {
|
||||
pub(super) fn new(
|
||||
origin: SiteOrigin,
|
||||
feature: SitePermissionFeature,
|
||||
decision: SitePermissionDecision,
|
||||
) -> Self {
|
||||
Self { origin, feature, decision }
|
||||
}
|
||||
|
||||
pub(super) fn origin(&self) -> &SiteOrigin {
|
||||
&self.origin
|
||||
}
|
||||
|
||||
pub(super) fn feature(&self) -> SitePermissionFeature {
|
||||
self.feature
|
||||
}
|
||||
|
||||
pub(super) fn decision(&self) -> SitePermissionDecision {
|
||||
self.decision
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn web_surface_site_permissions_for_tab(
|
||||
tab: &BrowserTab,
|
||||
snapshot: &BrowserSnapshot,
|
||||
) -> Vec<SidecarSitePermission> {
|
||||
) -> Vec<WebSurfaceSitePermission> {
|
||||
let Ok(Some(origin)) = SiteOrigin::from_url(tab.url()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
@@ -17,7 +44,7 @@ pub(super) fn sidecar_site_permissions_for_tab(
|
||||
.filter(|entry| entry.profile_id() == tab.profile_id())
|
||||
.filter(|entry| entry.origin() == &origin)
|
||||
.map(|entry| {
|
||||
SidecarSitePermission::new(entry.origin().clone(), entry.feature(), entry.decision())
|
||||
WebSurfaceSitePermission::new(entry.origin().clone(), entry.feature(), entry.decision())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -31,7 +58,7 @@ mod tests {
|
||||
BrowserTab, SiteOrigin, SitePermissionDecision, SitePermissionFeature, UrlText,
|
||||
};
|
||||
|
||||
use super::sidecar_site_permissions_for_tab;
|
||||
use super::web_surface_site_permissions_for_tab;
|
||||
|
||||
#[test]
|
||||
fn includes_matching_profile_and_origin_permissions() -> Result<(), Box<dyn Error>> {
|
||||
@@ -45,7 +72,7 @@ mod tests {
|
||||
|
||||
let snapshot = core.snapshot()?;
|
||||
let tab = active_tab(&snapshot)?;
|
||||
let permissions = sidecar_site_permissions_for_tab(tab, &snapshot);
|
||||
let permissions = web_surface_site_permissions_for_tab(tab, &snapshot);
|
||||
|
||||
assert_eq!(permissions.len(), 1);
|
||||
assert_eq!(permissions[0].origin().as_str(), "https://example.com");
|
||||
@@ -66,7 +93,7 @@ mod tests {
|
||||
let snapshot = core.snapshot()?;
|
||||
let tab = active_tab(&snapshot)?;
|
||||
|
||||
assert!(sidecar_site_permissions_for_tab(tab, &snapshot).is_empty());
|
||||
assert!(web_surface_site_permissions_for_tab(tab, &snapshot).is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -3,68 +3,60 @@ use std::error::Error;
|
||||
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
|
||||
use gpui::{Bounds, point, px, size};
|
||||
|
||||
use super::{ProfileDataMode, WebSurfaceStore};
|
||||
use super::WebSurfaceStore;
|
||||
|
||||
#[test]
|
||||
fn typed_text_enters_snapshot_request_after_clicked_viewport() -> Result<(), Box<dyn Error>> {
|
||||
fn typed_text_enters_pending_input_after_clicked_viewport() -> Result<(), Box<dyn Error>> {
|
||||
let mut store = WebSurfaceStore::new();
|
||||
let tab = web_tab("https://example.com/form")?;
|
||||
|
||||
let bounds = Bounds::new(point(px(0.0), px(0.0)), size(px(640.0), px(480.0)));
|
||||
assert!(store.record_viewport_size(tab.id(), bounds));
|
||||
assert!(store.record_viewport_size(tab.id(), web_bounds()));
|
||||
assert!(store.record_click_point(tab.id(), tab.url().as_str(), point(px(160.0), px(120.0))));
|
||||
assert!(store.record_typed_text(tab.id(), tab.url().as_str(), "e"));
|
||||
assert!(store.record_typed_text(tab.id(), tab.url().as_str(), "l"));
|
||||
|
||||
let request = store
|
||||
.prepare_request(&tab, ProfileDataMode::Persistent)
|
||||
.ok_or("missing web surface request")?;
|
||||
let input = store.take_pending_input(tab.id(), tab.url().as_str());
|
||||
|
||||
assert_eq!(request.typed_text.as_deref(), Some("el"));
|
||||
assert_eq!(request.snapshot_request.typed_text_for_test(), Some("el"));
|
||||
assert_eq!(request.snapshot_request.profile_id_for_test(), tab.profile_id());
|
||||
assert_eq!(request.zoom_percent, ely_domain::DEFAULT_ZOOM_PERCENT);
|
||||
assert_eq!(
|
||||
request.snapshot_request.page_zoom_percent_for_test(),
|
||||
ely_domain::DEFAULT_ZOOM_PERCENT
|
||||
);
|
||||
assert_eq!(input.click_point.map(|point| (point.x(), point.y())), Some((160, 120)));
|
||||
assert_eq!(input.typed_text.as_deref(), Some("el"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_profile_enters_transient_snapshot_request() -> Result<(), Box<dyn Error>> {
|
||||
fn scroll_delta_enters_pending_input_after_wheel() -> Result<(), Box<dyn Error>> {
|
||||
let mut store = WebSurfaceStore::new();
|
||||
let tab = web_tab("https://example.com/private")?;
|
||||
let tab = web_tab("https://example.com/list")?;
|
||||
|
||||
let bounds = Bounds::new(point(px(0.0), px(0.0)), size(px(640.0), px(480.0)));
|
||||
assert!(store.record_viewport_size(tab.id(), bounds));
|
||||
assert!(store.record_viewport_size(tab.id(), web_bounds()));
|
||||
assert!(store.record_scroll_delta(tab.id(), tab.url().as_str(), point(px(0.0), px(140.0))));
|
||||
assert!(store.record_scroll_delta(tab.id(), tab.url().as_str(), point(px(0.0), px(60.0))));
|
||||
|
||||
let request = store
|
||||
.prepare_request(&tab, ProfileDataMode::Transient)
|
||||
.ok_or("missing web surface request")?;
|
||||
let input = store.take_pending_input(tab.id(), tab.url().as_str());
|
||||
|
||||
assert_eq!(request.snapshot_request.profile_data_mode_for_test(), ProfileDataMode::Transient);
|
||||
assert_eq!(input.scroll_offset.y(), 200);
|
||||
assert_eq!(input.scroll_delta.map(|delta| (delta.x(), delta.y())), Some((0, 200)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_zoom_enters_snapshot_request() -> Result<(), Box<dyn Error>> {
|
||||
fn viewport_size_changes_after_stable_second_measurement() -> Result<(), Box<dyn Error>> {
|
||||
let mut store = WebSurfaceStore::new();
|
||||
let mut tab = web_tab("https://example.com/zoom")?;
|
||||
tab.set_zoom_percent(125)?;
|
||||
let tab = web_tab("https://example.com/resize")?;
|
||||
|
||||
let bounds = Bounds::new(point(px(0.0), px(0.0)), size(px(640.0), px(480.0)));
|
||||
assert!(store.record_viewport_size(tab.id(), bounds));
|
||||
|
||||
let request = store
|
||||
.prepare_request(&tab, ProfileDataMode::Persistent)
|
||||
.ok_or("missing web surface request")?;
|
||||
|
||||
assert_eq!(request.zoom_percent, 125);
|
||||
assert_eq!(request.snapshot_request.page_zoom_percent_for_test(), 125);
|
||||
assert!(store.record_viewport_size(tab.id(), web_bounds()));
|
||||
assert!(!store.record_viewport_size(tab.id(), resized_once_bounds()));
|
||||
assert!(store.record_viewport_size(tab.id(), resized_once_bounds()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn web_bounds() -> Bounds<gpui::Pixels> {
|
||||
Bounds::new(point(px(0.0), px(0.0)), size(px(640.0), px(480.0)))
|
||||
}
|
||||
|
||||
fn resized_once_bounds() -> Bounds<gpui::Pixels> {
|
||||
Bounds::new(point(px(0.0), px(0.0)), size(px(934.0), px(657.0)))
|
||||
}
|
||||
|
||||
fn web_tab(url: &str) -> Result<BrowserTab, Box<dyn Error>> {
|
||||
Ok(BrowserTab::new(TabId::new(), SpaceId::new(), ProfileId::new(), "Web", UrlText::parse(url)?))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user