bottest/src/web/browser.rs
Rodrigo Rodriguez (Pragmatismo) 45d588ad2b E2E test improvements: auto-start services, use Brave browser
- Add BotServerInstance::start_with_main_stack() for using real LLM
- Update E2E tests to auto-start BotServer and BotUI if not running
- Prefer Brave browser over Chrome/Chromium for CDP testing
- Upgrade chromiumoxide to 0.8
- Add browser window position/size for visibility
- Fix chat tests to require BotUI for chat interface
- Add browser_service.rs for CDP-based browser management
- Remove chromedriver dependency (use CDP directly)
2025-12-15 13:57:05 -03:00

1072 lines
34 KiB
Rust

//! Browser abstraction for E2E testing using Chrome DevTools Protocol
//!
//! Provides a high-level interface for browser automation using chromiumoxide/CDP.
//! Supports Chrome, Brave, and other Chromium-based browsers.
use anyhow::{Context, Result};
use chromiumoxide::browser::{Browser as CdpBrowser, BrowserConfig as CdpBrowserConfig};
use chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotFormat;
use chromiumoxide::page::Page;
use chromiumoxide::Element as CdpElement;
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
use tokio::time::sleep;
use super::{Cookie, Key, Locator, WaitCondition};
/// Browser type for E2E testing
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BrowserType {
Chrome,
Firefox,
Safari,
Edge,
}
impl Default for BrowserType {
fn default() -> Self {
Self::Chrome
}
}
impl BrowserType {
/// Get the browser name for WebDriver
pub fn browser_name(&self) -> &'static str {
match self {
BrowserType::Chrome => "chrome",
BrowserType::Firefox => "firefox",
BrowserType::Safari => "safari",
BrowserType::Edge => "MicrosoftEdge",
}
}
/// Get the WebDriver capability name for this browser
pub fn capability_name(&self) -> &'static str {
match self {
BrowserType::Chrome => "goog:chromeOptions",
BrowserType::Firefox => "moz:firefoxOptions",
BrowserType::Safari => "safari:options",
BrowserType::Edge => "ms:edgeOptions",
}
}
}
/// Configuration for browser sessions
#[derive(Debug, Clone)]
pub struct BrowserConfig {
/// Browser type
pub browser_type: BrowserType,
/// CDP debugging port (connects to existing browser)
pub debug_port: u16,
/// Whether to run headless (when launching browser)
pub headless: bool,
/// Window width
pub window_width: u32,
/// Window height
pub window_height: u32,
/// Default timeout for operations
pub timeout: Duration,
/// Browser binary path (for launching browser)
pub binary_path: Option<String>,
}
impl Default for BrowserConfig {
fn default() -> Self {
let binary_path = Self::detect_browser_binary();
// Default: SHOW browser window so user can see tests
// Set HEADLESS=1 to run without browser window (CI/automation)
let headless = std::env::var("HEADLESS").is_ok();
Self {
browser_type: BrowserType::Chrome,
debug_port: 9222,
headless,
window_width: 1920,
window_height: 1080,
timeout: Duration::from_secs(30),
binary_path,
}
}
}
impl BrowserConfig {
/// Detect the best available browser binary for CDP testing
fn detect_browser_binary() -> Option<String> {
// Check for BROWSER_BINARY env var first
if let Ok(path) = std::env::var("BROWSER_BINARY") {
if std::path::Path::new(&path).exists() {
log::info!("Using browser from BROWSER_BINARY env var: {}", path);
return Some(path);
}
}
// Prefer Brave first
let brave_paths = [
"/opt/brave.com/brave-nightly/brave",
"/opt/brave.com/brave/brave",
"/usr/bin/brave-browser-nightly",
"/usr/bin/brave-browser",
];
for path in brave_paths {
if std::path::Path::new(path).exists() {
log::info!("Detected Brave binary at: {}", path);
return Some(path.to_string());
}
}
// Chrome second
let chrome_paths = [
"/opt/google/chrome/chrome",
"/opt/google/chrome/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome",
];
for path in chrome_paths {
if std::path::Path::new(path).exists() {
log::info!("Detected Chrome binary at: {}", path);
return Some(path.to_string());
}
}
// Chromium last
let chromium_paths = [
"/usr/bin/chromium-browser",
"/usr/bin/chromium",
"/snap/bin/chromium",
];
for path in chromium_paths {
if std::path::Path::new(path).exists() {
log::info!("Detected Chromium binary at: {}", path);
return Some(path.to_string());
}
}
None
}
/// Create a new browser config
pub fn new() -> Self {
Self::default()
}
/// Set browser type
pub fn with_browser(mut self, browser: BrowserType) -> Self {
self.browser_type = browser;
self
}
/// Set CDP debugging port
pub fn with_debug_port(mut self, port: u16) -> Self {
self.debug_port = port;
self
}
/// Alias for with_debug_port for compatibility
pub fn with_webdriver_url(mut self, url: &str) -> Self {
// Extract port from URL like "http://localhost:9222"
if let Some(port_str) = url.split(':').last() {
if let Ok(port) = port_str.parse() {
self.debug_port = port;
}
}
self
}
/// Set headless mode
pub fn headless(mut self, headless: bool) -> Self {
self.headless = headless;
self
}
/// Set window size
pub fn with_window_size(mut self, width: u32, height: u32) -> Self {
self.window_width = width;
self.window_height = height;
self
}
/// Set default timeout
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
/// Add a browser argument (for compatibility, stored but not used with CDP)
pub fn with_arg(self, _arg: &str) -> Self {
self
}
/// Set browser binary path
pub fn with_binary(mut self, path: &str) -> Self {
self.binary_path = Some(path.to_string());
self
}
/// Build CDP browser config
pub fn build_cdp_config(&self) -> Result<CdpBrowserConfig> {
let mut builder = CdpBrowserConfig::builder();
if let Some(ref binary) = self.binary_path {
builder = builder.chrome_executable(binary);
}
if self.headless {
builder = builder.arg("--headless=new");
}
builder = builder
.arg("--no-sandbox")
.arg("--disable-dev-shm-usage")
.arg("--disable-extensions")
.arg(format!(
"--window-size={},{}",
self.window_width, self.window_height
))
.port(self.debug_port);
builder
.build()
.map_err(|e| anyhow::anyhow!("Failed to build CDP browser config: {}", e))
}
/// Build capabilities (for compatibility)
pub fn build_capabilities(&self) -> serde_json::Value {
serde_json::json!({
"browserName": self.browser_type.browser_name(),
"acceptInsecureCerts": true,
})
}
}
/// Browser instance for E2E testing using CDP
pub struct Browser {
browser: Arc<CdpBrowser>,
page: Arc<Mutex<Page>>,
config: BrowserConfig,
_handle: tokio::task::JoinHandle<()>,
}
impl Browser {
/// Create a new browser instance by connecting to existing CDP endpoint
pub async fn new(config: BrowserConfig) -> Result<Self> {
log::info!("Connecting to browser CDP on port {}", config.debug_port);
// Get the WebSocket debugger URL from the CDP JSON endpoint
let json_url = format!("http://127.0.0.1:{}/json/version", config.debug_port);
let ws_url = match reqwest::get(&json_url).await {
Ok(resp) if resp.status().is_success() => {
let json: serde_json::Value = resp
.json()
.await
.context("Failed to parse CDP JSON response")?;
json.get("webSocketDebuggerUrl")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("ws://127.0.0.1:{}", config.debug_port))
}
_ => format!("ws://127.0.0.1:{}", config.debug_port),
};
log::info!("CDP WebSocket URL: {}", ws_url);
// Try to connect to existing browser via CDP
let (browser, mut handler) = CdpBrowser::connect(&ws_url)
.await
.context(format!("Failed to connect to browser CDP at {}", ws_url))?;
// Spawn handler task - resilient to CDP message deserialization errors
// Note: Some browsers (especially Brave) send custom CDP events that chromiumoxide
// doesn't recognize, causing deserialization errors. We continue despite these errors
// as they typically don't affect the core functionality.
let handle = tokio::spawn(async move {
loop {
match handler.next().await {
Some(Ok(_)) => {
// Event processed successfully
}
Some(Err(e)) => {
// Log at trace level to reduce noise from Brave's custom events
let err_str = format!("{:?}", e);
if err_str.contains("did not match any variant") {
log::trace!("CDP: Ignoring unknown message type (likely browser-specific extension)");
} else if err_str.contains("ResetWithoutClosingHandshake")
|| err_str.contains("AlreadyClosed")
{
log::debug!("CDP connection closed: {:?}", e);
break;
} else {
log::debug!("CDP handler error: {:?}", e);
}
}
None => {
log::debug!("CDP handler stream ended");
break;
}
}
}
});
// Try to get existing page first, create new one if none exist
let page = match browser.pages().await {
Ok(pages) if !pages.is_empty() => {
log::info!("Using existing page");
pages.into_iter().next().unwrap()
}
_ => {
log::info!("Creating new page");
browser
.new_page("about:blank")
.await
.context("Failed to create new page")?
}
};
// Bring page to front
let _ = page.bring_to_front().await;
// Enable Security domain and ignore certificate errors via CDP
let _ = page.execute(
chromiumoxide::cdp::browser_protocol::security::SetIgnoreCertificateErrorsParams::builder()
.ignore(true)
.build()
.unwrap()
).await;
log::info!("CDP: Set to ignore certificate errors");
// Set viewport size via emulation
if let Ok(cmd) = chromiumoxide::cdp::browser_protocol::emulation::SetDeviceMetricsOverrideParams::builder()
.width(config.window_width)
.height(config.window_height)
.device_scale_factor(1.0)
.mobile(false)
.build()
{
let _ = page.execute(cmd).await;
}
log::info!("Successfully connected to browser via CDP");
Ok(Self {
browser: Arc::new(browser),
page: Arc::new(Mutex::new(page)),
config,
_handle: handle,
})
}
/// Create a new browser instance by launching a new browser
pub async fn launch(config: BrowserConfig) -> Result<Self> {
log::info!("Launching new browser with CDP");
let cdp_config = config.build_cdp_config()?;
let (browser, mut handler) = CdpBrowser::launch(cdp_config)
.await
.context("Failed to launch browser")?;
// Spawn handler task - resilient to CDP message deserialization errors
let handle = tokio::spawn(async move {
loop {
match handler.next().await {
Some(Ok(_)) => {
// Event processed successfully
}
Some(Err(e)) => {
let err_str = format!("{:?}", e);
if err_str.contains("did not match any variant") {
log::trace!("CDP: Ignoring unknown message type (likely browser-specific extension)");
} else if err_str.contains("ResetWithoutClosingHandshake")
|| err_str.contains("AlreadyClosed")
{
log::debug!("CDP connection closed: {:?}", e);
break;
} else {
log::debug!("CDP handler error: {:?}", e);
}
}
None => {
log::debug!("CDP handler stream ended");
break;
}
}
}
});
let page = browser
.new_page("about:blank")
.await
.context("Failed to create new page")?;
// Set viewport size via emulation
if let Ok(cmd) = chromiumoxide::cdp::browser_protocol::emulation::SetDeviceMetricsOverrideParams::builder()
.width(config.window_width)
.height(config.window_height)
.device_scale_factor(1.0)
.mobile(false)
.build()
{
let _ = page.execute(cmd).await;
}
log::info!("Browser launched successfully");
Ok(Self {
browser: Arc::new(browser),
page: Arc::new(Mutex::new(page)),
config,
_handle: handle,
})
}
/// Create a new headless browser with default settings
pub async fn new_headless() -> Result<Self> {
Self::launch(BrowserConfig::default().headless(true)).await
}
/// Create a new browser with visible window
pub async fn new_headed() -> Result<Self> {
Self::launch(BrowserConfig::default().headless(false)).await
}
/// Navigate to a URL
pub async fn goto(&self, url: &str) -> Result<()> {
let page = self.page.lock().await;
// Bring the page to front first
let _ = page.bring_to_front().await;
// For HTTPS URLs (especially with self-signed certs), use JavaScript navigation
// This works around chromiumoxide deserialization issues with some CDP responses
if url.starts_with("https://") {
log::info!("Using JavaScript navigation for HTTPS URL: {}", url);
// First navigate to about:blank to ensure clean state
let _ = page.goto("about:blank").await;
sleep(Duration::from_millis(100)).await;
// Use JavaScript to navigate - this bypasses CDP navigation issues
let nav_script = format!("window.location.href = '{}';", url);
let _ = page.evaluate(nav_script.as_str()).await;
// Wait for navigation to complete
sleep(Duration::from_millis(1500)).await;
// Check if we actually navigated
if let Ok(Some(current)) = page.url().await {
if current.as_str() != "about:blank" {
log::info!("Navigation successful: {}", current);
}
}
} else {
// Standard navigation for non-HTTPS URLs
page.goto(url)
.await
.context(format!("Failed to navigate to {}", url))?;
// Wait for page to actually load
sleep(Duration::from_millis(300)).await;
}
// Bring to front again after navigation
let _ = page.bring_to_front().await;
// Activate the window and force repaint
let _ = page
.evaluate("window.focus(); document.body.style.visibility = 'visible';")
.await;
Ok(())
}
/// Get the current URL
pub async fn current_url(&self) -> Result<String> {
let page = self.page.lock().await;
let url = page
.url()
.await
.context("Failed to get current URL")?
.unwrap_or_default();
Ok(url.to_string())
}
/// Get the page title
pub async fn title(&self) -> Result<String> {
let page = self.page.lock().await;
let title = page
.get_title()
.await
.context("Failed to get page title")?
.unwrap_or_default();
Ok(title)
}
/// Get the page source
pub async fn page_source(&self) -> Result<String> {
let page = self.page.lock().await;
let content = page.content().await.context("Failed to get page source")?;
Ok(content)
}
/// Find an element by locator
pub async fn find(&self, locator: Locator) -> Result<Element> {
let page = self.page.lock().await;
let selector = locator.to_css_selector();
let element = page
.find_element(&selector)
.await
.context(format!("Failed to find element: {:?}", locator))?;
Ok(Element {
inner: element,
locator,
})
}
/// Find all elements matching a locator
pub async fn find_all(&self, locator: Locator) -> Result<Vec<Element>> {
let page = self.page.lock().await;
let selector = locator.to_css_selector();
let elements = page
.find_elements(&selector)
.await
.context(format!("Failed to find elements: {:?}", locator))?;
Ok(elements
.into_iter()
.map(|e| Element {
inner: e,
locator: locator.clone(),
})
.collect())
}
/// Wait for an element to be present
pub async fn wait_for(&self, locator: Locator) -> Result<Element> {
self.wait_for_condition(locator, WaitCondition::Present)
.await
}
/// Wait for an element with a specific condition
pub async fn wait_for_condition(
&self,
locator: Locator,
condition: WaitCondition,
) -> Result<Element> {
let timeout = self.config.timeout;
let start = std::time::Instant::now();
while start.elapsed() < timeout {
match &condition {
WaitCondition::Present | WaitCondition::Visible | WaitCondition::Clickable => {
if let Ok(elem) = self.find(locator.clone()).await {
match &condition {
WaitCondition::Present => return Ok(elem),
WaitCondition::Visible => {
// CDP elements are visible if found
return Ok(elem);
}
WaitCondition::Clickable => {
return Ok(elem);
}
_ => {}
}
}
}
WaitCondition::NotPresent => {
if self.find(locator.clone()).await.is_err() {
anyhow::bail!("Element not present (expected)");
}
}
WaitCondition::NotVisible => {
if self.find(locator.clone()).await.is_err() {
anyhow::bail!("Element not visible (expected)");
}
}
WaitCondition::ContainsText(text) => {
if let Ok(elem) = self.find(locator.clone()).await {
if let Ok(elem_text) = elem.text().await {
if elem_text.contains(text) {
return Ok(elem);
}
}
}
}
WaitCondition::HasAttribute(attr, value) => {
if let Ok(elem) = self.find(locator.clone()).await {
if let Ok(Some(attr_val)) = elem.attr(attr).await {
if &attr_val == value {
return Ok(elem);
}
}
}
}
WaitCondition::Script(script) => {
if let Ok(result) = self.execute_script(script).await {
if result.as_bool().unwrap_or(false) {
return self.find(locator).await;
}
}
}
}
sleep(Duration::from_millis(100)).await;
}
anyhow::bail!(
"Timeout waiting for element {:?} with condition {:?}",
locator,
condition
)
}
/// Click an element
pub async fn click(&self, locator: Locator) -> Result<()> {
let elem = self
.wait_for_condition(locator, WaitCondition::Clickable)
.await?;
elem.click().await
}
/// Type text into an element
pub async fn fill(&self, locator: Locator, text: &str) -> Result<()> {
let elem = self
.wait_for_condition(locator, WaitCondition::Visible)
.await?;
elem.clear().await?;
elem.send_keys(text).await
}
/// Get text from an element
pub async fn text(&self, locator: Locator) -> Result<String> {
let elem = self.find(locator).await?;
elem.text().await
}
/// Check if an element exists
pub async fn exists(&self, locator: Locator) -> bool {
self.find(locator).await.is_ok()
}
/// Execute JavaScript
pub async fn execute_script(&self, script: &str) -> Result<serde_json::Value> {
let page = self.page.lock().await;
let result = page
.evaluate(script)
.await
.context("Failed to execute script")?;
Ok(result.value().cloned().unwrap_or(serde_json::Value::Null))
}
/// Execute JavaScript with arguments
pub async fn execute_script_with_args(
&self,
script: &str,
_args: Vec<serde_json::Value>,
) -> Result<serde_json::Value> {
// CDP doesn't directly support args, embed them in script
self.execute_script(script).await
}
/// Take a screenshot
pub async fn screenshot(&self) -> Result<Vec<u8>> {
let page = self.page.lock().await;
let screenshot = page
.screenshot(
chromiumoxide::page::ScreenshotParams::builder()
.format(CaptureScreenshotFormat::Png)
.build(),
)
.await
.context("Failed to take screenshot")?;
Ok(screenshot)
}
/// Save a screenshot to a file
pub async fn screenshot_to_file(&self, path: impl Into<PathBuf>) -> Result<()> {
let data = self.screenshot().await?;
let path = path.into();
std::fs::write(&path, data).context(format!("Failed to write screenshot to {:?}", path))
}
/// Refresh the page
pub async fn refresh(&self) -> Result<()> {
let page = self.page.lock().await;
page.reload().await.context("Failed to refresh page")?;
Ok(())
}
/// Go back in history
pub async fn back(&self) -> Result<()> {
// Use JavaScript history.back() instead
self.execute_script("history.back()").await?;
Ok(())
}
/// Go forward in history
pub async fn forward(&self) -> Result<()> {
// Use JavaScript history.forward() instead
self.execute_script("history.forward()").await?;
Ok(())
}
/// Set window size
pub async fn set_window_size(&self, width: u32, height: u32) -> Result<()> {
let page = self.page.lock().await;
let cmd = chromiumoxide::cdp::browser_protocol::emulation::SetDeviceMetricsOverrideParams::builder()
.width(width)
.height(height)
.device_scale_factor(1.0)
.mobile(false)
.build()
.map_err(|e| anyhow::anyhow!("Failed to build set window size params: {}", e))?;
page.execute(cmd)
.await
.context("Failed to set window size")?;
Ok(())
}
/// Maximize window (sets to large viewport)
pub async fn maximize_window(&self) -> Result<()> {
self.set_window_size(1920, 1080).await
}
/// Get all cookies
pub async fn get_cookies(&self) -> Result<Vec<Cookie>> {
let page = self.page.lock().await;
let cookies = page.get_cookies().await.context("Failed to get cookies")?;
Ok(cookies
.into_iter()
.map(|c| Cookie {
name: c.name,
value: c.value,
domain: Some(c.domain),
path: Some(c.path),
secure: Some(c.secure),
http_only: Some(c.http_only),
same_site: c.same_site.map(|s| format!("{:?}", s)),
expiry: None,
})
.collect())
}
/// Set a cookie
pub async fn set_cookie(&self, cookie: Cookie) -> Result<()> {
let page = self.page.lock().await;
page.set_cookie(
chromiumoxide::cdp::browser_protocol::network::CookieParam::builder()
.name(cookie.name)
.value(cookie.value)
.build()
.map_err(|e| anyhow::anyhow!("Failed to build cookie: {}", e))?,
)
.await
.context("Failed to set cookie")?;
Ok(())
}
/// Delete a cookie by name
pub async fn delete_cookie(&self, name: &str) -> Result<()> {
let page = self.page.lock().await;
page.delete_cookie(
chromiumoxide::cdp::browser_protocol::network::DeleteCookiesParams::builder()
.name(name)
.build()
.map_err(|e| anyhow::anyhow!("Failed to build delete cookie params: {}", e))?,
)
.await
.context("Failed to delete cookie")?;
Ok(())
}
/// Delete all cookies
pub async fn delete_all_cookies(&self) -> Result<()> {
let page = self.page.lock().await;
let cookies = page.get_cookies().await?;
for c in cookies {
page.delete_cookie(
chromiumoxide::cdp::browser_protocol::network::DeleteCookiesParams::builder()
.name(&c.name)
.build()
.map_err(|e| anyhow::anyhow!("Failed to build delete cookie params: {}", e))?,
)
.await
.ok();
}
Ok(())
}
/// Type text into an element (alias for fill)
pub async fn type_text(&self, locator: Locator, text: &str) -> Result<()> {
self.fill(locator, text).await
}
/// Find an element (alias for find)
pub async fn find_element(&self, locator: Locator) -> Result<Element> {
self.find(locator).await
}
/// Find all elements (alias for find_all)
pub async fn find_elements(&self, locator: Locator) -> Result<Vec<Element>> {
self.find_all(locator).await
}
/// Press a key on an element
pub async fn press_key(&self, locator: Locator, key: &str) -> Result<()> {
let elem = self.find(locator).await?;
elem.send_keys(key).await
}
/// Check if an element is enabled
pub async fn is_element_enabled(&self, locator: Locator) -> Result<bool> {
let elem = self.find(locator).await?;
elem.is_enabled().await
}
/// Check if an element is visible
pub async fn is_element_visible(&self, locator: Locator) -> Result<bool> {
let elem = self.find(locator).await?;
elem.is_displayed().await
}
/// Close the browser
pub async fn close(self) -> Result<()> {
// Browser will be closed when dropped
Ok(())
}
/// Send special key
pub async fn send_key(&self, key: Key) -> Result<()> {
let page = self.page.lock().await;
let key_str = Self::key_to_cdp_key(key);
// Use keyboard input via CDP
if let Ok(cmd) =
chromiumoxide::cdp::browser_protocol::input::DispatchKeyEventParams::builder()
.r#type(chromiumoxide::cdp::browser_protocol::input::DispatchKeyEventType::KeyDown)
.text(key_str)
.build()
{
let _ = page.execute(cmd).await;
}
Ok(())
}
fn key_to_cdp_key(key: Key) -> &'static str {
match key {
Key::Enter => "\r",
Key::Tab => "\t",
Key::Escape => "",
Key::Backspace => "",
Key::Delete => "",
Key::ArrowUp => "",
Key::ArrowDown => "",
Key::ArrowLeft => "",
Key::ArrowRight => "",
Key::Home => "",
Key::End => "",
Key::PageUp => "",
Key::PageDown => "",
_ => "",
}
}
// Frame methods - CDP handles frames differently
pub async fn switch_to_frame(&self, _locator: Locator) -> Result<()> {
Ok(())
}
pub async fn switch_to_frame_by_index(&self, _index: u16) -> Result<()> {
Ok(())
}
pub async fn switch_to_parent_frame(&self) -> Result<()> {
Ok(())
}
pub async fn switch_to_default_content(&self) -> Result<()> {
Ok(())
}
pub async fn current_window_handle(&self) -> Result<String> {
Ok("main".to_string())
}
pub async fn window_handles(&self) -> Result<Vec<String>> {
Ok(vec!["main".to_string()])
}
}
/// Wrapper around a CDP element
pub struct Element {
inner: CdpElement,
locator: Locator,
}
impl Element {
/// Click the element
pub async fn click(&self) -> Result<()> {
self.inner
.click()
.await
.map(|_| ())
.context("Failed to click element")
}
/// Clear the element's value
pub async fn clear(&self) -> Result<()> {
// Select all and delete
self.inner.click().await.ok();
self.inner
.type_str("")
.await
.map(|_| ())
.context("Failed to clear element")
}
/// Send keys to the element
pub async fn send_keys(&self, text: &str) -> Result<()> {
self.inner
.type_str(text)
.await
.map(|_| ())
.context("Failed to send keys")
}
/// Get the element's text content
pub async fn text(&self) -> Result<String> {
self.inner
.inner_text()
.await
.map(|opt| opt.unwrap_or_default())
.context("Failed to get element text")
}
/// Get the element's inner HTML
pub async fn inner_html(&self) -> Result<String> {
self.inner
.inner_html()
.await
.map(|opt| opt.unwrap_or_default())
.context("Failed to get inner HTML")
}
/// Get the element's outer HTML
pub async fn outer_html(&self) -> Result<String> {
self.inner
.outer_html()
.await
.map(|opt| opt.unwrap_or_default())
.context("Failed to get outer HTML")
}
/// Get an attribute value
pub async fn attr(&self, name: &str) -> Result<Option<String>> {
self.inner
.attribute(name)
.await
.context(format!("Failed to get attribute {}", name))
}
/// Get a CSS property value
pub async fn css_value(&self, _name: &str) -> Result<String> {
Ok(String::new())
}
/// Check if the element is displayed
pub async fn is_displayed(&self) -> Result<bool> {
// If we can get text, element exists and is visible
Ok(self.inner.inner_text().await.is_ok())
}
/// Check if the element is enabled
pub async fn is_enabled(&self) -> Result<bool> {
let disabled = self.inner.attribute("disabled").await?;
Ok(disabled.is_none())
}
/// Check if the element is selected
pub async fn is_selected(&self) -> Result<bool> {
let checked = self.inner.attribute("checked").await?;
Ok(checked.is_some())
}
/// Get the element's tag name
pub async fn tag_name(&self) -> Result<String> {
// CDP doesn't have direct tag name - try node name from description
Ok("element".to_string())
}
/// Get the element's location
pub async fn location(&self) -> Result<(i64, i64)> {
let point = self.inner.clickable_point().await?;
Ok((point.x as i64, point.y as i64))
}
/// Get the element's size
pub async fn size(&self) -> Result<(u64, u64)> {
Ok((100, 20)) // Default size
}
/// Get the locator used to find this element
pub fn locator(&self) -> &Locator {
&self.locator
}
/// Submit a form
pub async fn submit(&self) -> Result<()> {
self.click().await
}
/// Scroll the element into view
pub async fn scroll_into_view(&self) -> Result<()> {
self.inner
.scroll_into_view()
.await
.map(|_| ())
.context("Failed to scroll into view")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_browser_config_default() {
let config = BrowserConfig::default();
assert_eq!(config.browser_type, BrowserType::Chrome);
assert_eq!(config.debug_port, 9222);
assert_eq!(config.timeout, Duration::from_secs(30));
}
#[test]
fn test_browser_config_builder() {
let config = BrowserConfig::new()
.with_debug_port(9333)
.headless(false)
.with_window_size(1280, 720)
.with_timeout(Duration::from_secs(60));
assert_eq!(config.debug_port, 9333);
assert!(!config.headless);
assert_eq!(config.window_width, 1280);
assert_eq!(config.window_height, 720);
assert_eq!(config.timeout, Duration::from_secs(60));
}
#[test]
fn test_browser_type_browser_name() {
assert_eq!(BrowserType::Chrome.browser_name(), "chrome");
assert_eq!(BrowserType::Firefox.browser_name(), "firefox");
assert_eq!(BrowserType::Safari.browser_name(), "safari");
assert_eq!(BrowserType::Edge.browser_name(), "MicrosoftEdge");
}
}