use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use chromiumoxide::browser::{Browser, BrowserConfig as ChromeBrowserConfig};
use chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotFormat;
use chromiumoxide::cdp::browser_protocol::target::{
CloseTargetParams, CreateTargetParams, GetTargetsParams, TargetId,
};
use chromiumoxide::page::ScreenshotParams;
use futures::StreamExt;
use tokio::sync::Mutex;
use tracing::{debug, info, warn};
use crate::config::{BrowserConfig, SessionIsolation};
use crate::tools::browser::diagnostics::BrowserDiagnosticsStore;
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
#[async_trait]
pub trait PageHandle: Send + Sync {
async fn goto(&self, url: &str) -> Result<(), String>;
async fn find_element(&self, selector: &str) -> Result<(), String>;
async fn wait_for_navigation(&self, timeout: Duration) -> Result<(), String>;
async fn is_element_visible(&self, selector: &str) -> Result<bool, String>;
async fn is_element_enabled(&self, selector: &str) -> Result<bool, String>;
async fn click(&self, selector: &str) -> Result<(), String>;
#[allow(dead_code)]
async fn type_text(&self, selector: &str, value: &str) -> Result<(), String>;
async fn evaluate(&self, script: &str) -> Result<Option<serde_json::Value>, String>;
async fn inner_text(&self, selector: &str) -> Result<String, String>;
async fn body_text(&self) -> Result<String, String>;
async fn screenshot(&self, selector: Option<&str>, full_page: bool) -> Result<Vec<u8>, String>;
async fn url(&self) -> Option<String>;
async fn replace_text(&self, selector: &str, value: &str) -> Result<(), String>;
async fn attach_diagnostics(
&self,
_session_id: &str,
_tab_id: &str,
_store: BrowserDiagnosticsStore,
) {
}
}
#[derive(Debug, Clone)]
pub struct TargetInfo {
pub target_id: String,
pub title: Option<String>,
pub url: Option<String>,
pub opener_id: Option<String>,
}
#[async_trait]
pub trait BrowserBackend: Send + Sync {
async fn ensure_ready(&self) -> Result<(), String>;
async fn create_page(&self) -> Result<(String, Option<String>, Arc<dyn PageHandle>), String>;
async fn shutdown(&self) -> Result<String, String>;
async fn dispose_session(&self, tab_target_ids: &[String], context_ids: &[String]);
async fn set_headless_mode(&self, headless: bool, mode: &str) -> Result<String, String>;
async fn list_targets(&self) -> Result<Vec<TargetInfo>, String>;
async fn page_for_target(&self, target_id: &str) -> Result<Arc<dyn PageHandle>, String>;
async fn close_target(&self, target_id: &str) -> Result<(), String>;
#[allow(dead_code)]
async fn is_connected(&self) -> bool;
async fn reconnect(&self) -> Result<(), String>;
}
pub fn is_connection_error(err: &str) -> bool {
let e = err.to_ascii_lowercase();
const CONNECTION_PATTERNS: &[&str] = &[
"connection closed",
"connection reset",
"connection refused",
"connection aborted",
"websocket",
"ws(",
"channel closed",
"sender was dropped",
"sender dropped",
"receiver dropped",
"channel is closed",
"request did not resolve",
"no response from",
"the browser was closed",
"chrome process",
"browser process",
"transport",
"broken pipe",
];
CONNECTION_PATTERNS.iter().any(|p| e.contains(p))
}
pub struct ChromiumoxideBackend {
browser: Arc<Mutex<Option<Browser>>>,
browser_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
handler_alive: Arc<AtomicBool>,
connected_to_existing: Arc<Mutex<bool>>,
headless: AtomicBool,
session_isolation: SessionIsolation,
config: BrowserConfig,
}
impl ChromiumoxideBackend {
pub fn new(config: BrowserConfig) -> Result<Self, String> {
let session_isolation = config.resolve_session_isolation()?;
let headless = AtomicBool::new(config.headless);
Ok(Self {
browser: Arc::new(Mutex::new(None)),
browser_handle: Arc::new(Mutex::new(None)),
handler_alive: Arc::new(AtomicBool::new(false)),
connected_to_existing: Arc::new(Mutex::new(false)),
headless,
session_isolation,
config,
})
}
pub fn session_isolation(&self) -> SessionIsolation {
self.session_isolation
}
fn has_display() -> bool {
if cfg!(target_os = "macos") {
true
} else if cfg!(target_os = "windows") {
true
} else {
std::env::var("DISPLAY").is_ok() || std::env::var("WAYLAND_DISPLAY").is_ok()
}
}
async fn new_blank_page(
&self,
) -> Result<(String, Option<String>, Arc<chromiumoxide::Page>), String> {
let guard = self.browser.lock().await;
let browser = guard
.as_ref()
.ok_or_else(|| "Browser not initialized".to_string())?;
let (page, context_id) = match self.session_isolation {
SessionIsolation::Page => (
browser
.new_page("about:blank")
.await
.map_err(|e| format!("Failed to create new page: {}", e))?,
None,
),
SessionIsolation::BrowserContext => {
let context_id = browser
.create_browser_context(Default::default())
.await
.map_err(|e| format!("Failed to create isolated browser context: {}", e))?;
debug!("Created isolated browser context for session");
let context_id_str = context_id.as_ref().to_string();
let params = CreateTargetParams::builder()
.url("about:blank")
.browser_context_id(context_id)
.build()
.map_err(|e| format!("Failed to build isolated page parameters: {}", e))?;
let page = browser
.new_page(params)
.await
.map_err(|e| format!("Failed to create new isolated page: {}", e))?;
(page, Some(context_id_str))
}
};
let target_id = page.target_id().as_ref().to_string();
Ok((target_id, context_id, Arc::new(page)))
}
async fn abort_teardown(&self, guard: &mut Option<Browser>) {
*guard = None;
self.handler_alive.store(false, Ordering::SeqCst);
let mut handle_guard = self.browser_handle.lock().await;
if let Some(handle) = handle_guard.take() {
handle.abort();
}
*self.connected_to_existing.lock().await = false;
}
async fn graceful_teardown(&self) -> String {
let mut guard = self.browser.lock().await;
if guard.is_none() {
return "No browser session was active.".to_string();
}
let was_connected = *self.connected_to_existing.lock().await;
if was_connected {
self.abort_teardown(&mut guard).await;
info!("Disconnected from existing Chrome (browser still running)");
return "Disconnected from Chrome (your browser is still running).".to_string();
}
let mut browser = guard.take().expect("guard is Some (checked above)");
let graceful = tokio::time::timeout(SHUTDOWN_TIMEOUT, async {
browser.close().await.map_err(|e| e.to_string())?;
browser.wait().await.map_err(|e| e.to_string())?;
Ok::<(), String>(())
})
.await;
match graceful {
Ok(Ok(())) => info!("Browser closed gracefully"),
Ok(Err(e)) => warn!(error = %e, "graceful browser close failed; forcing teardown"),
Err(_) => warn!("graceful browser close timed out; forcing teardown"),
}
self.abort_teardown(&mut guard).await;
info!("Browser session closed");
"Browser session closed.".to_string()
}
fn spawn_supervised_handler<H>(&self, mut handler: H) -> tokio::task::JoinHandle<()>
where
H: futures::Stream + Send + Unpin + 'static,
{
let alive = Arc::clone(&self.handler_alive);
alive.store(true, Ordering::SeqCst);
tokio::spawn(async move {
while handler.next().await.is_some() {}
alive.store(false, Ordering::SeqCst);
warn!("browser CDP handler exited — connection is no longer healthy");
})
}
async fn launch_or_connect(&self, guard: &mut Option<Browser>) -> Result<(), String> {
if let Some(port) = self.config.remote_debugging_port {
let url = format!("http://127.0.0.1:{}", port);
info!(port, "Connecting to existing Chrome instance");
let (browser, handler) = Browser::connect(&url).await.map_err(|e| {
format!(
"Failed to connect to Chrome on port {}. \
Make sure Chrome is running with: --remote-debugging-port={}\n\
Error: {}",
port, port, e
)
})?;
let handle = self.spawn_supervised_handler(handler);
info!(
port,
"Connected to existing Chrome — sharing login sessions"
);
*guard = Some(browser);
*self.connected_to_existing.lock().await = true;
let mut handle_guard = self.browser_handle.lock().await;
*handle_guard = Some(handle);
return Ok(());
}
let mut builder = ChromeBrowserConfig::builder();
let want_headless = self.headless.load(Ordering::Relaxed);
let use_headless = if !want_headless && !Self::has_display() {
warn!("No display available — falling back to headless mode");
true
} else {
want_headless
};
if use_headless {
builder = builder.arg("--headless=new");
}
if let Some(ref user_data_dir) = self.config.user_data_dir {
let expanded = shellexpand::tilde(user_data_dir);
builder = builder.arg(format!("--user-data-dir={}", expanded));
let profile = self.config.profile.as_deref().unwrap_or("Default");
builder = builder.arg(format!("--profile-directory={}", profile));
info!(
user_data_dir = %expanded,
profile,
"Using existing Chrome profile"
);
}
builder = builder
.arg(format!(
"--window-size={},{}",
self.config.screenshot_width, self.config.screenshot_height
))
.arg("--no-first-run")
.arg("--no-default-browser-check")
.arg("--disable-gpu")
.arg("--disable-dev-shm-usage")
.arg("--disable-blink-features=AutomationControlled")
.arg("--disable-features=AutomationControlled");
let browser_config = builder.build().map_err(|e| {
format!(
"Failed to build browser config: {}. Is Chrome/Chromium installed?",
e
)
})?;
let (browser, handler) = Browser::launch(browser_config).await.map_err(|e| {
format!(
"Failed to launch browser: {}. Make sure Chrome or Chromium is installed.",
e
)
})?;
let handle = self.spawn_supervised_handler(handler);
info!("Browser launched successfully");
*guard = Some(browser);
*self.connected_to_existing.lock().await = false;
let mut handle_guard = self.browser_handle.lock().await;
*handle_guard = Some(handle);
Ok(())
}
}
#[async_trait]
impl BrowserBackend for ChromiumoxideBackend {
async fn ensure_ready(&self) -> Result<(), String> {
let mut guard = self.browser.lock().await;
if guard.is_some() && self.handler_alive.load(Ordering::SeqCst) {
return Ok(());
}
if guard.is_some() {
warn!("browser handler is dead — dropping stale connection and reconnecting");
*guard = None;
let mut handle_guard = self.browser_handle.lock().await;
if let Some(handle) = handle_guard.take() {
handle.abort();
}
}
self.launch_or_connect(&mut guard).await
}
async fn create_page(&self) -> Result<(String, Option<String>, Arc<dyn PageHandle>), String> {
let (target_id, context_id, page) = self.new_blank_page().await?;
Ok((target_id, context_id, Arc::new(ChromiumoxidePage { page })))
}
async fn shutdown(&self) -> Result<String, String> {
Ok(self.graceful_teardown().await)
}
async fn dispose_session(&self, tab_target_ids: &[String], context_ids: &[String]) {
if self.browser.lock().await.is_none() {
return;
}
for target_id in tab_target_ids {
match tokio::time::timeout(SHUTDOWN_TIMEOUT, self.close_target(target_id)).await {
Ok(Ok(())) => {}
Ok(Err(e)) => warn!(target_id, error = %e, "dispose_session: close_target failed"),
Err(_) => warn!(target_id, "dispose_session: close_target timed out"),
}
}
for context_id in context_ids {
let guard = self.browser.lock().await;
let Some(browser) = guard.as_ref() else {
return;
};
match tokio::time::timeout(
SHUTDOWN_TIMEOUT,
browser.dispose_browser_context(context_id.clone()),
)
.await
{
Ok(Ok(())) => debug!(context_id, "disposed isolated browser context"),
Ok(Err(e)) => {
warn!(context_id, error = %e, "dispose_session: dispose_browser_context failed")
}
Err(_) => warn!(
context_id,
"dispose_session: dispose_browser_context timed out"
),
}
}
}
async fn set_headless_mode(&self, headless: bool, mode: &str) -> Result<String, String> {
let new_headless = headless;
let old_headless = self.headless.load(Ordering::Relaxed);
if old_headless == new_headless {
return Ok(format!("Browser is already in {} mode.", mode));
}
if !new_headless && !Self::has_display() {
return Ok(
"No display available on this system. Visible mode requires a monitor, \
VNC, or X forwarding (ssh -X). Staying in headless mode."
.to_string(),
);
}
self.headless.store(new_headless, Ordering::Relaxed);
let had_browser = self.browser.lock().await.is_some();
if had_browser {
self.graceful_teardown().await;
info!(mode, "Browser mode changed, restarting on next use");
Ok(format!(
"Switched to {} mode. Browser will restart on next action.",
mode
))
} else {
info!(mode, "Browser mode changed");
Ok(format!("Switched to {} mode.", mode))
}
}
async fn list_targets(&self) -> Result<Vec<TargetInfo>, String> {
let guard = self.browser.lock().await;
let browser = guard
.as_ref()
.ok_or_else(|| "Browser not initialized".to_string())?;
let returns = browser
.execute(GetTargetsParams::builder().build())
.await
.map_err(|e| format!("Failed to list browser tabs: {}", e))?;
let infos = returns
.result
.target_infos
.into_iter()
.filter(|t| t.r#type == "page")
.map(|t| {
let title = (!t.title.is_empty()).then(|| t.title.clone());
let url = (!t.url.is_empty()).then(|| t.url.clone());
TargetInfo {
target_id: String::from(t.target_id),
title,
url,
opener_id: t.opener_id.map(String::from),
}
})
.collect();
Ok(infos)
}
async fn page_for_target(&self, target_id: &str) -> Result<Arc<dyn PageHandle>, String> {
let guard = self.browser.lock().await;
let browser = guard
.as_ref()
.ok_or_else(|| "Browser not initialized".to_string())?;
let page = browser
.get_page(TargetId::new(target_id.to_string()))
.await
.map_err(|e| format!("Tab '{}' not found: {}", target_id, e))?;
Ok(Arc::new(ChromiumoxidePage {
page: Arc::new(page),
}))
}
async fn close_target(&self, target_id: &str) -> Result<(), String> {
let guard = self.browser.lock().await;
let browser = guard
.as_ref()
.ok_or_else(|| "Browser not initialized".to_string())?;
browser
.execute(CloseTargetParams::new(TargetId::new(target_id.to_string())))
.await
.map_err(|e| format!("Failed to close tab '{}': {}", target_id, e))?;
Ok(())
}
async fn is_connected(&self) -> bool {
self.browser.lock().await.is_some() && self.handler_alive.load(Ordering::SeqCst)
}
async fn reconnect(&self) -> Result<(), String> {
let mut guard = self.browser.lock().await;
*guard = None;
self.handler_alive.store(false, Ordering::SeqCst);
{
let mut handle_guard = self.browser_handle.lock().await;
if let Some(handle) = handle_guard.take() {
handle.abort();
}
}
info!("reconnecting browser after a connection-class failure");
self.launch_or_connect(&mut guard).await
}
}
struct ChromiumoxidePage {
page: Arc<chromiumoxide::Page>,
}
#[async_trait]
impl PageHandle for ChromiumoxidePage {
async fn goto(&self, url: &str) -> Result<(), String> {
self.page
.goto(url)
.await
.map_err(|e| format!("Failed to navigate to {}: {}", url, e))?;
Ok(())
}
async fn find_element(&self, selector: &str) -> Result<(), String> {
self.page
.find_element(selector)
.await
.map_err(|e| format!("Element not found '{}': {}", selector, e))?;
Ok(())
}
async fn wait_for_navigation(&self, timeout: Duration) -> Result<(), String> {
match tokio::time::timeout(timeout, self.page.wait_for_navigation()).await {
Ok(Ok(_)) => Ok(()),
Ok(Err(e)) => Err(format!("Navigation wait failed: {}", e)),
Err(_) => {
debug!("wait_for_navigation local-timeout; proceeding best-effort (next call's health gate catches a dead CDP)");
Ok(())
}
}
}
async fn is_element_visible(&self, selector: &str) -> Result<bool, String> {
let element = match self.page.find_element(selector).await {
Ok(el) => el,
Err(_) => return Ok(false),
};
let ret = element
.call_js_fn(
"function() { \
const s = window.getComputedStyle(this); \
if (s.display === 'none' || s.visibility === 'hidden' || s.opacity === '0') return false; \
const r = this.getClientRects().length > 0; \
return (this.offsetParent !== null || r); \
}",
false,
)
.await
.map_err(|e| format!("Failed to check visibility of '{}': {}", selector, e))?;
Ok(ret.result.value.and_then(|v| v.as_bool()).unwrap_or(false))
}
async fn is_element_enabled(&self, selector: &str) -> Result<bool, String> {
let element = match self.page.find_element(selector).await {
Ok(el) => el,
Err(_) => return Ok(false),
};
let ret = element
.call_js_fn("function() { return !this.disabled; }", false)
.await
.map_err(|e| format!("Failed to check enabled state of '{}': {}", selector, e))?;
Ok(ret.result.value.and_then(|v| v.as_bool()).unwrap_or(false))
}
async fn click(&self, selector: &str) -> Result<(), String> {
let element = self
.page
.find_element(selector)
.await
.map_err(|e| format!("Element not found '{}': {}", selector, e))?;
element
.click()
.await
.map_err(|e| format!("Failed to click '{}': {}", selector, e))?;
Ok(())
}
async fn type_text(&self, selector: &str, value: &str) -> Result<(), String> {
let element = self
.page
.find_element(selector)
.await
.map_err(|e| format!("Element not found '{}': {}", selector, e))?;
element
.click()
.await
.map_err(|e| format!("Failed to focus '{}': {}", selector, e))?;
element
.type_str(value)
.await
.map_err(|e| format!("Failed to type into '{}': {}", selector, e))?;
Ok(())
}
async fn replace_text(&self, selector: &str, value: &str) -> Result<(), String> {
let element = self
.page
.find_element(selector)
.await
.map_err(|e| format!("Element not found '{}': {}", selector, e))?;
element
.focus()
.await
.map_err(|e| format!("Failed to focus '{}': {}", selector, e))?;
element
.call_js_fn(
"function() { \
this.value = ''; \
this.dispatchEvent(new Event('input', {bubbles: true})); \
}",
false,
)
.await
.map_err(|e| format!("Failed to clear '{}': {}", selector, e))?;
element
.type_str(value)
.await
.map_err(|e| format!("Failed to type into '{}': {}", selector, e))?;
element
.call_js_fn(
"function() { \
this.dispatchEvent(new Event('change', {bubbles: true})); \
}",
false,
)
.await
.map_err(|e| format!("Failed to dispatch change on '{}': {}", selector, e))?;
Ok(())
}
async fn evaluate(&self, script: &str) -> Result<Option<serde_json::Value>, String> {
let result = self
.page
.evaluate(script)
.await
.map_err(|e| format!("JavaScript execution failed: {}", e))?;
Ok(result.into_value::<serde_json::Value>().ok())
}
async fn inner_text(&self, selector: &str) -> Result<String, String> {
let element = self
.page
.find_element(selector)
.await
.map_err(|e| format!("Element not found '{}': {}", selector, e))?;
Ok(element
.inner_text()
.await
.map_err(|e| format!("Failed to get text from '{}': {}", selector, e))?
.unwrap_or_else(|| "(could not extract text)".to_string()))
}
async fn body_text(&self) -> Result<String, String> {
let result = self
.page
.evaluate("document.body.innerText")
.await
.map_err(|e| format!("Failed to get page text: {}", e))?;
Ok(result
.into_value::<String>()
.unwrap_or_else(|_| "(could not extract text)".to_string()))
}
async fn screenshot(&self, selector: Option<&str>, full_page: bool) -> Result<Vec<u8>, String> {
if let Some(selector) = selector {
let element = self
.page
.find_element(selector)
.await
.map_err(|e| format!("Element not found '{}': {}", selector, e))?;
element
.screenshot(CaptureScreenshotFormat::Png)
.await
.map_err(|e| format!("Failed to screenshot element: {}", e))
} else {
self.page
.screenshot(ScreenshotParams::builder().full_page(full_page).build())
.await
.map_err(|e| format!("Failed to take screenshot: {}", e))
}
}
async fn url(&self) -> Option<String> {
self.page.url().await.ok().flatten()
}
async fn attach_diagnostics(
&self,
session_id: &str,
tab_id: &str,
store: BrowserDiagnosticsStore,
) {
use chromiumoxide::cdp::browser_protocol::network::EventLoadingFailed;
use chromiumoxide::cdp::js_protocol::runtime::EventConsoleApiCalled;
let session_id = session_id.to_string();
let tab_id = tab_id.to_string();
let page = Arc::clone(&self.page);
if let Ok(mut stream) = page.event_listener::<EventConsoleApiCalled>().await {
let store = store.clone();
let session_id = session_id.clone();
let tab_id = tab_id.clone();
tokio::spawn(async move {
while let Some(event) = stream.next().await {
let level = format!("{:?}", event.r#type);
let message = format_console_api_args(&event.args);
store
.record_console(&session_id, &tab_id, &level, &message)
.await;
}
});
}
if let Ok(mut stream) = page.event_listener::<EventLoadingFailed>().await {
let page_for_url = Arc::clone(&page);
tokio::spawn(async move {
while let Some(event) = stream.next().await {
let url = page_for_url.url().await.ok().flatten().unwrap_or_default();
let detail = format!("{:?}: {}", event.r#type, event.error_text);
store
.record_network_error(&session_id, &tab_id, &url, &detail)
.await;
}
});
}
}
}
fn format_console_api_args(
args: &[chromiumoxide::cdp::js_protocol::runtime::RemoteObject],
) -> String {
args.iter()
.map(|arg| {
if let Some(v) = &arg.value {
v.as_str()
.map(|s| s.to_string())
.unwrap_or_else(|| v.to_string())
} else if let Some(desc) = &arg.description {
desc.clone()
} else {
"(object)".to_string()
}
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MockCall {
EnsureReady,
CreatePage(String),
Shutdown {
graceful: bool,
},
SetHeadlessMode(bool),
CloseTarget(String),
DisposeContext(String),
Goto(String),
FindElement(String),
Click(String),
TypeText(String, String),
ReplaceText(String, String),
Evaluate(String),
InnerText(String),
BodyText,
Screenshot(Option<String>, bool),
Url,
Reconnect,
WaitForNavigation,
IsVisible(String),
IsEnabled(String),
}
#[cfg(test)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FailOp {
BodyText,
InnerText,
Click,
ReplaceText,
}
#[cfg(test)]
pub struct MockBackend {
calls: Arc<Mutex<Vec<MockCall>>>,
eval_result: Option<serde_json::Value>,
text_result: String,
screenshot_bytes: Vec<u8>,
url: Option<String>,
connected: AtomicBool,
next_page_id: Mutex<u64>,
targets: Arc<Mutex<Vec<TargetInfo>>>,
pending_popup: Arc<Mutex<Option<TargetInfo>>>,
handler_alive: Arc<AtomicBool>,
pending_fail: Arc<Mutex<Option<FailOp>>>,
isolate_contexts: bool,
attached: bool,
graceful_fails: bool,
headless: AtomicBool,
click_navigates: Arc<AtomicBool>,
nav_never_settles: Arc<AtomicBool>,
element_state: Arc<Mutex<MockElementState>>,
diagnostic_console: Arc<Mutex<Vec<(String, String)>>>,
diagnostic_network: Arc<Mutex<Vec<(String, String)>>>,
}
#[cfg(test)]
#[derive(Debug, Default, Clone)]
pub struct MockElementState {
pub present_after: Option<u64>,
pub visible_after: Option<u64>,
pub enabled_after: Option<u64>,
pub hidden_after: Option<u64>,
pub text: Option<String>,
pub text_after: Option<u64>,
}
#[cfg(test)]
impl MockElementState {
fn tick(slot: &mut Option<u64>) -> bool {
match slot {
None => true,
Some(0) => true,
Some(n) => {
*n = n.saturating_sub(1);
false
}
}
}
}
#[cfg(test)]
impl Default for MockBackend {
fn default() -> Self {
Self {
calls: Arc::new(Mutex::new(Vec::new())),
eval_result: Some(serde_json::json!("mock-eval")),
text_result: "mock text".to_string(),
screenshot_bytes: vec![0x89, 0x50, 0x4e, 0x47],
url: Some("https://mock.example/".to_string()),
connected: AtomicBool::new(false),
next_page_id: Mutex::new(0),
targets: Arc::new(Mutex::new(Vec::new())),
pending_popup: Arc::new(Mutex::new(None)),
handler_alive: Arc::new(AtomicBool::new(true)),
pending_fail: Arc::new(Mutex::new(None)),
isolate_contexts: false,
attached: false,
graceful_fails: false,
headless: AtomicBool::new(true),
click_navigates: Arc::new(AtomicBool::new(false)),
nav_never_settles: Arc::new(AtomicBool::new(false)),
element_state: Arc::new(Mutex::new(MockElementState::default())),
diagnostic_console: Arc::new(Mutex::new(Vec::new())),
diagnostic_network: Arc::new(Mutex::new(Vec::new())),
}
}
}
#[cfg(test)]
impl MockBackend {
pub fn new() -> Self {
Self::default()
}
pub fn with_eval_result(mut self, eval_result: Option<serde_json::Value>) -> Self {
self.eval_result = eval_result;
self
}
pub fn with_text_result(mut self, text: impl Into<String>) -> Self {
self.text_result = text.into();
self
}
pub fn with_screenshot_bytes(mut self, bytes: Vec<u8>) -> Self {
self.screenshot_bytes = bytes;
self
}
pub fn with_url(mut self, url: impl Into<String>) -> Self {
self.url = Some(url.into());
self
}
pub fn with_console_logs(mut self, logs: Vec<(impl Into<String>, impl Into<String>)>) -> Self {
self.diagnostic_console = Arc::new(Mutex::new(
logs.into_iter()
.map(|(level, msg)| (level.into(), msg.into()))
.collect(),
));
self
}
pub fn with_network_errors(
mut self,
errors: Vec<(impl Into<String>, impl Into<String>)>,
) -> Self {
self.diagnostic_network = Arc::new(Mutex::new(
errors
.into_iter()
.map(|(url, err)| (url.into(), err.into()))
.collect(),
));
self
}
pub fn with_isolated_contexts(mut self) -> Self {
self.isolate_contexts = true;
self
}
pub fn attached(mut self) -> Self {
self.attached = true;
self
}
pub fn with_element_state(mut self, state: MockElementState) -> Self {
self.element_state = Arc::new(Mutex::new(state));
self
}
pub fn with_click_navigates(self) -> Self {
self.click_navigates.store(true, Ordering::SeqCst);
self
}
pub fn with_nav_never_settles(self) -> Self {
self.nav_never_settles.store(true, Ordering::SeqCst);
self
}
pub fn with_failing_graceful_close(mut self) -> Self {
self.graceful_fails = true;
self
}
pub fn calls(&self) -> Arc<Mutex<Vec<MockCall>>> {
Arc::clone(&self.calls)
}
pub async fn script_popup(&self, target_id: &str, title: &str, url: &str) {
self.script_popup_with_opener(target_id, title, url, None)
.await;
}
pub async fn script_popup_with_opener(
&self,
target_id: &str,
title: &str,
url: &str,
opener_id: Option<&str>,
) {
*self.pending_popup.lock().await = Some(TargetInfo {
target_id: target_id.to_string(),
title: Some(title.to_string()),
url: Some(url.to_string()),
opener_id: opener_id.map(|s| s.to_string()),
});
}
pub fn mark_handler_dead(&self) {
self.handler_alive.store(false, Ordering::SeqCst);
}
pub async fn fail_once_with_connection_error_on(&self, op: FailOp) {
*self.pending_fail.lock().await = Some(op);
}
pub async fn reconnect_count(&self) -> usize {
self.calls
.lock()
.await
.iter()
.filter(|c| matches!(c, MockCall::Reconnect))
.count()
}
async fn record(&self, call: MockCall) {
self.calls.lock().await.push(call);
}
}
#[cfg(test)]
const MOCK_CONNECTION_ERROR: &str = "WebSocket connection closed: Sender was dropped";
#[cfg(test)]
struct MockPage {
#[allow(dead_code)]
page_id: String,
calls: Arc<Mutex<Vec<MockCall>>>,
eval_result: Option<serde_json::Value>,
text_result: String,
screenshot_bytes: Vec<u8>,
url: Option<String>,
targets: Arc<Mutex<Vec<TargetInfo>>>,
pending_popup: Arc<Mutex<Option<TargetInfo>>>,
pending_fail: Arc<Mutex<Option<FailOp>>>,
element_state: Arc<Mutex<MockElementState>>,
click_navigates: Arc<AtomicBool>,
nav_never_settles: Arc<AtomicBool>,
diagnostic_console: Arc<Mutex<Vec<(String, String)>>>,
diagnostic_network: Arc<Mutex<Vec<(String, String)>>>,
}
#[cfg(test)]
impl MockPage {
async fn record(&self, call: MockCall) {
self.calls.lock().await.push(call);
}
async fn take_scripted_failure(&self, op: FailOp) -> Option<String> {
let mut slot = self.pending_fail.lock().await;
if *slot == Some(op) {
*slot = None;
return Some(MOCK_CONNECTION_ERROR.to_string());
}
None
}
}
#[cfg(test)]
#[async_trait]
impl PageHandle for MockPage {
async fn goto(&self, url: &str) -> Result<(), String> {
self.record(MockCall::Goto(url.to_string())).await;
Ok(())
}
async fn find_element(&self, selector: &str) -> Result<(), String> {
self.record(MockCall::FindElement(selector.to_string()))
.await;
let satisfied = {
let mut st = self.element_state.lock().await;
MockElementState::tick(&mut st.present_after)
};
if satisfied {
Ok(())
} else {
Err(format!("Element not found '{}': not present yet", selector))
}
}
async fn wait_for_navigation(&self, timeout: Duration) -> Result<(), String> {
self.record(MockCall::WaitForNavigation).await;
if self.nav_never_settles.load(Ordering::SeqCst) {
tokio::time::sleep(timeout).await;
Ok(())
} else {
Ok(())
}
}
async fn is_element_visible(&self, selector: &str) -> Result<bool, String> {
self.record(MockCall::IsVisible(selector.to_string())).await;
let mut st = self.element_state.lock().await;
if st.hidden_after.is_some() {
return Ok(!MockElementState::tick(&mut st.hidden_after));
}
Ok(MockElementState::tick(&mut st.visible_after))
}
async fn is_element_enabled(&self, selector: &str) -> Result<bool, String> {
self.record(MockCall::IsEnabled(selector.to_string())).await;
let mut st = self.element_state.lock().await;
Ok(MockElementState::tick(&mut st.enabled_after))
}
async fn click(&self, selector: &str) -> Result<(), String> {
self.record(MockCall::Click(selector.to_string())).await;
if let Some(err) = self.take_scripted_failure(FailOp::Click).await {
return Err(err);
}
if let Some(popup) = self.pending_popup.lock().await.take() {
self.targets.lock().await.push(popup);
}
Ok(())
}
async fn type_text(&self, selector: &str, value: &str) -> Result<(), String> {
self.record(MockCall::TypeText(selector.to_string(), value.to_string()))
.await;
Ok(())
}
async fn replace_text(&self, selector: &str, value: &str) -> Result<(), String> {
self.record(MockCall::ReplaceText(
selector.to_string(),
value.to_string(),
))
.await;
if let Some(err) = self.take_scripted_failure(FailOp::ReplaceText).await {
return Err(err);
}
Ok(())
}
async fn evaluate(&self, script: &str) -> Result<Option<serde_json::Value>, String> {
self.record(MockCall::Evaluate(script.to_string())).await;
Ok(self.eval_result.clone())
}
async fn inner_text(&self, selector: &str) -> Result<String, String> {
self.record(MockCall::InnerText(selector.to_string())).await;
if let Some(err) = self.take_scripted_failure(FailOp::InnerText).await {
return Err(err);
}
let mut st = self.element_state.lock().await;
if st.text.is_some() {
let ready = MockElementState::tick(&mut st.text_after);
return Ok(if ready {
st.text.clone().unwrap_or_default()
} else {
String::new()
});
}
Ok(self.text_result.clone())
}
async fn body_text(&self) -> Result<String, String> {
self.record(MockCall::BodyText).await;
if let Some(err) = self.take_scripted_failure(FailOp::BodyText).await {
return Err(err);
}
Ok(self.text_result.clone())
}
async fn screenshot(&self, selector: Option<&str>, full_page: bool) -> Result<Vec<u8>, String> {
self.record(MockCall::Screenshot(
selector.map(|s| s.to_string()),
full_page,
))
.await;
Ok(self.screenshot_bytes.clone())
}
async fn url(&self) -> Option<String> {
self.record(MockCall::Url).await;
self.url.clone()
}
async fn attach_diagnostics(
&self,
session_id: &str,
tab_id: &str,
store: BrowserDiagnosticsStore,
) {
for (level, message) in self.diagnostic_console.lock().await.iter() {
store
.record_console(session_id, tab_id, level, message)
.await;
}
for (url, error) in self.diagnostic_network.lock().await.iter() {
store
.record_network_error(session_id, tab_id, url, error)
.await;
}
}
}
#[cfg(test)]
#[async_trait]
impl BrowserBackend for MockBackend {
async fn ensure_ready(&self) -> Result<(), String> {
self.record(MockCall::EnsureReady).await;
self.connected.store(true, Ordering::Relaxed);
self.handler_alive.store(true, Ordering::SeqCst);
Ok(())
}
async fn create_page(&self) -> Result<(String, Option<String>, Arc<dyn PageHandle>), String> {
let page_id = {
let mut counter = self.next_page_id.lock().await;
*counter += 1;
format!("mock-page-{}", *counter)
};
self.record(MockCall::CreatePage(page_id.clone())).await;
let context_id = self
.isolate_contexts
.then(|| format!("mock-ctx-{}", page_id));
self.targets.lock().await.push(TargetInfo {
target_id: page_id.clone(),
title: Some("mock tab".to_string()),
url: self.url.clone(),
opener_id: None,
});
Ok((
page_id.clone(),
context_id,
Arc::new(MockPage {
page_id,
calls: Arc::clone(&self.calls),
eval_result: self.eval_result.clone(),
text_result: self.text_result.clone(),
screenshot_bytes: self.screenshot_bytes.clone(),
url: self.url.clone(),
targets: Arc::clone(&self.targets),
pending_popup: Arc::clone(&self.pending_popup),
pending_fail: Arc::clone(&self.pending_fail),
element_state: Arc::clone(&self.element_state),
click_navigates: Arc::clone(&self.click_navigates),
nav_never_settles: Arc::clone(&self.nav_never_settles),
diagnostic_console: Arc::clone(&self.diagnostic_console),
diagnostic_network: Arc::clone(&self.diagnostic_network),
}),
))
}
async fn shutdown(&self) -> Result<String, String> {
let graceful = !self.attached && !self.graceful_fails;
self.record(MockCall::Shutdown { graceful }).await;
self.connected.store(false, Ordering::Relaxed);
self.handler_alive.store(false, Ordering::SeqCst);
if self.attached {
Ok("Disconnected from Chrome (your browser is still running).".to_string())
} else {
Ok("Browser session closed.".to_string())
}
}
async fn dispose_session(&self, tab_target_ids: &[String], context_ids: &[String]) {
for target_id in tab_target_ids {
self.record(MockCall::CloseTarget(target_id.clone())).await;
self.targets
.lock()
.await
.retain(|t| &t.target_id != target_id);
}
for context_id in context_ids {
self.record(MockCall::DisposeContext(context_id.clone()))
.await;
}
}
async fn set_headless_mode(&self, headless: bool, mode: &str) -> Result<String, String> {
self.record(MockCall::SetHeadlessMode(headless)).await;
let old = self.headless.swap(headless, Ordering::Relaxed);
let changed = old != headless;
if changed && self.connected.load(Ordering::Relaxed) {
let graceful = !self.attached && !self.graceful_fails;
self.record(MockCall::Shutdown { graceful }).await;
self.connected.store(false, Ordering::Relaxed);
self.handler_alive.store(false, Ordering::SeqCst);
}
Ok(format!("Switched to {} mode.", mode))
}
async fn list_targets(&self) -> Result<Vec<TargetInfo>, String> {
Ok(self.targets.lock().await.clone())
}
async fn page_for_target(&self, target_id: &str) -> Result<Arc<dyn PageHandle>, String> {
let known = self
.targets
.lock()
.await
.iter()
.any(|t| t.target_id == target_id);
if !known {
return Err(format!("Tab '{}' not found", target_id));
}
Ok(Arc::new(MockPage {
page_id: target_id.to_string(),
calls: Arc::clone(&self.calls),
eval_result: self.eval_result.clone(),
text_result: self.text_result.clone(),
screenshot_bytes: self.screenshot_bytes.clone(),
url: self.url.clone(),
targets: Arc::clone(&self.targets),
pending_popup: Arc::clone(&self.pending_popup),
pending_fail: Arc::clone(&self.pending_fail),
element_state: Arc::clone(&self.element_state),
click_navigates: Arc::clone(&self.click_navigates),
nav_never_settles: Arc::clone(&self.nav_never_settles),
diagnostic_console: Arc::clone(&self.diagnostic_console),
diagnostic_network: Arc::clone(&self.diagnostic_network),
}))
}
async fn close_target(&self, target_id: &str) -> Result<(), String> {
self.targets
.lock()
.await
.retain(|t| t.target_id != target_id);
Ok(())
}
async fn is_connected(&self) -> bool {
self.connected.load(Ordering::Relaxed) && self.handler_alive.load(Ordering::SeqCst)
}
async fn reconnect(&self) -> Result<(), String> {
self.record(MockCall::Reconnect).await;
self.connected.store(true, Ordering::Relaxed);
self.handler_alive.store(true, Ordering::SeqCst);
Ok(())
}
}
#[cfg(test)]
mod isolation_tests {
use super::*;
use crate::config::SessionIsolation;
fn ephemeral_config() -> BrowserConfig {
BrowserConfig {
enabled: true,
headless: true,
screenshot_width: 1280,
screenshot_height: 720,
remote_debugging_port: None,
user_data_dir: None,
profile: None,
session_isolation: None,
..BrowserConfig::default()
}
}
#[test]
fn ephemeral_auto_resolves_to_browser_context() {
match ChromiumoxideBackend::new(ephemeral_config()) {
Ok(backend) => assert_eq!(
backend.session_isolation(),
SessionIsolation::BrowserContext
),
Err(e) => panic!("ephemeral config must construct: {e}"),
}
}
#[test]
fn profile_auto_resolves_to_page() {
let mut config = ephemeral_config();
config.user_data_dir = Some("/tmp/profile".to_string());
match ChromiumoxideBackend::new(config) {
Ok(backend) => assert_eq!(backend.session_isolation(), SessionIsolation::Page),
Err(e) => panic!("profile config must construct: {e}"),
}
}
#[test]
fn incompatible_browser_context_with_profile_is_rejected_at_construction() {
let mut config = ephemeral_config();
config.session_isolation = Some(SessionIsolation::BrowserContext);
config.user_data_dir = Some("/tmp/profile".to_string());
match ChromiumoxideBackend::new(config) {
Ok(_) => {
panic!("browser_context + persistent profile must fail fast at construction")
}
Err(err) => assert!(
err.contains("browser_context"),
"construction error should explain the incompatibility: {err}"
),
}
}
}