Add PRD reference site smoke coverage

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