use crate::browser::config::{ConnectionOptions, LaunchOptions};
use crate::dom::{DocumentMetadata, DomTree};
use crate::error::{BrowserError, Result};
use crate::tools::{ToolContext, ToolRegistry};
use headless_chrome::{Browser, Tab};
use std::ffi::OsStr;
use std::sync::atomic::{AtomicU16, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::time::Duration;
use std::time::Instant;
const DEBUG_PORT_START: u16 = 40_000;
const DEBUG_PORT_END: u16 = 59_999;
static DEBUG_PORT_COUNTER: AtomicU16 = AtomicU16::new(DEBUG_PORT_START);
pub struct TabElement<'a> {
pub tab: Arc<Tab>,
pub element: headless_chrome::Element<'a>,
}
#[derive(Debug, Clone)]
pub(crate) struct MarkdownCacheEntry {
pub document_id: String,
pub revision: String,
pub title: String,
pub url: String,
pub byline: String,
pub excerpt: String,
pub site_name: String,
pub full_markdown: Arc<str>,
}
pub struct BrowserSession {
browser: Browser,
tool_registry: ToolRegistry,
active_tab_hint: RwLock<Option<Arc<Tab>>>,
markdown_cache: Mutex<Option<Arc<MarkdownCacheEntry>>>,
}
impl BrowserSession {
pub fn launch(options: LaunchOptions) -> Result<Self> {
let launch_opts = build_launch_options(options);
let browser =
Browser::new(launch_opts).map_err(|e| BrowserError::LaunchFailed(e.to_string()))?;
let initial_tab = browser
.new_tab()
.map_err(|e| BrowserError::LaunchFailed(format!("Failed to create tab: {}", e)))?;
Ok(Self {
browser,
tool_registry: ToolRegistry::with_defaults(),
active_tab_hint: RwLock::new(Some(initial_tab)),
markdown_cache: Mutex::new(None),
})
}
pub fn connect(options: ConnectionOptions) -> Result<Self> {
let browser = Browser::connect(options.ws_url)
.map_err(|e| BrowserError::ConnectionFailed(e.to_string()))?;
Ok(Self {
browser,
tool_registry: ToolRegistry::with_defaults(),
active_tab_hint: RwLock::new(None),
markdown_cache: Mutex::new(None),
})
}
pub fn new() -> Result<Self> {
Self::launch(LaunchOptions::default())
}
pub fn tab(&self) -> Result<Arc<Tab>> {
self.get_active_tab()
}
pub fn new_tab(&mut self) -> Result<Arc<Tab>> {
let tab = self.browser.new_tab().map_err(|e| {
BrowserError::TabOperationFailed(format!("Failed to create tab: {}", e))
})?;
self.set_active_tab_hint(Some(tab.clone()))?;
Ok(tab)
}
pub fn get_tabs(&self) -> Result<Vec<Arc<Tab>>> {
let tabs = self
.browser
.get_tabs()
.lock()
.map_err(|e| BrowserError::TabOperationFailed(format!("Failed to get tabs: {}", e)))?
.clone();
Ok(tabs)
}
pub fn get_active_tab(&self) -> Result<Arc<Tab>> {
if let Some(tab) = self.active_tab_hint()? {
return Ok(tab);
}
let tabs = self.get_tabs()?;
for tab in &tabs {
let result = tab.evaluate(
"document.visibilityState === 'visible' && document.hasFocus()",
false,
);
match result {
Ok(remote_object) => {
if let Some(value) = remote_object.value {
if value.as_bool().unwrap_or(false) {
self.set_active_tab_hint(Some(tab.clone()))?;
return Ok(tab.clone());
}
}
}
Err(e) => {
log::debug!("Failed to check tab status: {}", e);
continue;
}
}
}
for tab in &tabs {
let result = tab.evaluate("document.visibilityState === 'visible'", false);
match result {
Ok(remote_object) => {
if let Some(value) = remote_object.value {
if value.as_bool().unwrap_or(false) {
self.set_active_tab_hint(Some(tab.clone()))?;
return Ok(tab.clone());
}
}
}
Err(_) => continue,
}
}
Err(BrowserError::TabOperationFailed(
"No active tab found".to_string(),
))
}
pub fn close_active_tab(&mut self) -> Result<()> {
let active_tab = self.tab()?;
self.clear_active_tab_hint()?;
active_tab
.close(true)
.map_err(|e| BrowserError::TabOperationFailed(format!("Failed to close tab: {}", e)))?;
Ok(())
}
pub fn browser(&self) -> &Browser {
&self.browser
}
pub fn activate_tab(&self, tab: &Arc<Tab>) -> Result<()> {
tab.activate().map_err(|e| {
BrowserError::TabOperationFailed(format!("Failed to activate tab: {}", e))
})?;
self.set_active_tab_hint(Some(tab.clone()))?;
Ok(())
}
pub fn open_tab(&self, url: &str) -> Result<Arc<Tab>> {
let tab = self.browser.new_tab().map_err(|e| {
BrowserError::TabOperationFailed(format!("Failed to create tab: {}", e))
})?;
tab.navigate_to(url).map_err(|e| {
BrowserError::NavigationFailed(format!("Failed to navigate to {}: {}", url, e))
})?;
tab.wait_until_navigated().map_err(|e| {
BrowserError::NavigationFailed(format!("Navigation to {} did not complete: {}", url, e))
})?;
self.activate_tab(&tab)?;
Ok(tab)
}
pub fn navigate(&self, url: &str) -> Result<()> {
self.tab()?.navigate_to(url).map_err(|e| {
BrowserError::NavigationFailed(format!("Failed to navigate to {}: {}", url, e))
})?;
Ok(())
}
pub fn document_metadata(&self) -> Result<DocumentMetadata> {
let tab = self.tab()?;
self.document_metadata_for_tab(&tab)
}
pub(crate) fn document_metadata_for_tab(&self, tab: &Arc<Tab>) -> Result<DocumentMetadata> {
DocumentMetadata::from_tab(tab)
}
pub fn wait_for_navigation(&self) -> Result<()> {
let tab = self.tab()?;
tab.wait_until_navigated()
.map_err(|e| BrowserError::NavigationFailed(format!("Navigation timeout: {}", e)))?;
self.wait_for_document_ready_with_tab(&tab, Duration::from_secs(30))?;
Ok(())
}
pub fn document_ready_state(&self) -> Result<String> {
let tab = self.tab()?;
self.document_ready_state_for_tab(&tab)
}
fn document_ready_state_for_tab(&self, tab: &Arc<Tab>) -> Result<String> {
let result = tab.evaluate("document.readyState", false).map_err(|e| {
BrowserError::NavigationFailed(format!("Failed to read readyState: {}", e))
})?;
let ready_state = result
.value
.and_then(|value| value.as_str().map(str::to_string))
.ok_or_else(|| {
BrowserError::NavigationFailed(
"Browser did not return a document.readyState value".to_string(),
)
})?;
Ok(ready_state)
}
pub fn wait_for_document_ready_with_timeout(&self, timeout: Duration) -> Result<()> {
let tab = self.tab()?;
self.wait_for_document_ready_with_tab(&tab, timeout)
}
fn wait_for_document_ready_with_tab(&self, tab: &Arc<Tab>, timeout: Duration) -> Result<()> {
let start = Instant::now();
loop {
let ready_state = self.document_ready_state_for_tab(tab)?;
if ready_state == "complete" {
return Ok(());
}
if start.elapsed() >= timeout {
return Err(BrowserError::Timeout(format!(
"Document did not reach readyState=complete within {} ms",
timeout.as_millis()
)));
}
std::thread::sleep(Duration::from_millis(50));
}
}
fn wait_for_history_settle(
&self,
tab: &Arc<Tab>,
previous_url: &str,
timeout: Duration,
) -> Result<()> {
let start = Instant::now();
let mut observed_navigation = false;
loop {
let current_url = tab.get_url();
if current_url != previous_url {
observed_navigation = true;
}
let ready_state = self.document_ready_state_for_tab(tab)?;
let elapsed = start.elapsed();
let grace_period = Duration::from_millis(500);
if ready_state == "complete" && (observed_navigation || elapsed >= grace_period) {
return Ok(());
}
if elapsed >= timeout {
return Err(BrowserError::Timeout(format!(
"History navigation did not settle within {} ms",
timeout.as_millis()
)));
}
std::thread::sleep(Duration::from_millis(50));
}
}
pub fn extract_dom(&self) -> Result<DomTree> {
let tab = self.tab()?;
DomTree::from_tab(&tab)
}
pub fn extract_dom_with_prefix(&self, prefix: &str) -> Result<DomTree> {
let tab = self.tab()?;
DomTree::from_tab_with_prefix(&tab, prefix)
}
pub fn find_element<'a>(
&self,
tab: &'a Arc<Tab>,
css_selector: &str,
) -> Result<headless_chrome::Element<'a>> {
tab.find_element(css_selector).map_err(|e| {
BrowserError::ElementNotFound(format!("Element '{}' not found: {}", css_selector, e))
})
}
pub fn tool_registry(&self) -> &ToolRegistry {
&self.tool_registry
}
pub fn tool_registry_mut(&mut self) -> &mut ToolRegistry {
&mut self.tool_registry
}
pub fn execute_tool(
&self,
name: &str,
params: serde_json::Value,
) -> Result<crate::tools::ToolResult> {
let mut context = ToolContext::new(self);
self.tool_registry.execute(name, params, &mut context)
}
fn active_tab_hint(&self) -> Result<Option<Arc<Tab>>> {
Ok(self
.active_tab_hint
.read()
.map_err(|e| {
BrowserError::TabOperationFailed(format!("Failed to read active tab hint: {}", e))
})?
.clone())
}
pub(crate) fn set_active_tab_hint(&self, tab: Option<Arc<Tab>>) -> Result<()> {
*self.active_tab_hint.write().map_err(|e| {
BrowserError::TabOperationFailed(format!("Failed to write active tab hint: {}", e))
})? = tab;
Ok(())
}
pub(crate) fn clear_active_tab_hint(&self) -> Result<()> {
self.set_active_tab_hint(None)
}
pub(crate) fn markdown_cache_entry(
&self,
document: &DocumentMetadata,
) -> Result<Option<Arc<MarkdownCacheEntry>>> {
let guard = self
.markdown_cache
.lock()
.map_err(|e| BrowserError::ToolExecutionFailed {
tool: "get_markdown".to_string(),
reason: format!("Failed to read markdown cache: {}", e),
})?;
Ok(guard.as_ref().and_then(|entry| {
(entry.document_id == document.document_id && entry.revision == document.revision)
.then_some(Arc::clone(entry))
}))
}
pub(crate) fn store_markdown_cache(&self, entry: Arc<MarkdownCacheEntry>) -> Result<()> {
*self
.markdown_cache
.lock()
.map_err(|e| BrowserError::ToolExecutionFailed {
tool: "get_markdown".to_string(),
reason: format!("Failed to write markdown cache: {}", e),
})? = Some(entry);
Ok(())
}
pub fn go_back(&self) -> Result<()> {
let tab = self.tab()?;
let previous_url = tab.get_url();
let go_back_js = r#"
(function() {
window.history.back();
return true;
})()
"#;
tab.evaluate(go_back_js, false)
.map_err(|e| BrowserError::NavigationFailed(format!("Failed to go back: {}", e)))?;
self.wait_for_history_settle(&tab, &previous_url, Duration::from_secs(5))?;
Ok(())
}
pub fn go_forward(&self) -> Result<()> {
let tab = self.tab()?;
let previous_url = tab.get_url();
let go_forward_js = r#"
(function() {
window.history.forward();
return true;
})()
"#;
tab.evaluate(go_forward_js, false)
.map_err(|e| BrowserError::NavigationFailed(format!("Failed to go forward: {}", e)))?;
self.wait_for_history_settle(&tab, &previous_url, Duration::from_secs(5))?;
Ok(())
}
pub fn close(&self) -> Result<()> {
let tabs = self.get_tabs()?;
for tab in tabs {
let _ = tab.close(false); }
Ok(())
}
}
impl Default for BrowserSession {
fn default() -> Self {
Self::new().expect("Failed to create default browser session")
}
}
fn choose_debug_port() -> u16 {
let span = DEBUG_PORT_END - DEBUG_PORT_START + 1;
let offset = DEBUG_PORT_COUNTER.fetch_add(1, Ordering::Relaxed) % span;
DEBUG_PORT_START + offset
}
fn build_launch_options(options: LaunchOptions) -> headless_chrome::LaunchOptions<'static> {
let mut launch_opts = headless_chrome::LaunchOptions::default();
launch_opts
.ignore_default_args
.push(OsStr::new("--enable-automation"));
launch_opts
.args
.push(OsStr::new("--disable-blink-features=AutomationControlled"));
launch_opts.idle_browser_timeout = Duration::from_secs(60 * 60);
launch_opts.headless = options.headless;
launch_opts.window_size = Some((options.window_width, options.window_height));
launch_opts.port = Some(options.debug_port.unwrap_or_else(choose_debug_port));
launch_opts.sandbox = options.sandbox;
if let Some(path) = options.chrome_path {
launch_opts.path = Some(path);
}
if let Some(dir) = options.user_data_dir {
launch_opts.user_data_dir = Some(dir);
}
launch_opts
}
#[cfg(test)]
mod tests {
use super::*;
use crate::browser::launch_error_is_environmental;
fn launch_or_skip(result: Result<BrowserSession>) -> Option<BrowserSession> {
match result {
Ok(session) => Some(session),
Err(err) if launch_error_is_environmental(&err) => {
eprintln!("Skipping browser launch test due to environment: {}", err);
None
}
Err(err) => panic!("Unexpected launch failure: {}", err),
}
}
#[test]
fn test_launch_options_builder() {
let opts = LaunchOptions::new().headless(true).window_size(800, 600);
assert!(opts.headless);
assert_eq!(opts.window_width, 800);
assert_eq!(opts.window_height, 600);
}
#[test]
fn test_connection_options() {
let opts = ConnectionOptions::new("ws://localhost:9222").timeout(5000);
assert_eq!(opts.ws_url, "ws://localhost:9222");
assert_eq!(opts.timeout, 5000);
}
#[test]
fn test_choose_debug_port_advances_within_expected_range() {
let first = choose_debug_port();
let second = choose_debug_port();
assert!((DEBUG_PORT_START..=DEBUG_PORT_END).contains(&first));
assert!((DEBUG_PORT_START..=DEBUG_PORT_END).contains(&second));
assert_ne!(first, second);
}
#[test]
fn test_build_launch_options_maps_browser_settings() {
let options = LaunchOptions::new()
.headless(false)
.window_size(1024, 768)
.sandbox(false)
.debug_port(45555)
.chrome_path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome".into())
.user_data_dir("/tmp/chromewright-test".into());
let launch_opts = build_launch_options(options);
assert!(!launch_opts.headless);
assert_eq!(launch_opts.window_size, Some((1024, 768)));
assert_eq!(launch_opts.port, Some(45555));
assert!(!launch_opts.sandbox);
assert_eq!(
launch_opts.path.as_deref(),
Some(std::path::Path::new(
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
))
);
assert_eq!(
launch_opts.user_data_dir.as_deref(),
Some(std::path::Path::new("/tmp/chromewright-test"))
);
assert_eq!(
launch_opts.idle_browser_timeout,
Duration::from_secs(60 * 60)
);
assert!(
launch_opts
.ignore_default_args
.iter()
.any(|arg| *arg == OsStr::new("--enable-automation"))
);
assert!(
launch_opts
.args
.iter()
.any(|arg| { *arg == OsStr::new("--disable-blink-features=AutomationControlled") })
);
}
#[test]
fn test_build_launch_options_chooses_debug_port_when_missing() {
let launch_opts = build_launch_options(LaunchOptions::new());
let port = launch_opts.port.expect("port should be assigned");
assert!((DEBUG_PORT_START..=DEBUG_PORT_END).contains(&port));
}
#[test]
#[ignore]
fn test_get_active_tab() {
let Some(session) =
launch_or_skip(BrowserSession::launch(LaunchOptions::new().headless(true)))
else {
return;
};
let tab = session.get_active_tab();
assert!(tab.is_ok());
}
#[test]
#[ignore] fn test_launch_browser() {
let Some(_session) =
launch_or_skip(BrowserSession::launch(LaunchOptions::new().headless(true)))
else {
return;
};
}
#[test]
#[ignore]
fn test_navigate() {
let Some(session) =
launch_or_skip(BrowserSession::launch(LaunchOptions::new().headless(true)))
else {
return;
};
let result = session.navigate("about:blank");
assert!(result.is_ok());
}
#[test]
#[ignore]
fn test_new_tab() {
let Some(mut session) =
launch_or_skip(BrowserSession::launch(LaunchOptions::new().headless(true)))
else {
return;
};
let result = session.new_tab();
assert!(result.is_ok());
let tabs = session.get_tabs().expect("Failed to get tabs");
assert!(tabs.len() >= 2);
}
}