ferridriver 0.3.0

Browser automation in Rust with a Playwright-compatible API. Four pluggable backends: CDP pipe, CDP WebSocket, Playwright WebKit, Firefox BiDi.
Documentation
//! `BiDi` element -- implements element interactions via `SharedReferences`.
//!
//! Uses `script.callFunction` with element as argument for JS operations,
//! and `input.performActions` for user input simulation.

use std::sync::Arc;

use base64::Engine;
use serde_json::json;

use super::input;
use super::session::BidiSession;
use super::types::EvaluateResult;
use crate::backend::ImageFormat;
use crate::error::{FerriError, Result};

/// Element handle for the `BiDi` backend.
pub struct BidiElement {
  pub(crate) session: Arc<BidiSession>,
  pub(crate) context_id: Arc<str>,
  pub(crate) shared_id: String,
}

impl BidiElement {
  pub(crate) fn new(session: Arc<BidiSession>, context_id: Arc<str>, shared_id: String) -> Self {
    Self {
      session,
      context_id,
      shared_id,
    }
  }

  /// Call a JS function with this element.
  /// The element is passed both as `this` (for `function() { this.value }` style)
  /// and as the first argument (for `(el) => el.value` style).
  /// This matches CDP's `callFunctionOn` which binds `this` to the target object.
  async fn call_fn(&self, func: &str) -> Result<serde_json::Value> {
    self
      .session
      .transport
      .send_command(
        "script.callFunction",
        json!({
          "functionDeclaration": func,
          "target": {"context": &*self.context_id},
          "this": {"type": "sharedReference", "sharedId": self.shared_id},
          "arguments": [{"type": "sharedReference", "sharedId": self.shared_id}],
          "awaitPromise": true,
          "resultOwnership": "none"
        }),
      )
      .await
  }

  /// Call a JS function and parse the evaluate result to a JSON value.
  async fn call_fn_value(&self, func: &str) -> Result<Option<serde_json::Value>> {
    let result = self.call_fn(func).await?;
    let eval_result: EvaluateResult = serde_json::from_value(result)
      .map_err(|e| FerriError::protocol("script.callFunction", format!("BiDi element call_fn parse: {e}")))?;

    match eval_result {
      EvaluateResult::Success { result } => Ok(result.to_json()),
      EvaluateResult::Exception { exception_details } => Err(FerriError::evaluation(format!(
        "JS error on element: {}",
        exception_details.text
      ))),
    }
  }

  /// Deterministic content-frame resolution for an `<iframe>` /
  /// `<frame>`: its `contentWindow` serialises as a `window`
  /// `RemoteValue` whose `context` is the child browsing-context id.
  /// Robust for `srcdoc` / `data:` / nested / re-attached frames
  /// (the name/url heuristic is not). `None` if the element hosts no
  /// frame.
  pub(crate) async fn content_frame_context(&self) -> Result<Option<String>> {
    let v = self
      .call_fn_value("(el) => (el && (el.tagName === 'IFRAME' || el.tagName === 'FRAME')) ? el.contentWindow : null")
      .await?;
    Ok(v.and_then(|j| j.get("context").and_then(|c| c.as_str()).map(String::from)))
  }

  /// Get the element's bounding box.
  async fn bounding_box(&self) -> Result<(f64, f64, f64, f64)> {
    let result = self
      .call_fn_value(
        "(el) => { const r = el.getBoundingClientRect(); return {x: r.x, y: r.y, w: r.width, h: r.height}; }",
      )
      .await?
      .ok_or_else(|| FerriError::protocol("script.callFunction", "Element bounding box returned null"))?;

    tracing::debug!(target: "ferridriver::bidi", bbox_json = %result, "BiDi bounding box result");

    let x = result.get("x").and_then(serde_json::Value::as_f64).unwrap_or(0.0);
    let y = result.get("y").and_then(serde_json::Value::as_f64).unwrap_or(0.0);
    let w = result.get("w").and_then(serde_json::Value::as_f64).unwrap_or(0.0);
    let h = result.get("h").and_then(serde_json::Value::as_f64).unwrap_or(0.0);

    Ok((x, y, w, h))
  }

  pub async fn click(&self) -> Result<()> {
    self.scroll_into_view().await?;
    let (x, y, w, h) = self.bounding_box().await?;
    let cx = x + w / 2.0;
    let cy = y + h / 2.0;
    tracing::debug!(target: "ferridriver::bidi", x, y, w, h, cx, cy, shared_id = %self.shared_id, "BiDi element click");
    self
      .session
      .transport
      .send_command("input.performActions", input::click(&self.context_id, cx, cy))
      .await?;
    Ok(())
  }

  pub async fn dblclick(&self) -> Result<()> {
    self.scroll_into_view().await?;
    let (x, y, w, h) = self.bounding_box().await?;
    let cx = x + w / 2.0;
    let cy = y + h / 2.0;
    self
      .session
      .transport
      .send_command(
        "input.performActions",
        input::click_button(&self.context_id, cx, cy, 0, 2),
      )
      .await?;
    Ok(())
  }

  pub async fn hover(&self) -> Result<()> {
    self.scroll_into_view().await?;
    let (x, y, w, h) = self.bounding_box().await?;
    let cx = x + w / 2.0;
    let cy = y + h / 2.0;
    self
      .session
      .transport
      .send_command("input.performActions", input::pointer_move(&self.context_id, cx, cy))
      .await?;
    Ok(())
  }

  pub async fn type_str(&self, text: &str) -> Result<()> {
    // Click to focus first
    self.click().await?;
    self
      .session
      .transport
      .send_command("input.performActions", input::type_text(&self.context_id, text))
      .await?;
    Ok(())
  }

  pub async fn call_js_fn(&self, function: &str) -> Result<()> {
    let result = self.call_fn(function).await?;
    let eval_result: EvaluateResult = serde_json::from_value(result)
      .map_err(|e| FerriError::protocol("script.callFunction", format!("BiDi element call_js_fn parse: {e}")))?;

    match eval_result {
      EvaluateResult::Success { .. } => Ok(()),
      EvaluateResult::Exception { exception_details } => Err(FerriError::evaluation(format!(
        "JS error on element: {}",
        exception_details.text
      ))),
    }
  }

  pub async fn call_js_fn_value(&self, function: &str) -> Result<Option<serde_json::Value>> {
    self.call_fn_value(function).await
  }

  pub async fn scroll_into_view(&self) -> Result<()> {
    let _ = self
      .call_fn("(el) => el.scrollIntoView({block: 'center', inline: 'center'})")
      .await;
    Ok(())
  }

  pub async fn screenshot(&self, format: ImageFormat) -> Result<Vec<u8>> {
    let format_type = match format {
      ImageFormat::Png => "image/png",
      ImageFormat::Jpeg => "image/jpeg",
      ImageFormat::Webp => "image/webp",
    };

    let result = self
      .session
      .transport
      .send_command(
        "browsingContext.captureScreenshot",
        json!({
          "context": &*self.context_id,
          "format": {"type": format_type},
          "clip": {"type": "element", "element": {"sharedId": self.shared_id}}
        }),
      )
      .await?;

    let data_str = result
      .get("data")
      .and_then(|v| v.as_str())
      .ok_or_else(|| FerriError::backend("Element screenshot: missing data"))?;
    base64::engine::general_purpose::STANDARD
      .decode(data_str)
      .map_err(|e| FerriError::Backend(format!("Element screenshot base64 decode: {e}")))
  }
}