diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs index 5e5da82..b727133 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs @@ -8,13 +8,17 @@ use std::{ use ely_domain::{ProfileId, TabId, UrlText}; use ely_servo_host::{ - KeyboardTextRequest, MouseClickRequest, NavigationRequest, RenderedFrame, ScrollRequest, + KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest, ScrollRequest, ServoHost, ServoHostError, ServoSurfaceSize, SoftwareServoHost, TouchTapRequest, WebViewSnapshot, WebViewState, }; -use serde::Serialize; use thiserror::Error; +#[path = "ely_servo_sidecar/report.rs"] +mod report; + +use report::{SnapshotInputChanges, SnapshotReport}; + const WAIT_ITERATIONS: usize = 5_000; const WAIT_INTERVAL: Duration = Duration::from_millis(2); const RENDER_TIMEOUT: Duration = Duration::from_secs(20); @@ -38,6 +42,7 @@ struct SnapshotArgs { scroll_x: i32, scroll_y: i32, click_point: Option, + drag_points: Option, touch_point: Option, typed_text: Option, } @@ -48,6 +53,12 @@ struct ClickPoint { y: u32, } +#[derive(Clone, Copy)] +struct DragPoints { + from: ClickPoint, + to: ClickPoint, +} + #[derive(Debug, Error)] enum SidecarError { #[error("missing sidecar command")] @@ -79,6 +90,9 @@ enum SidecarError { #[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, @@ -124,6 +138,10 @@ fn parse_snapshot_args( 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; @@ -160,6 +178,30 @@ fn parse_snapshot_args( 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", @@ -182,6 +224,14 @@ fn parse_snapshot_args( (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, @@ -196,6 +246,7 @@ fn parse_snapshot_args( scroll_x, scroll_y, click_point, + drag_points, touch_point, typed_text, }) @@ -254,6 +305,8 @@ fn run_snapshot(args: SnapshotArgs) -> Result<(), SidecarError> { apply_scroll_if_requested(&mut host, &webview_id, &args, snapshot)?; let (snapshot, click_changed_frame) = apply_click_if_requested(&mut host, &webview_id, &args, snapshot)?; + let (snapshot, drag_changed_frame) = + apply_drag_if_requested(&mut host, &webview_id, &args, snapshot)?; let (snapshot, touch_changed_frame) = apply_touch_if_requested(&mut host, &webview_id, &args, snapshot)?; let (snapshot, text_changed_frame) = @@ -267,10 +320,13 @@ fn run_snapshot(args: SnapshotArgs) -> Result<(), SidecarError> { &args, &snapshot, &frame, - scroll_changed_frame, - click_changed_frame, - touch_changed_frame, - text_changed_frame, + SnapshotInputChanges { + scroll: scroll_changed_frame, + click: click_changed_frame, + drag: drag_changed_frame, + touch: touch_changed_frame, + text: text_changed_frame, + }, ), )?; Ok(()) @@ -314,6 +370,27 @@ fn apply_click_if_requested( wait_for_changed_or_settled_frame(host, webview_id, previous_frame_hash) } +fn apply_drag_if_requested( + host: &mut SoftwareServoHost, + webview_id: &ely_domain::WebViewId, + args: &SnapshotArgs, + snapshot: WebViewSnapshot, +) -> Result<(WebViewSnapshot, bool), SidecarError> { + let Some(drag_points) = args.drag_points else { + return Ok((snapshot, false)); + }; + + let previous_frame_hash = host.last_rendered_frame()?.sample_hash(); + host.drag(MouseDragRequest { + webview_id: webview_id.clone(), + from_x: drag_points.from.x, + from_y: drag_points.from.y, + to_x: drag_points.to.x, + to_y: drag_points.to.y, + })?; + wait_for_changed_or_settled_frame(host, webview_id, previous_frame_hash) +} + fn apply_touch_if_requested( host: &mut SoftwareServoHost, webview_id: &ely_domain::WebViewId, @@ -416,78 +493,3 @@ fn wait_for_changed_or_settled_frame( Ok((latest_snapshot, false)) } - -#[derive(Serialize)] -struct SnapshotReport { - requested_url: String, - loaded_url: Option, - title: Option, - rgba_path: String, - state: &'static str, - width: u32, - height: u32, - rgba_byte_count: usize, - opaque_pixel_count: u64, - non_white_pixel_count: u64, - content_pixel_count: u64, - sample_hash: u64, - scroll_x: i32, - scroll_y: i32, - scroll_changed_frame: bool, - click_x: Option, - click_y: Option, - click_changed_frame: bool, - touch_x: Option, - touch_y: Option, - touch_changed_frame: bool, - typed_text_byte_count: usize, - text_changed_frame: bool, -} - -impl SnapshotReport { - fn new( - args: &SnapshotArgs, - snapshot: &WebViewSnapshot, - frame: &RenderedFrame, - scroll_changed_frame: bool, - click_changed_frame: bool, - touch_changed_frame: bool, - text_changed_frame: bool, - ) -> Self { - Self { - requested_url: args.url.as_str().to_string(), - loaded_url: snapshot.url().map(str::to_string), - title: snapshot.title().map(str::to_string), - rgba_path: args.rgba_out.display().to_string(), - state: state_label(snapshot.state()), - width: frame.width(), - height: frame.height(), - rgba_byte_count: frame.rgba_bytes().len(), - opaque_pixel_count: frame.opaque_pixel_count(), - non_white_pixel_count: frame.non_white_pixel_count(), - content_pixel_count: frame.content_pixel_count(), - sample_hash: frame.sample_hash(), - scroll_x: args.scroll_x, - scroll_y: args.scroll_y, - scroll_changed_frame, - click_x: args.click_point.map(|point| point.x), - click_y: args.click_point.map(|point| point.y), - click_changed_frame, - touch_x: args.touch_point.map(|point| point.x), - touch_y: args.touch_point.map(|point| point.y), - touch_changed_frame, - typed_text_byte_count: args.typed_text.as_ref().map_or(0, String::len), - text_changed_frame, - } - } -} - -fn state_label(state: &WebViewState) -> &'static str { - match state { - WebViewState::Created => "created", - WebViewState::Loading => "loading", - WebViewState::Complete => "complete", - WebViewState::Sleeping => "sleeping", - WebViewState::Crashed => "crashed", - } -} diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/report.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/report.rs new file mode 100644 index 0000000..f1432b8 --- /dev/null +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/report.rs @@ -0,0 +1,94 @@ +use ely_servo_host::{RenderedFrame, WebViewSnapshot, WebViewState}; +use serde::Serialize; + +use super::SnapshotArgs; + +pub(super) struct SnapshotInputChanges { + pub(super) scroll: bool, + pub(super) click: bool, + pub(super) drag: bool, + pub(super) touch: bool, + pub(super) text: bool, +} + +#[derive(Serialize)] +pub(super) struct SnapshotReport { + requested_url: String, + loaded_url: Option, + title: Option, + rgba_path: String, + state: &'static str, + width: u32, + height: u32, + rgba_byte_count: usize, + opaque_pixel_count: u64, + non_white_pixel_count: u64, + content_pixel_count: u64, + sample_hash: u64, + scroll_x: i32, + scroll_y: i32, + scroll_changed_frame: bool, + click_x: Option, + click_y: Option, + click_changed_frame: bool, + drag_from_x: Option, + drag_from_y: Option, + drag_to_x: Option, + drag_to_y: Option, + drag_changed_frame: bool, + touch_x: Option, + touch_y: Option, + touch_changed_frame: bool, + typed_text_byte_count: usize, + text_changed_frame: bool, +} + +impl SnapshotReport { + pub(super) fn new( + args: &SnapshotArgs, + snapshot: &WebViewSnapshot, + frame: &RenderedFrame, + changes: SnapshotInputChanges, + ) -> Self { + Self { + requested_url: args.url.as_str().to_string(), + loaded_url: snapshot.url().map(str::to_string), + title: snapshot.title().map(str::to_string), + rgba_path: args.rgba_out.display().to_string(), + state: state_label(snapshot.state()), + width: frame.width(), + height: frame.height(), + rgba_byte_count: frame.rgba_bytes().len(), + opaque_pixel_count: frame.opaque_pixel_count(), + non_white_pixel_count: frame.non_white_pixel_count(), + content_pixel_count: frame.content_pixel_count(), + sample_hash: frame.sample_hash(), + scroll_x: args.scroll_x, + scroll_y: args.scroll_y, + scroll_changed_frame: changes.scroll, + click_x: args.click_point.map(|point| point.x), + click_y: args.click_point.map(|point| point.y), + click_changed_frame: changes.click, + drag_from_x: args.drag_points.map(|points| points.from.x), + drag_from_y: args.drag_points.map(|points| points.from.y), + drag_to_x: args.drag_points.map(|points| points.to.x), + drag_to_y: args.drag_points.map(|points| points.to.y), + drag_changed_frame: changes.drag, + touch_x: args.touch_point.map(|point| point.x), + touch_y: args.touch_point.map(|point| point.y), + touch_changed_frame: changes.touch, + typed_text_byte_count: args.typed_text.as_ref().map_or(0, String::len), + text_changed_frame: changes.text, + } + } +} + +fn state_label(state: &WebViewState) -> &'static str { + match state { + WebViewState::Created => "created", + WebViewState::Loading => "loading", + WebViewState::Complete => "complete", + WebViewState::Sleeping => "sleeping", + WebViewState::Crashed => "crashed", + } +} diff --git a/crates/ely_servo_host/src/host.rs b/crates/ely_servo_host/src/host.rs index 89174cf..01eb10e 100644 --- a/crates/ely_servo_host/src/host.rs +++ b/crates/ely_servo_host/src/host.rs @@ -227,6 +227,15 @@ pub struct MouseClickRequest { pub y: u32, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MouseDragRequest { + pub webview_id: WebViewId, + pub from_x: u32, + pub from_y: u32, + pub to_x: u32, + pub to_y: u32, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct TouchTapRequest { pub webview_id: WebViewId, @@ -268,6 +277,8 @@ pub trait ServoHost { fn click(&mut self, request: MouseClickRequest) -> Result<(), ServoHostError>; + fn drag(&mut self, request: MouseDragRequest) -> Result<(), ServoHostError>; + fn touch_tap(&mut self, request: TouchTapRequest) -> Result<(), ServoHostError>; fn type_text(&mut self, request: KeyboardTextRequest) -> Result<(), ServoHostError>; diff --git a/crates/ely_servo_host/src/lib.rs b/crates/ely_servo_host/src/lib.rs index e418e96..9223112 100644 --- a/crates/ely_servo_host/src/lib.rs +++ b/crates/ely_servo_host/src/lib.rs @@ -4,12 +4,14 @@ mod host; mod keyboard; #[cfg(feature = "servo-engine")] mod runtime; +#[cfg(feature = "servo-engine")] +mod runtime_input; pub use error::ServoHostError; pub use host::{ - KeyboardTextRequest, MouseClickRequest, NavigationRequest, PermissionDecision, - PermissionRequest, RenderedFrame, RenderedFrameSummary, ScrollRequest, ServoHost, - TouchTapRequest, WebViewSnapshot, WebViewState, + KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest, + PermissionDecision, PermissionRequest, RenderedFrame, RenderedFrameSummary, ScrollRequest, + ServoHost, TouchTapRequest, WebViewSnapshot, WebViewState, }; #[cfg(feature = "servo-engine")] pub use runtime::{ServoSurfaceSize, SoftwareServoHost}; diff --git a/crates/ely_servo_host/src/runtime.rs b/crates/ely_servo_host/src/runtime.rs index e392b0a..9c15256 100644 --- a/crates/ely_servo_host/src/runtime.rs +++ b/crates/ely_servo_host/src/runtime.rs @@ -12,17 +12,16 @@ use dpi::PhysicalSize; use ely_domain::{ProfileId, TabId, WebViewId}; use servo::{ DeviceIntPoint, DeviceIntRect, DeviceIntSize, DevicePoint, DeviceVector2D, EventLoopWaker, - InputEvent, Key, KeyState, KeyboardEvent, LoadStatus, Location, Modifiers, MouseButton, - MouseButtonAction, MouseButtonEvent, MouseMoveEvent, RenderingContext, Scroll, Servo, - ServoBuilder, TouchEvent, TouchEventType, TouchId, WebView, WebViewBuilder, WebViewDelegate, - WebViewPoint, WebViewVector, + LoadStatus, RenderingContext, Scroll, Servo, ServoBuilder, WebView, WebViewBuilder, + WebViewDelegate, WebViewPoint, WebViewVector, }; use url::Url; use crate::{ - KeyboardTextRequest, MouseClickRequest, NavigationRequest, PermissionDecision, - PermissionRequest, RenderedFrame, ScrollRequest, ServoHost, ServoHostError, TouchTapRequest, - WebViewSnapshot, WebViewState, keyboard::keyboard_code_for_character, + KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest, + PermissionDecision, PermissionRequest, RenderedFrame, ScrollRequest, ServoHost, ServoHostError, + TouchTapRequest, WebViewSnapshot, WebViewState, + runtime_input::{send_keyboard_text, send_mouse_click, send_mouse_drag, send_touch_tap}, }; static SERVO_RUNTIME_STARTED: AtomicBool = AtomicBool::new(false); @@ -172,18 +171,23 @@ impl ServoHost for SoftwareServoHost { .get(&request.webview_id) .ok_or_else(|| ServoHostError::WebViewNotFound { id: request.webview_id.clone() })?; - let point = WebViewPoint::Device(DevicePoint::new(request.x as f32, request.y as f32)); - webview.webview.notify_input_event(InputEvent::MouseMove(MouseMoveEvent::new(point))); - webview.webview.notify_input_event(InputEvent::MouseButton(MouseButtonEvent::new( - MouseButtonAction::Down, - MouseButton::Left, - point, - ))); - webview.webview.notify_input_event(InputEvent::MouseButton(MouseButtonEvent::new( - MouseButtonAction::Up, - MouseButton::Left, - point, - ))); + send_mouse_click(&webview.webview, request.x, request.y); + Ok(()) + } + + fn drag(&mut self, request: MouseDragRequest) -> Result<(), ServoHostError> { + let webview = self + .webviews + .get(&request.webview_id) + .ok_or_else(|| ServoHostError::WebViewNotFound { id: request.webview_id.clone() })?; + + send_mouse_drag( + &webview.webview, + request.from_x, + request.from_y, + request.to_x, + request.to_y, + ); Ok(()) } @@ -193,13 +197,7 @@ impl ServoHost for SoftwareServoHost { .get(&request.webview_id) .ok_or_else(|| ServoHostError::WebViewNotFound { id: request.webview_id.clone() })?; - let point = WebViewPoint::Device(DevicePoint::new(request.x as f32, request.y as f32)); - let touch_id = TouchId(1); - for event_type in [TouchEventType::Down, TouchEventType::Up] { - webview.webview.notify_input_event(InputEvent::Touch(TouchEvent::new( - event_type, touch_id, point, - ))); - } + send_touch_tap(&webview.webview, request.x, request.y); Ok(()) } @@ -209,32 +207,7 @@ impl ServoHost for SoftwareServoHost { .get(&request.webview_id) .ok_or_else(|| ServoHostError::WebViewNotFound { id: request.webview_id.clone() })?; - for character in request.text.chars() { - let key = Key::Character(character.to_string()); - let code = keyboard_code_for_character(character); - webview.webview.notify_input_event(InputEvent::Keyboard( - KeyboardEvent::new_without_event( - KeyState::Down, - key.clone(), - code, - Location::Standard, - Modifiers::empty(), - false, - false, - ), - )); - webview.webview.notify_input_event(InputEvent::Keyboard( - KeyboardEvent::new_without_event( - KeyState::Up, - key, - code, - Location::Standard, - Modifiers::empty(), - false, - false, - ), - )); - } + send_keyboard_text(&webview.webview, &request.text); Ok(()) } diff --git a/crates/ely_servo_host/src/runtime_input.rs b/crates/ely_servo_host/src/runtime_input.rs new file mode 100644 index 0000000..0231cd9 --- /dev/null +++ b/crates/ely_servo_host/src/runtime_input.rs @@ -0,0 +1,68 @@ +use servo::{ + DevicePoint, InputEvent, Key, KeyState, KeyboardEvent, Location, Modifiers, MouseButton, + MouseButtonAction, MouseButtonEvent, MouseMoveEvent, TouchEvent, TouchEventType, TouchId, + WebView, WebViewPoint, +}; + +use crate::keyboard::keyboard_code_for_character; + +pub(super) fn send_mouse_click(webview: &WebView, x: u32, y: u32) { + let point = point(x, y); + webview.notify_input_event(InputEvent::MouseMove(MouseMoveEvent::new(point))); + send_mouse_button(webview, MouseButtonAction::Down, point); + send_mouse_button(webview, MouseButtonAction::Up, point); +} + +pub(super) fn send_mouse_drag(webview: &WebView, from_x: u32, from_y: u32, to_x: u32, to_y: u32) { + let from = point(from_x, from_y); + let to = point(to_x, to_y); + webview.notify_input_event(InputEvent::MouseMove(MouseMoveEvent::new(from))); + send_mouse_button(webview, MouseButtonAction::Down, from); + webview.notify_input_event(InputEvent::MouseMove(MouseMoveEvent::new(to))); + send_mouse_button(webview, MouseButtonAction::Up, to); +} + +pub(super) fn send_touch_tap(webview: &WebView, x: u32, y: u32) { + let point = point(x, y); + let touch_id = TouchId(1); + for event_type in [TouchEventType::Down, TouchEventType::Up] { + webview.notify_input_event(InputEvent::Touch(TouchEvent::new(event_type, touch_id, point))); + } +} + +pub(super) fn send_keyboard_text(webview: &WebView, text: &str) { + for character in text.chars() { + let key = Key::Character(character.to_string()); + let code = keyboard_code_for_character(character); + webview.notify_input_event(InputEvent::Keyboard(KeyboardEvent::new_without_event( + KeyState::Down, + key.clone(), + code, + Location::Standard, + Modifiers::empty(), + false, + false, + ))); + webview.notify_input_event(InputEvent::Keyboard(KeyboardEvent::new_without_event( + KeyState::Up, + key, + code, + Location::Standard, + Modifiers::empty(), + false, + false, + ))); + } +} + +fn send_mouse_button(webview: &WebView, action: MouseButtonAction, point: WebViewPoint) { + webview.notify_input_event(InputEvent::MouseButton(MouseButtonEvent::new( + action, + MouseButton::Left, + point, + ))); +} + +fn point(x: u32, y: u32) -> WebViewPoint { + WebViewPoint::Device(DevicePoint::new(x as f32, y as f32)) +} diff --git a/crates/ely_servo_host/tests/sidecar.rs b/crates/ely_servo_host/tests/sidecar.rs index c9d4e21..0d858b5 100644 --- a/crates/ely_servo_host/tests/sidecar.rs +++ b/crates/ely_servo_host/tests/sidecar.rs @@ -1,66 +1,11 @@ #![cfg(feature = "servo-engine")] -use std::{ - error::Error, - io, - process::{Child, Command, Output, Stdio}, - thread, - time::{Duration, Instant}, -}; +use std::error::Error; -const MINIMUM_CONTENT_PIXELS: u64 = 1_000; -const SIDECAR_TIMEOUT: Duration = Duration::from_secs(25); -const SIDECAR_POLL_INTERVAL: Duration = Duration::from_millis(20); -const PRD_SITE_COMPATIBILITY_CASES: &[PrdSiteCompatibilityCase] = &[ - PrdSiteCompatibilityCase { url: "https://example.com", title_fragment: "Example Domain" }, - PrdSiteCompatibilityCase { url: "https://servo.org", title_fragment: "Servo" }, -]; -const PRD_SITE_COMPATIBILITY_SIZES: &[FrameSize] = &[ - FrameSize { width: 640, height: 480 }, - FrameSize { width: 934, height: 657 }, - FrameSize { width: 1614, height: 980 }, -]; -const SERVO_SCROLL_SITE: PrdSiteCompatibilityCase = - PrdSiteCompatibilityCase { url: "https://servo.org", title_fragment: "Servo" }; -const SERVO_SCROLL_SIZE: FrameSize = FrameSize { width: 934, height: 657 }; -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_SIZE: FrameSize = FrameSize { width: 640, height: 480 }; -const SERVO_CLICK_POINT: ClickPoint = ClickPoint { x: 160, y: 120 }; -const SERVO_TOUCH_URL: &str = "data:text/html,%3C%21doctype%20html%3E%3Ctitle%3ETouch%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%3Btouch-action%3Amanipulation%3B%7D%3C%2Fstyle%3E%3Cbutton%20ontouchstart%3D%22document.body.dataset.touch%3D%27start%27%3B%22%20onclick%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Touched%27%3Bthis.textContent%3D%27Touched%27%3B%22%3ETap%3C%2Fbutton%3E"; -const SERVO_TOUCH_SIZE: FrameSize = FrameSize { width: 640, height: 480 }; -const SERVO_TOUCH_POINT: ClickPoint = ClickPoint { x: 160, y: 120 }; -const SERVO_TEXT_URL: &str = "data:text/html,%3C!doctype%20html%3E%3Ctitle%3EText%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3Bfont%3A28px%20sans-serif%3B%7Dinput%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A260px%3Bheight%3A70px%3Bfont%3A28px%20sans-serif%3B%7Doutput%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A180px%3Bfont%3A32px%20sans-serif%3B%7D%3C%2Fstyle%3E%3Cinput%20id%3Dq%20autofocus%20oninput%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.getElementById%28%27out%27%29.textContent%3Dthis.value%3B%22%3E%3Coutput%20id%3Dout%3Eempty%3C%2Foutput%3E"; -const SERVO_TEXT_SIZE: FrameSize = FrameSize { width: 640, height: 480 }; -const SERVO_TEXT_POINT: ClickPoint = ClickPoint { x: 160, y: 120 }; -const SERVO_TEXT_VALUE: &str = "ely42"; +#[path = "sidecar/support.rs"] +mod support; -struct PrdSiteCompatibilityCase { - url: &'static str, - title_fragment: &'static str, -} - -#[derive(Clone, Copy)] -struct FrameSize { - width: u64, - height: u64, -} - -#[derive(Clone, Copy)] -struct ScrollOffset { - x: i64, - y: i64, -} - -impl ScrollOffset { - const ZERO: Self = Self { x: 0, y: 0 }; -} - -#[derive(Clone, Copy)] -struct ClickPoint { - x: u64, - y: u64, -} +use support::*; #[test] fn sidecar_snapshots_prd_sites_to_rgba_files() -> Result<(), Box> { @@ -82,22 +27,11 @@ fn sidecar_scrolls_prd_site_with_servo_input() -> Result<(), Box> { assert_eq!(report_field_as_i64(&scrolled_report, "scroll_x")?, SERVO_SCROLL_OFFSET.x); assert_eq!(report_field_as_i64(&scrolled_report, "scroll_y")?, SERVO_SCROLL_OFFSET.y); - assert_eq!( - report_field_as_u64(&scrolled_report, "width")?, - SERVO_SCROLL_SIZE.width, - "{}", - SERVO_SCROLL_SITE.url - ); - assert!( - report_field_as_bool(&scrolled_report, "scroll_changed_frame")?, - "{}", - SERVO_SCROLL_SITE.url - ); + assert_eq!(report_field_as_u64(&scrolled_report, "width")?, SERVO_SCROLL_SIZE.width); + assert!(report_field_as_bool(&scrolled_report, "scroll_changed_frame")?); assert_ne!( report_field_as_u64(&initial_report, "sample_hash")?, - report_field_as_u64(&scrolled_report, "sample_hash")?, - "{}", - SERVO_SCROLL_SITE.url + report_field_as_u64(&scrolled_report, "sample_hash")? ); Ok(()) @@ -119,6 +53,25 @@ fn sidecar_clicks_page_with_servo_mouse_input() -> Result<(), Box> { Ok(()) } +#[test] +fn sidecar_drags_page_with_servo_mouse_input() -> Result<(), Box> { + let initial_report = snapshot_drag_probe(None)?; + let drag_points = DragPoints { from: SERVO_DRAG_FROM, to: SERVO_DRAG_TO }; + let dragged_report = snapshot_drag_probe(Some(drag_points))?; + + assert_eq!(report_field_as_u64(&dragged_report, "drag_from_x")?, SERVO_DRAG_FROM.x); + assert_eq!(report_field_as_u64(&dragged_report, "drag_from_y")?, SERVO_DRAG_FROM.y); + assert_eq!(report_field_as_u64(&dragged_report, "drag_to_x")?, SERVO_DRAG_TO.x); + assert_eq!(report_field_as_u64(&dragged_report, "drag_to_y")?, SERVO_DRAG_TO.y); + assert!(report_field_as_bool(&dragged_report, "drag_changed_frame")?); + assert_ne!( + report_field_as_u64(&initial_report, "sample_hash")?, + report_field_as_u64(&dragged_report, "sample_hash")? + ); + + Ok(()) +} + #[test] fn sidecar_touches_page_with_servo_touch_input() -> Result<(), Box> { let initial_report = snapshot_touch_probe(None)?; @@ -152,311 +105,3 @@ fn sidecar_types_text_with_servo_keyboard_input() -> Result<(), Box> Ok(()) } - -fn snapshot_prd_site( - case: &PrdSiteCompatibilityCase, - size: FrameSize, - scroll_offset: ScrollOffset, -) -> Result> { - let site_name = case - .url - .chars() - .map(|character| if character.is_ascii_alphanumeric() { character } else { '-' }) - .collect::(); - let output_path = std::env::temp_dir().join(format!( - "ely-servo-sidecar-{}-{site_name}-{}x{}-{}-{}.rgba", - std::process::id(), - size.width, - size.height, - scroll_offset.x, - scroll_offset.y - )); - - if output_path.exists() { - std::fs::remove_file(&output_path)?; - } - - let output = - run_sidecar_snapshot(case.url, &output_path, size, scroll_offset, None, None, None)?; - - assert!( - output.status.success(), - "{} {}x{}\nstatus: {:?}\nstdout: {}\nstderr: {}", - case.url, - size.width, - size.height, - output.status.code(), - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - - let report: serde_json::Value = serde_json::from_slice(&output.stdout)?; - assert_eq!(report_field_as_u64(&report, "width")?, size.width, "{}", case.url); - assert_eq!(report_field_as_u64(&report, "height")?, size.height, "{}", case.url); - assert_eq!( - report_field_as_u64(&report, "rgba_byte_count")?, - size.width * size.height * 4, - "{}", - case.url - ); - assert_report_text_contains(&report, "loaded_url", case.url)?; - assert_report_text_contains(&report, "title", case.title_fragment)?; - assert!(report_field_as_u64(&report, "non_white_pixel_count")? > 0, "{}", case.url); - assert!( - report_field_as_u64(&report, "content_pixel_count")? >= MINIMUM_CONTENT_PIXELS, - "{}", - case.url - ); - assert!(report_field_as_u64(&report, "sample_hash")? > 0, "{}", case.url); - assert_eq!(std::fs::metadata(&output_path)?.len(), size.width * size.height * 4); - - std::fs::remove_file(&output_path)?; - Ok(report) -} - -fn snapshot_click_probe( - click_point: Option, -) -> Result> { - let output_path = std::env::temp_dir().join(format!( - "ely-servo-sidecar-{}-click-{}x{}.rgba", - std::process::id(), - SERVO_CLICK_SIZE.width, - SERVO_CLICK_SIZE.height - )); - - if output_path.exists() { - std::fs::remove_file(&output_path)?; - } - - let output = run_sidecar_snapshot( - SERVO_CLICK_URL, - &output_path, - SERVO_CLICK_SIZE, - ScrollOffset::ZERO, - click_point, - None, - None, - )?; - - assert!( - output.status.success(), - "click probe\nstatus: {:?}\nstdout: {}\nstderr: {}", - output.status.code(), - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - - let report: serde_json::Value = serde_json::from_slice(&output.stdout)?; - assert_eq!(report_field_as_u64(&report, "width")?, SERVO_CLICK_SIZE.width); - assert_eq!(report_field_as_u64(&report, "height")?, SERVO_CLICK_SIZE.height); - assert!(report_field_as_u64(&report, "content_pixel_count")? > 0); - assert_eq!( - std::fs::metadata(&output_path)?.len(), - SERVO_CLICK_SIZE.width * SERVO_CLICK_SIZE.height * 4 - ); - - std::fs::remove_file(&output_path)?; - Ok(report) -} - -fn snapshot_touch_probe( - touch_point: Option, -) -> Result> { - let output_path = std::env::temp_dir().join(format!( - "ely-servo-sidecar-{}-touch-{}x{}.rgba", - std::process::id(), - SERVO_TOUCH_SIZE.width, - SERVO_TOUCH_SIZE.height - )); - - if output_path.exists() { - std::fs::remove_file(&output_path)?; - } - - let output = run_sidecar_snapshot( - SERVO_TOUCH_URL, - &output_path, - SERVO_TOUCH_SIZE, - ScrollOffset::ZERO, - None, - touch_point, - None, - )?; - - assert!( - output.status.success(), - "touch probe\nstatus: {:?}\nstdout: {}\nstderr: {}", - output.status.code(), - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - - let report: serde_json::Value = serde_json::from_slice(&output.stdout)?; - assert_eq!(report_field_as_u64(&report, "width")?, SERVO_TOUCH_SIZE.width); - assert_eq!(report_field_as_u64(&report, "height")?, SERVO_TOUCH_SIZE.height); - assert!(report_field_as_u64(&report, "content_pixel_count")? > 0); - assert_eq!( - std::fs::metadata(&output_path)?.len(), - SERVO_TOUCH_SIZE.width * SERVO_TOUCH_SIZE.height * 4 - ); - - std::fs::remove_file(&output_path)?; - Ok(report) -} - -fn snapshot_text_probe(typed_text: Option<&str>) -> Result> { - let output_path = std::env::temp_dir().join(format!( - "ely-servo-sidecar-{}-text-{}x{}.rgba", - std::process::id(), - SERVO_TEXT_SIZE.width, - SERVO_TEXT_SIZE.height - )); - - if output_path.exists() { - std::fs::remove_file(&output_path)?; - } - - let click_point = typed_text.map(|_| SERVO_TEXT_POINT); - let output = run_sidecar_snapshot( - SERVO_TEXT_URL, - &output_path, - SERVO_TEXT_SIZE, - ScrollOffset::ZERO, - click_point, - None, - typed_text, - )?; - - assert!( - output.status.success(), - "text probe\nstatus: {:?}\nstdout: {}\nstderr: {}", - output.status.code(), - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - - let report: serde_json::Value = serde_json::from_slice(&output.stdout)?; - assert_eq!(report_field_as_u64(&report, "width")?, SERVO_TEXT_SIZE.width); - assert_eq!(report_field_as_u64(&report, "height")?, SERVO_TEXT_SIZE.height); - assert!(report_field_as_u64(&report, "content_pixel_count")? > 0); - assert_eq!( - std::fs::metadata(&output_path)?.len(), - SERVO_TEXT_SIZE.width * SERVO_TEXT_SIZE.height * 4 - ); - - std::fs::remove_file(&output_path)?; - Ok(report) -} - -fn run_sidecar_snapshot( - site_url: &str, - output_path: &std::path::Path, - size: FrameSize, - scroll_offset: ScrollOffset, - click_point: Option, - touch_point: Option, - typed_text: Option<&str>, -) -> Result> { - let mut command = Command::new(env!("CARGO_BIN_EXE_ely_servo_sidecar")); - command - .arg("snapshot") - .arg("--url") - .arg(site_url) - .arg("--rgba-out") - .arg(output_path) - .arg("--width") - .arg(size.width.to_string()) - .arg("--height") - .arg(size.height.to_string()); - if scroll_offset.x != 0 { - command.arg("--scroll-x").arg(scroll_offset.x.to_string()); - } - if scroll_offset.y != 0 { - command.arg("--scroll-y").arg(scroll_offset.y.to_string()); - } - if let Some(click_point) = click_point { - command.arg("--click-x").arg(click_point.x.to_string()); - command.arg("--click-y").arg(click_point.y.to_string()); - } - if let Some(touch_point) = touch_point { - command.arg("--touch-x").arg(touch_point.x.to_string()); - command.arg("--touch-y").arg(touch_point.y.to_string()); - } - if let Some(typed_text) = typed_text { - command.arg("--type-text").arg(typed_text); - } - - let mut child = command.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?; - - let started_at = Instant::now(); - loop { - if child.try_wait()?.is_some() { - return child.wait_with_output().map_err(Into::into); - } - - if started_at.elapsed() >= SIDECAR_TIMEOUT { - terminate_child(child)?; - return Err(format!( - "timed out rendering {site_url} at {}x{}", - size.width, size.height - ) - .into()); - } - - thread::sleep(SIDECAR_POLL_INTERVAL); - } -} - -fn terminate_child(mut child: Child) -> Result<(), Box> { - match child.kill() { - Ok(()) => { - let _output = child.wait_with_output()?; - Ok(()) - } - Err(error) if error.kind() == io::ErrorKind::InvalidInput => Ok(()), - Err(error) => Err(error.into()), - } -} - -fn assert_report_text_contains( - report: &serde_json::Value, - field: &'static str, - fragment: &str, -) -> Result<(), Box> { - let value = report - .get(field) - .and_then(serde_json::Value::as_str) - .ok_or_else(|| format!("missing text report field: {field}"))?; - assert!(value.contains(fragment), "{field}: {value}"); - Ok(()) -} - -fn report_field_as_bool( - report: &serde_json::Value, - field: &'static str, -) -> Result> { - report - .get(field) - .and_then(serde_json::Value::as_bool) - .ok_or_else(|| format!("missing boolean report field: {field}").into()) -} - -fn report_field_as_i64( - report: &serde_json::Value, - field: &'static str, -) -> Result> { - report - .get(field) - .and_then(serde_json::Value::as_i64) - .ok_or_else(|| format!("missing signed report field: {field}").into()) -} - -fn report_field_as_u64( - report: &serde_json::Value, - field: &'static str, -) -> Result> { - report - .get(field) - .and_then(serde_json::Value::as_u64) - .ok_or_else(|| format!("missing numeric report field: {field}").into()) -} diff --git a/crates/ely_servo_host/tests/sidecar/support.rs b/crates/ely_servo_host/tests/sidecar/support.rs new file mode 100644 index 0000000..4b656ab --- /dev/null +++ b/crates/ely_servo_host/tests/sidecar/support.rs @@ -0,0 +1,346 @@ +use std::{ + error::Error, + io, + process::{Child, Command, Output, Stdio}, + thread, + time::{Duration, Instant}, +}; + +pub(super) const MINIMUM_CONTENT_PIXELS: u64 = 1_000; +const SIDECAR_TIMEOUT: Duration = Duration::from_secs(25); +const SIDECAR_POLL_INTERVAL: Duration = Duration::from_millis(20); +pub(super) const PRD_SITE_COMPATIBILITY_CASES: &[PrdSiteCompatibilityCase] = &[ + PrdSiteCompatibilityCase { url: "https://example.com", title_fragment: "Example Domain" }, + PrdSiteCompatibilityCase { url: "https://servo.org", title_fragment: "Servo" }, +]; +pub(super) const PRD_SITE_COMPATIBILITY_SIZES: &[FrameSize] = &[ + FrameSize { width: 640, height: 480 }, + FrameSize { width: 934, height: 657 }, + FrameSize { width: 1614, height: 980 }, +]; +pub(super) const SERVO_SCROLL_SITE: PrdSiteCompatibilityCase = + 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_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_SIZE: FrameSize = FrameSize { width: 640, height: 480 }; +pub(super) const SERVO_CLICK_POINT: ClickPoint = ClickPoint { x: 160, y: 120 }; +const SERVO_DRAG_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 SERVO_DRAG_SIZE: FrameSize = FrameSize { width: 640, height: 480 }; +pub(super) const SERVO_DRAG_FROM: ClickPoint = ClickPoint { x: 160, y: 120 }; +pub(super) const SERVO_DRAG_TO: ClickPoint = ClickPoint { x: 320, y: 120 }; +const SERVO_TOUCH_URL: &str = "data:text/html,%3C%21doctype%20html%3E%3Ctitle%3ETouch%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%3Btouch-action%3Amanipulation%3B%7D%3C%2Fstyle%3E%3Cbutton%20ontouchstart%3D%22document.body.dataset.touch%3D%27start%27%3B%22%20onclick%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Touched%27%3Bthis.textContent%3D%27Touched%27%3B%22%3ETap%3C%2Fbutton%3E"; +const SERVO_TOUCH_SIZE: FrameSize = FrameSize { width: 640, height: 480 }; +pub(super) const SERVO_TOUCH_POINT: ClickPoint = ClickPoint { x: 160, y: 120 }; +const SERVO_TEXT_URL: &str = "data:text/html,%3C!doctype%20html%3E%3Ctitle%3EText%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3Bfont%3A28px%20sans-serif%3B%7Dinput%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A260px%3Bheight%3A70px%3Bfont%3A28px%20sans-serif%3B%7Doutput%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A180px%3Bfont%3A32px%20sans-serif%3B%7D%3C%2Fstyle%3E%3Cinput%20id%3Dq%20autofocus%20oninput%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.getElementById%28%27out%27%29.textContent%3Dthis.value%3B%22%3E%3Coutput%20id%3Dout%3Eempty%3C%2Foutput%3E"; +const SERVO_TEXT_SIZE: FrameSize = FrameSize { width: 640, height: 480 }; +const SERVO_TEXT_POINT: ClickPoint = ClickPoint { x: 160, y: 120 }; +pub(super) const SERVO_TEXT_VALUE: &str = "ely42"; + +pub(super) struct PrdSiteCompatibilityCase { + pub(super) url: &'static str, + title_fragment: &'static str, +} + +#[derive(Clone, Copy)] +pub(super) struct FrameSize { + pub(super) width: u64, + height: u64, +} + +#[derive(Clone, Copy)] +pub(super) struct ScrollOffset { + pub(super) x: i64, + pub(super) y: i64, +} + +impl ScrollOffset { + pub(super) const ZERO: Self = Self { x: 0, y: 0 }; +} + +#[derive(Clone, Copy)] +pub(super) struct ClickPoint { + pub(super) x: u64, + pub(super) y: u64, +} + +#[derive(Clone, Copy)] +pub(super) struct DragPoints { + pub(super) from: ClickPoint, + pub(super) to: ClickPoint, +} + +#[derive(Clone, Copy, Default)] +struct SnapshotInput<'a> { + click_point: Option, + drag_points: Option, + touch_point: Option, + typed_text: Option<&'a str>, +} + +pub(super) fn snapshot_prd_site( + case: &PrdSiteCompatibilityCase, + size: FrameSize, + scroll_offset: ScrollOffset, +) -> Result> { + let site_name = case + .url + .chars() + .map(|character| if character.is_ascii_alphanumeric() { character } else { '-' }) + .collect::(); + let output_path = std::env::temp_dir().join(format!( + "ely-servo-sidecar-{}-{site_name}-{}x{}-{}-{}.rgba", + std::process::id(), + size.width, + size.height, + scroll_offset.x, + scroll_offset.y + )); + + if output_path.exists() { + std::fs::remove_file(&output_path)?; + } + + let output = run_sidecar_snapshot( + case.url, + &output_path, + size, + scroll_offset, + SnapshotInput::default(), + )?; + + assert!( + output.status.success(), + "{} {}x{}\nstatus: {:?}\nstdout: {}\nstderr: {}", + case.url, + size.width, + size.height, + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let report: serde_json::Value = serde_json::from_slice(&output.stdout)?; + assert_eq!(report_field_as_u64(&report, "width")?, size.width, "{}", case.url); + assert_eq!(report_field_as_u64(&report, "height")?, size.height, "{}", case.url); + assert_eq!( + report_field_as_u64(&report, "rgba_byte_count")?, + size.width * size.height * 4, + "{}", + case.url + ); + assert_report_text_contains(&report, "loaded_url", case.url)?; + assert_report_text_contains(&report, "title", case.title_fragment)?; + assert!(report_field_as_u64(&report, "non_white_pixel_count")? > 0, "{}", case.url); + assert!( + report_field_as_u64(&report, "content_pixel_count")? >= MINIMUM_CONTENT_PIXELS, + "{}", + case.url + ); + assert!(report_field_as_u64(&report, "sample_hash")? > 0, "{}", case.url); + assert_eq!(std::fs::metadata(&output_path)?.len(), size.width * size.height * 4); + + std::fs::remove_file(&output_path)?; + Ok(report) +} + +pub(super) fn snapshot_click_probe( + click_point: Option, +) -> Result> { + snapshot_probe( + SERVO_CLICK_URL, + SERVO_CLICK_SIZE, + "click", + SnapshotInput { click_point, ..SnapshotInput::default() }, + ) +} + +pub(super) fn snapshot_drag_probe( + drag_points: Option, +) -> Result> { + snapshot_probe( + SERVO_DRAG_URL, + SERVO_DRAG_SIZE, + "drag", + SnapshotInput { drag_points, ..SnapshotInput::default() }, + ) +} + +pub(super) fn snapshot_touch_probe( + touch_point: Option, +) -> Result> { + snapshot_probe( + SERVO_TOUCH_URL, + SERVO_TOUCH_SIZE, + "touch", + SnapshotInput { touch_point, ..SnapshotInput::default() }, + ) +} + +pub(super) fn snapshot_text_probe( + typed_text: Option<&str>, +) -> Result> { + snapshot_probe( + SERVO_TEXT_URL, + SERVO_TEXT_SIZE, + "text", + SnapshotInput { + click_point: typed_text.map(|_| SERVO_TEXT_POINT), + typed_text, + ..SnapshotInput::default() + }, + ) +} + +fn snapshot_probe( + url: &str, + size: FrameSize, + label: &'static str, + input: SnapshotInput<'_>, +) -> Result> { + let output_path = std::env::temp_dir().join(format!( + "ely-servo-sidecar-{}-{label}-{}x{}.rgba", + std::process::id(), + size.width, + size.height + )); + + if output_path.exists() { + std::fs::remove_file(&output_path)?; + } + + let output = run_sidecar_snapshot(url, &output_path, size, ScrollOffset::ZERO, input)?; + + assert!( + output.status.success(), + "{label} probe\nstatus: {:?}\nstdout: {}\nstderr: {}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let report: serde_json::Value = serde_json::from_slice(&output.stdout)?; + assert_eq!(report_field_as_u64(&report, "width")?, size.width); + assert_eq!(report_field_as_u64(&report, "height")?, size.height); + assert!(report_field_as_u64(&report, "content_pixel_count")? > 0); + assert_eq!(std::fs::metadata(&output_path)?.len(), size.width * size.height * 4); + + std::fs::remove_file(&output_path)?; + Ok(report) +} + +fn run_sidecar_snapshot( + site_url: &str, + output_path: &std::path::Path, + size: FrameSize, + scroll_offset: ScrollOffset, + input: SnapshotInput<'_>, +) -> Result> { + let mut command = Command::new(env!("CARGO_BIN_EXE_ely_servo_sidecar")); + command + .arg("snapshot") + .arg("--url") + .arg(site_url) + .arg("--rgba-out") + .arg(output_path) + .arg("--width") + .arg(size.width.to_string()) + .arg("--height") + .arg(size.height.to_string()); + if scroll_offset.x != 0 { + command.arg("--scroll-x").arg(scroll_offset.x.to_string()); + } + if scroll_offset.y != 0 { + command.arg("--scroll-y").arg(scroll_offset.y.to_string()); + } + if let Some(click_point) = input.click_point { + command.arg("--click-x").arg(click_point.x.to_string()); + command.arg("--click-y").arg(click_point.y.to_string()); + } + if let Some(drag_points) = input.drag_points { + command.arg("--drag-from-x").arg(drag_points.from.x.to_string()); + command.arg("--drag-from-y").arg(drag_points.from.y.to_string()); + command.arg("--drag-to-x").arg(drag_points.to.x.to_string()); + command.arg("--drag-to-y").arg(drag_points.to.y.to_string()); + } + if let Some(touch_point) = input.touch_point { + command.arg("--touch-x").arg(touch_point.x.to_string()); + command.arg("--touch-y").arg(touch_point.y.to_string()); + } + if let Some(typed_text) = input.typed_text { + command.arg("--type-text").arg(typed_text); + } + + let mut child = command.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?; + let started_at = Instant::now(); + loop { + if child.try_wait()?.is_some() { + return child.wait_with_output().map_err(Into::into); + } + + if started_at.elapsed() >= SIDECAR_TIMEOUT { + terminate_child(child)?; + return Err(format!( + "timed out rendering {site_url} at {}x{}", + size.width, size.height + ) + .into()); + } + + thread::sleep(SIDECAR_POLL_INTERVAL); + } +} + +fn terminate_child(mut child: Child) -> Result<(), Box> { + match child.kill() { + Ok(()) => { + let _output = child.wait_with_output()?; + Ok(()) + } + Err(error) if error.kind() == io::ErrorKind::InvalidInput => Ok(()), + Err(error) => Err(error.into()), + } +} + +fn assert_report_text_contains( + report: &serde_json::Value, + field: &'static str, + fragment: &str, +) -> Result<(), Box> { + let value = report + .get(field) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| format!("missing text report field: {field}"))?; + assert!(value.contains(fragment), "{field}: {value}"); + Ok(()) +} + +pub(super) fn report_field_as_bool( + report: &serde_json::Value, + field: &'static str, +) -> Result> { + report + .get(field) + .and_then(serde_json::Value::as_bool) + .ok_or_else(|| format!("missing boolean report field: {field}").into()) +} + +pub(super) fn report_field_as_i64( + report: &serde_json::Value, + field: &'static str, +) -> Result> { + report + .get(field) + .and_then(serde_json::Value::as_i64) + .ok_or_else(|| format!("missing signed report field: {field}").into()) +} + +pub(super) fn report_field_as_u64( + report: &serde_json::Value, + field: &'static str, +) -> Result> { + report + .get(field) + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| format!("missing numeric report field: {field}").into()) +} diff --git a/crates/ely_servo_host/tests/software_host.rs b/crates/ely_servo_host/tests/software_host.rs index 2c812d5..48164e2 100644 --- a/crates/ely_servo_host/tests/software_host.rs +++ b/crates/ely_servo_host/tests/software_host.rs @@ -4,8 +4,8 @@ use std::{error::Error, thread, time::Duration}; use ely_domain::{ProfileId, TabId, UrlText}; use ely_servo_host::{ - KeyboardTextRequest, MouseClickRequest, NavigationRequest, ScrollRequest, ServoHost, - ServoHostError, ServoSurfaceSize, SoftwareServoHost, TouchTapRequest, WebViewState, + KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest, ScrollRequest, + ServoHost, ServoHostError, ServoSurfaceSize, SoftwareServoHost, TouchTapRequest, WebViewState, }; const MINIMUM_CONTENT_PIXELS: u64 = 1_000; @@ -14,6 +14,7 @@ const PRD_SITE_COMPATIBILITY_CASES: &[PrdSiteCompatibilityCase] = &[ 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 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 TOUCH_PROBE_URL: &str = "data:text/html,%3C%21doctype%20html%3E%3Ctitle%3ETouch%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%3Btouch-action%3Amanipulation%3B%7D%3C%2Fstyle%3E%3Cbutton%20ontouchstart%3D%22document.body.dataset.touch%3D%27start%27%3B%22%20onclick%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Touched%27%3Bthis.textContent%3D%27Touched%27%3B%22%3ETap%3C%2Fbutton%3E"; const TEXT_PROBE_URL: &str = "data:text/html,%3C!doctype%20html%3E%3Ctitle%3EText%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3Bfont%3A28px%20sans-serif%3B%7Dinput%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A260px%3Bheight%3A70px%3Bfont%3A28px%20sans-serif%3B%7Doutput%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A180px%3Bfont%3A32px%20sans-serif%3B%7D%3C%2Fstyle%3E%3Cinput%20id%3Dq%20autofocus%20oninput%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.getElementById%28%27out%27%29.textContent%3Dthis.value%3B%22%3E%3Coutput%20id%3Dout%3Eempty%3C%2Foutput%3E"; const TEXT_PROBE_VALUE: &str = "ely42"; @@ -56,6 +57,26 @@ fn manages_real_servo_webview_lifecycle() -> Result<(), Box> { assert_rendered_frame_has_content(&host, "data:text/html clicked", 1)?; assert_ne!(host.last_rendered_frame()?.sample_hash(), previous_frame_hash); + let tab_id = TabId::new(); + let url = UrlText::parse(DRAG_PROBE_URL)?; + host.navigate(NavigationRequest { webview_id: webview_id.clone(), tab_id, url })?; + let snapshot = wait_for_rendered_webview(&mut host, &webview_id, None)?; + assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}"); + assert_rendered_frame_has_content(&host, "data:text/html drag", 1)?; + + let previous_frame_hash = host.last_rendered_frame()?.sample_hash(); + host.drag(MouseDragRequest { + webview_id: webview_id.clone(), + from_x: 160, + from_y: 120, + to_x: 320, + to_y: 120, + })?; + let snapshot = wait_for_rendered_webview(&mut host, &webview_id, Some(previous_frame_hash))?; + assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}"); + assert_rendered_frame_has_content(&host, "data:text/html dragged", 1)?; + assert_ne!(host.last_rendered_frame()?.sample_hash(), previous_frame_hash); + let tab_id = TabId::new(); let url = UrlText::parse(TOUCH_PROBE_URL)?; host.navigate(NavigationRequest { webview_id: webview_id.clone(), tab_id, url })?;