pub mod actions;
pub mod cdp;
pub mod manager;
pub mod session;
pub use roboticus_core::config::BrowserConfig;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageInfo {
pub id: String,
pub url: String,
pub title: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScreenshotResult {
pub data_base64: String,
pub format: String,
pub width: u32,
pub height: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageContent {
pub url: String,
pub title: String,
pub text: String,
pub html_length: usize,
}
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::warn;
use roboticus_core::Result;
pub struct Browser {
config: BrowserConfig,
manager: RwLock<manager::BrowserManager>,
session: RwLock<Option<session::CdpSession>>,
}
impl Browser {
pub fn new(config: BrowserConfig) -> Self {
let mgr = manager::BrowserManager::new(config.clone());
Self {
config,
manager: RwLock::new(mgr),
session: RwLock::new(None),
}
}
pub async fn start(&self) -> Result<()> {
let mut mgr = self.manager.write().await;
mgr.start().await?;
let cdp = cdp::CdpClient::new(self.config.cdp_port)?;
let mut attempts = 0;
let targets = loop {
match cdp.list_targets().await {
Ok(t) if !t.is_empty() => break t,
_ if attempts < 10 => {
attempts += 1;
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
}
Ok(_) => {
return Err(roboticus_core::RoboticusError::Tool {
tool: "browser".into(),
message: "no CDP targets available after startup".into(),
});
}
Err(e) => return Err(e),
}
};
let ws_url = targets
.iter()
.find(|t| t.target_type == "page")
.and_then(|t| t.ws_url.clone())
.ok_or_else(|| roboticus_core::RoboticusError::Tool {
tool: "browser".into(),
message: "no page target with WebSocket URL found".into(),
})?;
let sess = session::CdpSession::connect(&ws_url).await?;
sess.send_command("Page.enable", serde_json::json!({}))
.await?;
sess.send_command("DOM.enable", serde_json::json!({}))
.await?;
sess.send_command("Network.enable", serde_json::json!({}))
.await?;
sess.send_command("Runtime.enable", serde_json::json!({}))
.await?;
*self.session.write().await = Some(sess);
Ok(())
}
pub async fn stop(&self) -> Result<()> {
if let Some(sess) = self.session.write().await.take() {
let _ = sess.close().await;
}
self.manager.write().await.stop().await
}
pub async fn is_running(&self) -> bool {
self.manager.read().await.is_running()
}
pub async fn execute_action(&self, action: &actions::BrowserAction) -> actions::ActionResult {
let initial = {
let session_guard = self.session.read().await;
match session_guard.as_ref() {
Some(sess) => actions::ActionExecutor::execute(sess, action).await,
None => {
return actions::ActionResult::err(
&format!("{:?}", action),
"browser not started".into(),
);
}
}
};
if initial.success
|| !should_attempt_session_recovery(action, &initial)
|| !is_idempotent_recovery_action(action)
{
return initial;
}
if let Err(err) = self.recover_session().await {
warn!(error = %err, "browser session recovery failed");
return actions::ActionResult::err(
&format!("{:?}", action),
format!(
"browser session recovery failed: {err}; original error: {}",
initial
.error
.unwrap_or_else(|| "unknown browser error".to_string())
),
);
}
let session_guard = self.session.read().await;
match session_guard.as_ref() {
Some(sess) => actions::ActionExecutor::execute(sess, action).await,
None => actions::ActionResult::err(
&format!("{:?}", action),
"browser session unavailable after recovery".into(),
),
}
}
pub fn cdp_port(&self) -> u16 {
self.config.cdp_port
}
async fn recover_session(&self) -> Result<()> {
let _ = self.stop().await;
self.start().await
}
}
fn should_attempt_session_recovery(
_action: &actions::BrowserAction,
result: &actions::ActionResult,
) -> bool {
if result.success {
return false;
}
let Some(err) = result.error.as_deref() else {
return false;
};
let e = err.to_ascii_lowercase();
if e.contains("not started") {
return false;
}
e.contains("websocket")
|| e.contains("closed")
|| e.contains("connection reset")
|| e.contains("broken pipe")
|| e.contains("cdp read error")
|| e.contains("cdp send failed")
}
fn is_idempotent_recovery_action(action: &actions::BrowserAction) -> bool {
matches!(
action,
actions::BrowserAction::Navigate { .. }
| actions::BrowserAction::Screenshot
| actions::BrowserAction::Pdf
| actions::BrowserAction::Evaluate { .. }
| actions::BrowserAction::GetCookies
| actions::BrowserAction::ReadPage
| actions::BrowserAction::GoBack
| actions::BrowserAction::GoForward
| actions::BrowserAction::Reload
)
}
pub type SharedBrowser = Arc<Browser>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn browser_config_defaults() {
let cfg = BrowserConfig::default();
assert!(!cfg.enabled);
assert!(cfg.headless);
assert_eq!(cfg.cdp_port, 9222);
assert!(cfg.executable_path.is_none());
}
#[test]
fn page_info_serde() {
let info = PageInfo {
id: "page1".into(),
url: "https://example.com".into(),
title: "Example".into(),
};
let json = serde_json::to_string(&info).unwrap();
let back: PageInfo = serde_json::from_str(&json).unwrap();
assert_eq!(back.id, "page1");
assert_eq!(back.url, "https://example.com");
}
#[test]
fn screenshot_result_serde() {
let result = ScreenshotResult {
data_base64: "abc123".into(),
format: "png".into(),
width: 1920,
height: 1080,
};
let json = serde_json::to_string(&result).unwrap();
let back: ScreenshotResult = serde_json::from_str(&json).unwrap();
assert_eq!(back.width, 1920);
}
#[test]
fn browser_facade_creation() {
let browser = Browser::new(BrowserConfig::default());
assert_eq!(browser.cdp_port(), 9222);
}
#[tokio::test]
async fn browser_not_running_initially() {
let browser = Browser::new(BrowserConfig::default());
assert!(!browser.is_running().await);
}
#[tokio::test]
async fn execute_action_without_start_returns_error() {
let browser = Browser::new(BrowserConfig::default());
let result = browser
.execute_action(&actions::BrowserAction::Screenshot)
.await;
assert!(!result.success);
assert!(result.error.as_deref().unwrap().contains("not started"));
}
#[tokio::test]
async fn navigate_without_browser_returns_error_not_panic() {
let browser = Browser::new(BrowserConfig::default());
let action = actions::BrowserAction::Navigate {
url: "https://example.com".into(),
};
let result = browser.execute_action(&action).await;
assert!(
!result.success,
"navigate should fail when browser isn't started"
);
assert!(result.error.is_some());
assert!(result.data.is_none());
}
#[tokio::test]
async fn all_actions_return_error_without_session() {
let browser = Browser::new(BrowserConfig::default());
let cases = vec![
actions::BrowserAction::Navigate {
url: "https://example.com".into(),
},
actions::BrowserAction::Click {
selector: "#btn".into(),
},
actions::BrowserAction::Type {
selector: "input".into(),
text: "hello".into(),
},
actions::BrowserAction::Screenshot,
actions::BrowserAction::Evaluate {
expression: "1+1".into(),
},
actions::BrowserAction::ReadPage,
actions::BrowserAction::Reload,
];
for action in &cases {
let result = browser.execute_action(action).await;
assert!(
!result.success,
"action {:?} should fail without session",
action
);
assert!(result.error.is_some());
}
}
#[tokio::test]
async fn all_12_actions_return_error_without_session() {
let browser = Browser::new(BrowserConfig::default());
let cases = vec![
actions::BrowserAction::Navigate {
url: "https://example.com".into(),
},
actions::BrowserAction::Click {
selector: "#btn".into(),
},
actions::BrowserAction::Type {
selector: "input".into(),
text: "hello".into(),
},
actions::BrowserAction::Screenshot,
actions::BrowserAction::Pdf,
actions::BrowserAction::Evaluate {
expression: "1+1".into(),
},
actions::BrowserAction::GetCookies,
actions::BrowserAction::ClearCookies,
actions::BrowserAction::ReadPage,
actions::BrowserAction::GoBack,
actions::BrowserAction::GoForward,
actions::BrowserAction::Reload,
];
for action in &cases {
let result = browser.execute_action(action).await;
assert!(
!result.success,
"action {:?} should fail without session",
action
);
assert!(result.error.is_some());
assert!(
result.error.as_deref().unwrap().contains("not started"),
"error should mention 'not started' for {:?}: {:?}",
action,
result.error
);
}
}
#[test]
fn session_recovery_detection_for_disconnect_signatures() {
let action = actions::BrowserAction::Navigate {
url: "https://example.com".to_string(),
};
let recoverable =
actions::ActionResult::err("Navigate", "CDP WebSocket closed unexpectedly".to_string());
assert!(should_attempt_session_recovery(&action, &recoverable));
let non_recoverable =
actions::ActionResult::err("Navigate", "browser not started".to_string());
assert!(!should_attempt_session_recovery(&action, &non_recoverable));
}
#[test]
fn session_recovery_detection_ignores_policy_errors() {
let action = actions::BrowserAction::Navigate {
url: "https://example.com".to_string(),
};
let blocked = actions::ActionResult::err(
"Navigate",
"URL scheme is blocked for security: file:///etc/passwd".to_string(),
);
assert!(
!should_attempt_session_recovery(&action, &blocked),
"security/policy denials should not trigger recovery loops"
);
}
#[test]
fn session_recovery_replay_is_limited_to_idempotent_actions() {
assert!(is_idempotent_recovery_action(
&actions::BrowserAction::ReadPage
));
assert!(!is_idempotent_recovery_action(
&actions::BrowserAction::Click {
selector: "#submit".to_string(),
}
));
assert!(!is_idempotent_recovery_action(
&actions::BrowserAction::Type {
selector: "input".to_string(),
text: "abc".to_string(),
}
));
assert!(!is_idempotent_recovery_action(
&actions::BrowserAction::ClearCookies
));
}
#[test]
fn page_content_serde() {
let content = PageContent {
url: "https://example.com".into(),
title: "Example".into(),
text: "Hello world".into(),
html_length: 1234,
};
let json = serde_json::to_string(&content).unwrap();
let back: PageContent = serde_json::from_str(&json).unwrap();
assert_eq!(back.url, "https://example.com");
assert_eq!(back.title, "Example");
assert_eq!(back.text, "Hello world");
assert_eq!(back.html_length, 1234);
}
#[test]
fn browser_custom_config() {
let config = BrowserConfig {
enabled: true,
headless: false,
cdp_port: 9333,
..Default::default()
};
let browser = Browser::new(config);
assert_eq!(browser.cdp_port(), 9333);
}
#[tokio::test]
async fn stop_without_start_is_ok() {
let browser = Browser::new(BrowserConfig::default());
let result = browser.stop().await;
assert!(result.is_ok());
}
#[tokio::test]
async fn shared_browser_type() {
let browser = Browser::new(BrowserConfig::default());
let shared: SharedBrowser = Arc::new(browser);
assert_eq!(shared.cdp_port(), 9222);
assert!(!shared.is_running().await);
}
#[test]
fn screenshot_result_fields() {
let result = ScreenshotResult {
data_base64: "iVBORw0KGgo=".into(),
format: "png".into(),
width: 800,
height: 600,
};
assert_eq!(result.format, "png");
assert_eq!(result.width, 800);
assert_eq!(result.height, 600);
assert!(!result.data_base64.is_empty());
}
#[test]
fn page_info_debug_and_clone() {
let info = PageInfo {
id: "p1".into(),
url: "https://example.com".into(),
title: "Test".into(),
};
let cloned = info.clone();
assert_eq!(cloned.id, "p1");
let debug_str = format!("{:?}", info);
assert!(debug_str.contains("p1"));
}
}