494 lines
15 KiB
Rust
494 lines
15 KiB
Rust
// Browser client using Fantoccini WebDriver
|
|
|
|
use crate::browser::error::{BrowserError, Result};
|
|
use crate::browser::session::{BrowserSession, BrowserType, SessionConfig, SessionManager};
|
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
|
use fantoccini::elements::Element;
|
|
use fantoccini::{Client, ClientBuilder, Locator};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use tokio::sync::RwLock;
|
|
use uuid::Uuid;
|
|
|
|
/// Main browser automation client
|
|
pub struct BrowserClient {
|
|
/// Active WebDriver connections
|
|
connections: Arc<RwLock<HashMap<String, Client>>>,
|
|
/// Session manager
|
|
session_manager: SessionManager,
|
|
}
|
|
|
|
impl BrowserClient {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
connections: Arc::new(RwLock::new(HashMap::new())),
|
|
session_manager: SessionManager::new(),
|
|
}
|
|
}
|
|
|
|
/// Create a new browser session
|
|
pub async fn create_session(&self, config: SessionConfig) -> Result<String> {
|
|
let session_id = Uuid::new_v4().to_string();
|
|
|
|
// Build WebDriver capabilities as Map
|
|
let capabilities = self.build_capabilities(&config)?;
|
|
|
|
// Connect to WebDriver
|
|
let client = ClientBuilder::native()
|
|
.capabilities(capabilities)
|
|
.connect(&config.webdriver_url)
|
|
.await
|
|
.map_err(|e| BrowserError::ConnectionFailed(e.to_string()))?;
|
|
|
|
// Store connection
|
|
{
|
|
let mut connections = self.connections.write().await;
|
|
connections.insert(session_id.clone(), client);
|
|
}
|
|
|
|
// Create session record
|
|
let session = BrowserSession::new(session_id.clone(), config);
|
|
self.session_manager.add_session(session).await;
|
|
|
|
Ok(session_id)
|
|
}
|
|
|
|
/// Close a browser session
|
|
pub async fn close_session(&self, session_id: &str) -> Result<()> {
|
|
let client = self.get_client(session_id).await?;
|
|
|
|
// Close the browser
|
|
client
|
|
.close()
|
|
.await
|
|
.map_err(|e| BrowserError::ConnectionFailed(e.to_string()))?;
|
|
|
|
// Remove from connections
|
|
{
|
|
let mut connections = self.connections.write().await;
|
|
connections.remove(session_id);
|
|
}
|
|
|
|
// Remove session record
|
|
self.session_manager.remove_session(session_id).await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get session information
|
|
pub async fn get_session(&self, session_id: &str) -> Result<BrowserSession> {
|
|
self.session_manager
|
|
.get_session(session_id)
|
|
.await
|
|
.ok_or_else(|| BrowserError::SessionNotFound(session_id.to_string()))
|
|
}
|
|
|
|
/// List all sessions
|
|
pub async fn list_sessions(&self) -> Vec<BrowserSession> {
|
|
self.session_manager.list_sessions().await
|
|
}
|
|
|
|
/// Navigate to URL
|
|
pub async fn navigate(&self, session_id: &str, url: &str) -> Result<NavigationResult> {
|
|
let client = self.get_client(session_id).await?;
|
|
|
|
client
|
|
.goto(url)
|
|
.await
|
|
.map_err(|_| BrowserError::NavigationFailed {
|
|
url: url.to_string(),
|
|
})?;
|
|
|
|
// Get current URL and title
|
|
let current_url = client.current_url().await.ok().map(|u| u.to_string());
|
|
let title = client.title().await.ok();
|
|
|
|
// Update session
|
|
self.session_manager
|
|
.update_session(session_id, |s| {
|
|
s.update_location(current_url.clone(), title.clone());
|
|
})
|
|
.await;
|
|
|
|
Ok(NavigationResult {
|
|
url: current_url,
|
|
title,
|
|
})
|
|
}
|
|
|
|
/// Go back
|
|
pub async fn back(&self, session_id: &str) -> Result<()> {
|
|
let client = self.get_client(session_id).await?;
|
|
client.back().await.map_err(|e| BrowserError::CommandFailed(e.to_string()))?;
|
|
self.update_session_location(session_id).await;
|
|
Ok(())
|
|
}
|
|
|
|
/// Go forward
|
|
pub async fn forward(&self, session_id: &str) -> Result<()> {
|
|
let client = self.get_client(session_id).await?;
|
|
client.forward().await.map_err(|e| BrowserError::CommandFailed(e.to_string()))?;
|
|
self.update_session_location(session_id).await;
|
|
Ok(())
|
|
}
|
|
|
|
/// Refresh page
|
|
pub async fn refresh(&self, session_id: &str) -> Result<()> {
|
|
let client = self.get_client(session_id).await?;
|
|
client.refresh().await.map_err(|e| BrowserError::CommandFailed(e.to_string()))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Find element by CSS selector
|
|
pub async fn find_element(&self, session_id: &str, selector: &str) -> Result<ElementInfo> {
|
|
let client = self.get_client(session_id).await?;
|
|
|
|
let element = client
|
|
.find(Locator::Css(selector))
|
|
.await
|
|
.map_err(|_| BrowserError::ElementNotFound {
|
|
selector: selector.to_string(),
|
|
})?;
|
|
|
|
self.element_to_info(&element, selector).await
|
|
}
|
|
|
|
/// Find multiple elements
|
|
pub async fn find_elements(&self, session_id: &str, selector: &str) -> Result<Vec<ElementInfo>> {
|
|
let client = self.get_client(session_id).await?;
|
|
|
|
let elements = client
|
|
.find_all(Locator::Css(selector))
|
|
.await
|
|
.map_err(|_| BrowserError::ElementNotFound {
|
|
selector: selector.to_string(),
|
|
})?;
|
|
|
|
let mut infos = Vec::new();
|
|
for element in elements {
|
|
if let Ok(info) = self.element_to_info(&element, selector).await {
|
|
infos.push(info);
|
|
}
|
|
}
|
|
|
|
Ok(infos)
|
|
}
|
|
|
|
/// Click element
|
|
pub async fn click(&self, session_id: &str, selector: &str) -> Result<()> {
|
|
let client = self.get_client(session_id).await?;
|
|
|
|
let element = client
|
|
.find(Locator::Css(selector))
|
|
.await
|
|
.map_err(|_| BrowserError::ElementNotFound {
|
|
selector: selector.to_string(),
|
|
})?;
|
|
|
|
element.click().await.map_err(|e| BrowserError::CommandFailed(e.to_string()))?;
|
|
self.update_session_location(session_id).await;
|
|
Ok(())
|
|
}
|
|
|
|
/// Type text into element
|
|
pub async fn type_text(&self, session_id: &str, selector: &str, text: &str) -> Result<()> {
|
|
let client = self.get_client(session_id).await?;
|
|
|
|
let element = client
|
|
.find(Locator::Css(selector))
|
|
.await
|
|
.map_err(|_| BrowserError::ElementNotFound {
|
|
selector: selector.to_string(),
|
|
})?;
|
|
|
|
element.send_keys(text).await.map_err(|e| BrowserError::CommandFailed(e.to_string()))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Clear and type text
|
|
pub async fn clear_and_type(&self, session_id: &str, selector: &str, text: &str) -> Result<()> {
|
|
let client = self.get_client(session_id).await?;
|
|
|
|
let element = client
|
|
.find(Locator::Css(selector))
|
|
.await
|
|
.map_err(|_| BrowserError::ElementNotFound {
|
|
selector: selector.to_string(),
|
|
})?;
|
|
|
|
element.clear().await.map_err(|e| BrowserError::CommandFailed(e.to_string()))?;
|
|
element.send_keys(text).await.map_err(|e| BrowserError::CommandFailed(e.to_string()))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Get element text
|
|
pub async fn get_text(&self, session_id: &str, selector: &str) -> Result<String> {
|
|
let client = self.get_client(session_id).await?;
|
|
|
|
let element = client
|
|
.find(Locator::Css(selector))
|
|
.await
|
|
.map_err(|_| BrowserError::ElementNotFound {
|
|
selector: selector.to_string(),
|
|
})?;
|
|
|
|
element.text().await.map_err(|e| BrowserError::CommandFailed(e.to_string()))
|
|
}
|
|
|
|
/// Get element attribute
|
|
pub async fn get_attribute(
|
|
&self,
|
|
session_id: &str,
|
|
selector: &str,
|
|
attribute: &str,
|
|
) -> Result<Option<String>> {
|
|
let client = self.get_client(session_id).await?;
|
|
|
|
let element = client
|
|
.find(Locator::Css(selector))
|
|
.await
|
|
.map_err(|_| BrowserError::ElementNotFound {
|
|
selector: selector.to_string(),
|
|
})?;
|
|
|
|
element.attr(attribute).await.map_err(|e| BrowserError::CommandFailed(e.to_string()))
|
|
}
|
|
|
|
/// Execute JavaScript
|
|
pub async fn execute_script(
|
|
&self,
|
|
session_id: &str,
|
|
script: &str,
|
|
args: Vec<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
let client = self.get_client(session_id).await?;
|
|
client.execute(script, args).await.map_err(|e| BrowserError::ScriptError {
|
|
message: e.to_string(),
|
|
})
|
|
}
|
|
|
|
/// Take screenshot
|
|
pub async fn screenshot(&self, session_id: &str) -> Result<ScreenshotResult> {
|
|
let client = self.get_client(session_id).await?;
|
|
|
|
let screenshot = client.screenshot().await.map_err(|e| BrowserError::ScreenshotFailed {
|
|
reason: e.to_string(),
|
|
})?;
|
|
|
|
let base64_data = STANDARD.encode(&screenshot);
|
|
|
|
Ok(ScreenshotResult {
|
|
data: screenshot,
|
|
base64: base64_data,
|
|
format: "png".to_string(),
|
|
})
|
|
}
|
|
|
|
/// Take element screenshot
|
|
pub async fn element_screenshot(
|
|
&self,
|
|
session_id: &str,
|
|
selector: &str,
|
|
) -> Result<ScreenshotResult> {
|
|
let client = self.get_client(session_id).await?;
|
|
|
|
let element = client
|
|
.find(Locator::Css(selector))
|
|
.await
|
|
.map_err(|_| BrowserError::ElementNotFound {
|
|
selector: selector.to_string(),
|
|
})?;
|
|
|
|
let screenshot = element.screenshot().await.map_err(|e| BrowserError::ScreenshotFailed {
|
|
reason: e.to_string(),
|
|
})?;
|
|
|
|
let base64_data = STANDARD.encode(&screenshot);
|
|
|
|
Ok(ScreenshotResult {
|
|
data: screenshot,
|
|
base64: base64_data,
|
|
format: "png".to_string(),
|
|
})
|
|
}
|
|
|
|
/// Wait for element with custom timeout
|
|
pub async fn wait_for_element(
|
|
&self,
|
|
session_id: &str,
|
|
selector: &str,
|
|
timeout_ms: u64,
|
|
) -> Result<ElementInfo> {
|
|
let client = self.get_client(session_id).await?;
|
|
let locator = Locator::Css(selector);
|
|
|
|
// Use the new wait().for_element() API instead of deprecated wait_for_find
|
|
let element = tokio::time::timeout(
|
|
Duration::from_millis(timeout_ms),
|
|
client.wait().for_element(locator)
|
|
)
|
|
.await
|
|
.map_err(|_| BrowserError::Timeout {
|
|
selector: selector.to_string(),
|
|
})?
|
|
.map_err(|_| BrowserError::ElementNotFound {
|
|
selector: selector.to_string(),
|
|
})?;
|
|
|
|
self.element_to_info(&element, selector).await
|
|
}
|
|
|
|
/// Get page source
|
|
pub async fn get_source(&self, session_id: &str) -> Result<String> {
|
|
let client = self.get_client(session_id).await?;
|
|
client.source().await.map_err(|e| BrowserError::CommandFailed(e.to_string()))
|
|
}
|
|
|
|
/// Get current URL
|
|
pub async fn get_current_url(&self, session_id: &str) -> Result<String> {
|
|
let client = self.get_client(session_id).await?;
|
|
let url = client.current_url().await.map_err(|e| BrowserError::CommandFailed(e.to_string()))?;
|
|
Ok(url.to_string())
|
|
}
|
|
|
|
/// Get page title
|
|
pub async fn get_title(&self, session_id: &str) -> Result<String> {
|
|
let client = self.get_client(session_id).await?;
|
|
client.title().await.map_err(|e| BrowserError::CommandFailed(e.to_string()))
|
|
}
|
|
|
|
// Private helper methods
|
|
|
|
async fn get_client(&self, session_id: &str) -> Result<Client> {
|
|
let connections = self.connections.read().await;
|
|
connections
|
|
.get(session_id)
|
|
.cloned()
|
|
.ok_or_else(|| BrowserError::SessionNotFound(session_id.to_string()))
|
|
}
|
|
|
|
fn build_capabilities(&self, config: &SessionConfig) -> Result<serde_json::Map<String, serde_json::Value>> {
|
|
let browser_name = match config.browser_type {
|
|
BrowserType::Chrome => "chrome",
|
|
BrowserType::Firefox => "firefox",
|
|
BrowserType::Edge => "MicrosoftEdge",
|
|
BrowserType::Safari => "safari",
|
|
};
|
|
|
|
let mut args = vec![];
|
|
|
|
if config.headless {
|
|
args.push("--headless".to_string());
|
|
}
|
|
|
|
if let Some((width, height)) = config.window_size {
|
|
args.push(format!("--window-size={},{}", width, height));
|
|
}
|
|
|
|
args.extend(config.browser_args.clone());
|
|
|
|
let mut caps = serde_json::Map::new();
|
|
caps.insert("browserName".to_string(), serde_json::json!(browser_name));
|
|
|
|
let mut chrome_options = serde_json::Map::new();
|
|
chrome_options.insert("args".to_string(), serde_json::json!(args));
|
|
chrome_options.insert("w3c".to_string(), serde_json::json!(true));
|
|
|
|
caps.insert("goog:chromeOptions".to_string(), serde_json::Value::Object(chrome_options));
|
|
|
|
Ok(caps)
|
|
}
|
|
|
|
async fn element_to_info(&self, element: &Element, selector: &str) -> Result<ElementInfo> {
|
|
let tag_name = element.tag_name().await.ok();
|
|
let text = element.text().await.ok();
|
|
let is_displayed = element.is_displayed().await.unwrap_or(false);
|
|
let is_enabled = element.is_enabled().await.unwrap_or(false);
|
|
let is_selected = element.is_selected().await.unwrap_or(false);
|
|
// Note: location() and size() may not be available in all fantoccini versions
|
|
// Using placeholder values if not available
|
|
let location = None;
|
|
let size = None;
|
|
|
|
Ok(ElementInfo {
|
|
selector: selector.to_string(),
|
|
tag_name,
|
|
text,
|
|
is_displayed,
|
|
is_enabled,
|
|
is_selected,
|
|
location,
|
|
size,
|
|
})
|
|
}
|
|
|
|
async fn update_session_location(&self, session_id: &str) {
|
|
if let Ok(client) = self.get_client(session_id).await {
|
|
let current_url = client.current_url().await.ok().map(|u| u.to_string());
|
|
let title = client.title().await.ok();
|
|
|
|
self.session_manager
|
|
.update_session(session_id, |s| {
|
|
s.update_location(current_url, title);
|
|
})
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for BrowserClient {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl Clone for BrowserClient {
|
|
fn clone(&self) -> Self {
|
|
Self {
|
|
connections: Arc::clone(&self.connections),
|
|
session_manager: self.session_manager.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// Result types
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct NavigationResult {
|
|
pub url: Option<String>,
|
|
pub title: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ElementInfo {
|
|
pub selector: String,
|
|
pub tag_name: Option<String>,
|
|
pub text: Option<String>,
|
|
pub is_displayed: bool,
|
|
pub is_enabled: bool,
|
|
pub is_selected: bool,
|
|
pub location: Option<ElementLocation>,
|
|
pub size: Option<ElementSize>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ElementLocation {
|
|
pub x: i32,
|
|
pub y: i32,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ElementSize {
|
|
pub width: u64,
|
|
pub height: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ScreenshotResult {
|
|
pub data: Vec<u8>,
|
|
pub base64: String,
|
|
pub format: String,
|
|
}
|