use std::path::Path;
use serde::Deserialize;
use tracing::{info, instrument};
use viewpoint_cdp::protocol::dom::{BackendNodeId, ResolveNodeParams, ResolveNodeResult};
use viewpoint_js::js;
use super::locator::Selector;
use super::screenshot::{Animations, ScreenshotBuilder, ScreenshotFormat};
use crate::error::LocatorError;
use crate::page::Locator;
#[derive(Debug)]
pub struct ElementScreenshotBuilder<'a, 'b> {
locator: &'a Locator<'b>,
format: ScreenshotFormat,
quality: Option<u8>,
path: Option<String>,
omit_background: bool,
animations: Animations,
}
impl<'a, 'b> ElementScreenshotBuilder<'a, 'b> {
pub(crate) fn new(locator: &'a Locator<'b>) -> Self {
Self {
locator,
format: ScreenshotFormat::Png,
quality: None,
path: None,
omit_background: false,
animations: Animations::default(),
}
}
#[must_use]
pub fn format(mut self, format: ScreenshotFormat) -> Self {
self.format = format;
self
}
#[must_use]
pub fn quality(mut self, quality: u8) -> Self {
self.quality = Some(quality.min(100));
self
}
#[must_use]
pub fn path(mut self, path: impl AsRef<Path>) -> Self {
self.path = Some(path.as_ref().to_string_lossy().to_string());
self
}
#[must_use]
pub fn omit_background(mut self, omit: bool) -> Self {
self.omit_background = omit;
self
}
#[must_use]
pub fn animations(mut self, animations: Animations) -> Self {
self.animations = animations;
self
}
#[instrument(level = "info", skip(self), fields(format = ?self.format))]
pub async fn capture(self) -> Result<Vec<u8>, LocatorError> {
let page = self.locator.page();
if page.is_closed() {
return Err(LocatorError::PageClosed);
}
let bbox = self.get_element_bounding_box().await?;
info!(
x = bbox.x,
y = bbox.y,
width = bbox.width,
height = bbox.height,
"Capturing element screenshot"
);
let mut builder = ScreenshotBuilder::new(page)
.format(self.format)
.clip(bbox.x, bbox.y, bbox.width, bbox.height)
.omit_background(self.omit_background)
.animations(self.animations);
if let Some(quality) = self.quality {
builder = builder.quality(quality);
}
if let Some(ref path) = self.path {
builder = builder.path(path);
}
builder
.capture()
.await
.map_err(|e| LocatorError::EvaluationError(e.to_string()))
}
async fn get_element_bounding_box(&self) -> Result<BoundingBox, LocatorError> {
let page = self.locator.page();
let selector = self.locator.selector();
if let Selector::Ref(ref_str) = selector {
let backend_node_id = page.get_backend_node_id_for_ref(ref_str)?;
return self
.get_element_bounding_box_by_backend_id(backend_node_id)
.await;
}
if let Selector::BackendNodeId(backend_node_id) = selector {
return self
.get_element_bounding_box_by_backend_id(*backend_node_id)
.await;
}
let js_selector = selector.to_js_expression();
let script = format!(
r"
(function() {{
const element = {js_selector};
if (!element) return null;
const rect = element.getBoundingClientRect();
return JSON.stringify({{
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height
}});
}})()
"
);
let result: viewpoint_cdp::protocol::runtime::EvaluateResult = page
.connection()
.send_command(
"Runtime.evaluate",
Some(viewpoint_cdp::protocol::runtime::EvaluateParams {
expression: script,
object_group: None,
include_command_line_api: None,
silent: Some(false),
context_id: None,
return_by_value: Some(true),
await_promise: Some(false),
}),
Some(page.session_id()),
)
.await?;
let json_str = result
.result
.value
.and_then(|v| {
if v.is_null() {
None
} else {
v.as_str().map(String::from)
}
})
.ok_or_else(|| LocatorError::NotFound(selector.to_string()))?;
let bbox: serde_json::Value = serde_json::from_str(&json_str).map_err(|e| {
LocatorError::EvaluationError(format!("Failed to parse bounding box: {e}"))
})?;
Ok(BoundingBox {
x: bbox["x"].as_f64().unwrap_or(0.0),
y: bbox["y"].as_f64().unwrap_or(0.0),
width: bbox["width"].as_f64().unwrap_or(0.0),
height: bbox["height"].as_f64().unwrap_or(0.0),
})
}
async fn get_element_bounding_box_by_backend_id(
&self,
backend_node_id: BackendNodeId,
) -> Result<BoundingBox, LocatorError> {
let page = self.locator.page();
let result: ResolveNodeResult = page
.connection()
.send_command(
"DOM.resolveNode",
Some(ResolveNodeParams {
node_id: None,
backend_node_id: Some(backend_node_id),
object_group: Some("viewpoint-screenshot".to_string()),
execution_context_id: None,
}),
Some(page.session_id()),
)
.await
.map_err(|_| {
LocatorError::NotFound(format!(
"Could not resolve backend node ID {backend_node_id}: element may no longer exist"
))
})?;
let object_id = result.object.object_id.ok_or_else(|| {
LocatorError::NotFound(format!(
"No object ID for backend node ID {backend_node_id}"
))
})?;
#[derive(Debug, Deserialize)]
struct CallResult {
result: viewpoint_cdp::protocol::runtime::RemoteObject,
#[serde(rename = "exceptionDetails")]
exception_details: Option<viewpoint_cdp::protocol::runtime::ExceptionDetails>,
}
let js_fn = js! {
(function() {
const rect = this.getBoundingClientRect();
return {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height
};
})
};
let js_fn = js_fn.trim_start_matches('(').trim_end_matches(')');
let call_result: CallResult = page
.connection()
.send_command(
"Runtime.callFunctionOn",
Some(serde_json::json!({
"objectId": object_id,
"functionDeclaration": js_fn,
"returnByValue": true
})),
Some(page.session_id()),
)
.await?;
let _ = page
.connection()
.send_command::<_, serde_json::Value>(
"Runtime.releaseObject",
Some(serde_json::json!({ "objectId": object_id })),
Some(page.session_id()),
)
.await;
if let Some(exception) = call_result.exception_details {
return Err(LocatorError::EvaluationError(exception.text));
}
let bbox = call_result.result.value.ok_or_else(|| {
LocatorError::EvaluationError("No result from bounding box query".to_string())
})?;
Ok(BoundingBox {
x: bbox["x"].as_f64().unwrap_or(0.0),
y: bbox["y"].as_f64().unwrap_or(0.0),
width: bbox["width"].as_f64().unwrap_or(0.0),
height: bbox["height"].as_f64().unwrap_or(0.0),
})
}
}
#[derive(Debug, Clone)]
struct BoundingBox {
x: f64,
y: f64,
width: f64,
height: f64,
}