use base64::Engine;
use chromiumoxide::browser::BrowserConfig;
use chromiumoxide::{Browser, Page};
use futures::StreamExt;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
#[derive(Clone)]
pub struct BrowserManager {
inner: Arc<Mutex<ManagerInner>>,
}
struct ManagerInner {
browser: Option<Browser>,
pages: HashMap<String, Page>,
handler_handle: Option<tokio::task::JoinHandle<()>>,
headless: bool,
}
impl Drop for ManagerInner {
fn drop(&mut self) {
if self.browser.is_none() && self.handler_handle.is_none() {
return; }
self.pages.clear();
self.browser.take();
if let Some(h) = self.handler_handle.take() {
h.abort();
}
tracing::debug!("browser: ManagerInner dropped — handler aborted, CDP closed");
}
}
impl Default for BrowserManager {
fn default() -> Self {
Self::new()
}
}
impl BrowserManager {
pub fn new() -> Self {
let headless = !Self::has_display();
if headless {
tracing::info!("No display detected — browser will run headless");
}
Self::with_headless(headless)
}
pub fn with_headless(headless: bool) -> Self {
Self {
inner: Arc::new(Mutex::new(ManagerInner {
browser: None,
pages: HashMap::new(),
handler_handle: None,
headless,
})),
}
}
pub(crate) fn has_display() -> bool {
if cfg!(target_os = "macos") || cfg!(target_os = "windows") {
true
} else {
std::env::var("DISPLAY").is_ok() || std::env::var("WAYLAND_DISPLAY").is_ok()
}
}
pub async fn set_headless(&self, headless: bool) -> bool {
let mut inner = self.inner.lock().await;
if inner.headless == headless {
return false; }
if !headless && !Self::has_display() {
tracing::warn!("Cannot switch to headed mode — no display detected. Staying headless.");
return false;
}
inner.headless = headless;
inner.pages.clear();
inner.browser.take();
if let Some(handle) = inner.handler_handle.take() {
handle.abort();
}
tracing::info!(
"Browser mode switched to {}",
if headless { "headless" } else { "headed" }
);
true
}
pub async fn is_headless(&self) -> bool {
self.inner.lock().await.headless
}
async fn ensure_browser(&self) -> anyhow::Result<()> {
let mut inner = self.inner.lock().await;
if inner.browser.is_some() && !handler_is_dead(inner.handler_handle.as_ref()) {
return Ok(());
}
if inner.browser.is_some() {
tracing::warn!(
"browser: CDP handler task is dead — tearing down stale Browser handle and relaunching"
);
inner.pages.clear();
inner.browser.take();
if let Some(h) = inner.handler_handle.take() {
h.abort();
}
}
let mode = if inner.headless { "headless" } else { "headed" };
let detected = detect_browser();
let browser_name = detected
.as_ref()
.map(|b| b.name.as_str())
.unwrap_or("Chrome");
tracing::info!("Launching {mode} {browser_name} via CDP...");
let mut builder = BrowserConfig::builder();
builder = builder.no_sandbox().window_size(1280, 720);
if !inner.headless {
builder = builder.with_head();
}
if let Some(ref info) = detected {
builder = builder.chrome_executable(&info.path);
tracing::info!("Using browser: {} at {}", info.name, info.path.display());
}
let native_profile = detected.as_ref().and_then(|b| b.user_data_dir.clone());
let profile_dir = match native_profile {
Some(p) if p.exists() && wait_for_profile_unlock(&p, 10_000).await => p,
_ => {
let fallback = crate::config::opencrabs_home().join("chrome-profile");
if !fallback.exists() {
let _ = std::fs::create_dir_all(&fallback);
}
fallback
}
};
let fallback_root = crate::config::opencrabs_home().join("chrome-profile");
if profile_dir == fallback_root {
clean_stale_locks(&profile_dir);
}
tracing::debug!("Browser profile: {}", profile_dir.display());
builder = builder.user_data_dir(profile_dir);
builder = builder
.arg("--disable-blink-features=AutomationControlled")
.arg("--disable-features=AutomationControlled")
.arg("--disable-infobars")
.arg("--disable-background-timer-throttling")
.arg("--disable-backgrounding-occluded-windows")
.arg("--disable-renderer-backgrounding")
.arg("--disable-ipc-flooding-protection")
.arg("--lang=en-US,en");
let config = builder
.build()
.map_err(|e| anyhow::anyhow!("BrowserConfig error: {e}"))?;
let (browser, mut handler) = Browser::launch(config)
.await
.map_err(|e| anyhow::anyhow!("Failed to launch Chrome: {e}"))?;
let handle = tokio::spawn(async move {
while let Some(event) = handler.next().await {
if event.is_err() {
tracing::warn!("CDP handler error, browser connection may be lost");
break;
}
}
});
inner.browser = Some(browser);
inner.handler_handle = Some(handle);
tracing::info!("{mode} {browser_name} launched successfully");
Ok(())
}
pub async fn get_or_create_session_page(&self, session_id: uuid::Uuid) -> anyhow::Result<Page> {
self.get_or_create_page(Some(&Self::page_name_for_session(session_id)))
.await
}
pub(crate) fn page_name_for_session(session_id: uuid::Uuid) -> String {
format!("session-{}", session_id)
}
pub async fn get_or_create_page(&self, name: Option<&str>) -> anyhow::Result<Page> {
self.ensure_browser().await?;
let session_name = name.unwrap_or("default").to_string();
let mut inner = self.inner.lock().await;
if let Some(page) = inner.pages.get(&session_name) {
return Ok(page.clone());
}
let browser = inner
.browser
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Browser not initialized"))?;
let page = browser
.new_page("about:blank")
.await
.map_err(|e| anyhow::anyhow!("Failed to create page: {e}"))?;
Self::install_stealth_on_new_document(&page).await;
inner.pages.insert(session_name, page.clone());
Ok(page)
}
async fn install_stealth_on_new_document(page: &Page) {
if let Err(e) = page
.add_script_to_evaluate_on_new_document(Some(STEALTH_JS.to_string()))
.await
{
tracing::warn!("Stealth JS injection failed: {e}");
}
}
pub async fn attach_screenshot(
&self,
session_id: uuid::Uuid,
result: &mut crate::brain::tools::ToolResult,
) {
match self.take_screenshot_for_session(session_id).await {
Some(img) => {
result.images.push(img);
result
.metadata
.insert("screenshot".to_string(), "ok".to_string());
}
None => {
result
.output
.push_str("\n\n[screenshot unavailable — the page may not be rendered yet]");
result
.metadata
.insert("screenshot".to_string(), "failed".to_string());
}
}
}
pub async fn take_screenshot_for_session(
&self,
session_id: uuid::Uuid,
) -> Option<(String, String)> {
let key = Self::page_name_for_session(session_id);
let inner = self.inner.lock().await;
let page = inner.pages.get(&key)?;
let bytes = page
.screenshot(
chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotParams::builder()
.format(
chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotFormat::Png,
)
.build(),
)
.await
.ok()?;
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
Some(("image/png".to_string(), b64))
}
#[allow(dead_code)]
pub async fn take_screenshot(&self) -> Option<(String, String)> {
let inner = self.inner.lock().await;
let page = inner.pages.get("default")?;
let bytes = page
.screenshot(
chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotParams::builder()
.format(
chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotFormat::Png,
)
.build(),
)
.await
.ok()?;
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
Some(("image/png".to_string(), b64))
}
pub async fn close_page(&self, name: &str) -> bool {
let mut inner = self.inner.lock().await;
inner.pages.remove(name).is_some()
}
pub async fn list_pages(&self) -> Vec<String> {
let inner = self.inner.lock().await;
inner.pages.keys().cloned().collect()
}
pub async fn shutdown(&self) {
let mut inner = self.inner.lock().await;
inner.pages.clear();
inner.browser.take();
if let Some(handle) = inner.handler_handle.take() {
handle.abort();
}
tracing::info!("Browser shut down");
}
}
struct BrowserInfo {
name: String,
path: PathBuf,
user_data_dir: Option<PathBuf>,
}
struct BrowserCandidate {
name: &'static str,
#[cfg(target_os = "macos")]
bundle_id: &'static str,
#[cfg(target_os = "linux")]
desktop_file: &'static str,
#[cfg(target_os = "windows")]
prog_id: &'static str,
paths: &'static [&'static str],
which_names: &'static [&'static str],
#[cfg(target_os = "macos")]
profile_dir: Option<&'static str>,
#[cfg(target_os = "linux")]
profile_dir: Option<&'static str>,
#[cfg(target_os = "windows")]
profile_dir: Option<&'static str>,
}
fn known_browsers() -> Vec<BrowserCandidate> {
vec![
BrowserCandidate {
name: "Google Chrome",
#[cfg(target_os = "macos")]
bundle_id: "com.google.chrome",
#[cfg(target_os = "linux")]
desktop_file: "google-chrome.desktop",
#[cfg(target_os = "windows")]
prog_id: "ChromeHTML",
paths: if cfg!(target_os = "macos") {
&["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"]
} else if cfg!(target_os = "windows") {
&[
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
]
} else {
&["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome"]
},
which_names: &["google-chrome-stable", "google-chrome"],
#[cfg(target_os = "macos")]
profile_dir: Some("Google/Chrome"),
#[cfg(target_os = "linux")]
profile_dir: Some("google-chrome"),
#[cfg(target_os = "windows")]
profile_dir: Some(r"Google\Chrome\User Data"),
},
BrowserCandidate {
name: "Brave",
#[cfg(target_os = "macos")]
bundle_id: "com.brave.Browser",
#[cfg(target_os = "linux")]
desktop_file: "brave-browser.desktop",
#[cfg(target_os = "windows")]
prog_id: "BraveHTML",
paths: if cfg!(target_os = "macos") {
&["/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"]
} else if cfg!(target_os = "windows") {
&[r"C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe"]
} else {
&[
"/usr/bin/brave-browser",
"/usr/bin/brave",
"/opt/brave.com/brave/brave",
]
},
which_names: &["brave-browser", "brave"],
#[cfg(target_os = "macos")]
profile_dir: Some("BraveSoftware/Brave-Browser"),
#[cfg(target_os = "linux")]
profile_dir: Some("BraveSoftware/Brave-Browser"),
#[cfg(target_os = "windows")]
profile_dir: Some(r"BraveSoftware\Brave-Browser\User Data"),
},
BrowserCandidate {
name: "Microsoft Edge",
#[cfg(target_os = "macos")]
bundle_id: "com.microsoft.edgemac",
#[cfg(target_os = "linux")]
desktop_file: "microsoft-edge.desktop",
#[cfg(target_os = "windows")]
prog_id: "MSEdgeHTM",
paths: if cfg!(target_os = "macos") {
&["/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"]
} else if cfg!(target_os = "windows") {
&[
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
]
} else {
&["/usr/bin/microsoft-edge", "/opt/microsoft/msedge/msedge"]
},
which_names: &["microsoft-edge", "msedge"],
#[cfg(target_os = "macos")]
profile_dir: Some("Microsoft Edge"),
#[cfg(target_os = "linux")]
profile_dir: Some("microsoft-edge"),
#[cfg(target_os = "windows")]
profile_dir: Some(r"Microsoft\Edge\User Data"),
},
BrowserCandidate {
name: "Arc",
#[cfg(target_os = "macos")]
bundle_id: "company.thebrowser.Browser",
#[cfg(target_os = "linux")]
desktop_file: "",
#[cfg(target_os = "windows")]
prog_id: "",
paths: if cfg!(target_os = "macos") {
&["/Applications/Arc.app/Contents/MacOS/Arc"]
} else {
&[]
},
which_names: &[],
#[cfg(target_os = "macos")]
profile_dir: Some("Arc/User Data"),
#[cfg(target_os = "linux")]
profile_dir: None,
#[cfg(target_os = "windows")]
profile_dir: None,
},
BrowserCandidate {
name: "Vivaldi",
#[cfg(target_os = "macos")]
bundle_id: "com.vivaldi.Vivaldi",
#[cfg(target_os = "linux")]
desktop_file: "vivaldi-stable.desktop",
#[cfg(target_os = "windows")]
prog_id: "VivaldiHTM",
paths: if cfg!(target_os = "macos") {
&["/Applications/Vivaldi.app/Contents/MacOS/Vivaldi"]
} else if cfg!(target_os = "windows") {
&[r"C:\Program Files\Vivaldi\Application\vivaldi.exe"]
} else {
&["/usr/bin/vivaldi", "/opt/vivaldi/vivaldi"]
},
which_names: &["vivaldi"],
#[cfg(target_os = "macos")]
profile_dir: Some("Vivaldi"),
#[cfg(target_os = "linux")]
profile_dir: Some("vivaldi"),
#[cfg(target_os = "windows")]
profile_dir: Some(r"Vivaldi\User Data"),
},
BrowserCandidate {
name: "Opera",
#[cfg(target_os = "macos")]
bundle_id: "com.operasoftware.Opera",
#[cfg(target_os = "linux")]
desktop_file: "opera.desktop",
#[cfg(target_os = "windows")]
prog_id: "OperaStable",
paths: if cfg!(target_os = "macos") {
&["/Applications/Opera.app/Contents/MacOS/Opera"]
} else if cfg!(target_os = "windows") {
&[r"C:\Program Files\Opera\launcher.exe"]
} else {
&["/usr/bin/opera"]
},
which_names: &["opera"],
#[cfg(target_os = "macos")]
profile_dir: Some("com.operasoftware.Opera"),
#[cfg(target_os = "linux")]
profile_dir: Some("opera"),
#[cfg(target_os = "windows")]
profile_dir: Some(r"Opera Software\Opera Stable"),
},
BrowserCandidate {
name: "Chromium",
#[cfg(target_os = "macos")]
bundle_id: "org.chromium.Chromium",
#[cfg(target_os = "linux")]
desktop_file: "chromium-browser.desktop",
#[cfg(target_os = "windows")]
prog_id: "ChromiumHTM",
paths: if cfg!(target_os = "macos") {
&["/Applications/Chromium.app/Contents/MacOS/Chromium"]
} else if cfg!(target_os = "windows") {
&[r"C:\Program Files\Chromium\Application\chrome.exe"]
} else {
&["/usr/bin/chromium-browser", "/usr/bin/chromium"]
},
which_names: &["chromium-browser", "chromium"],
#[cfg(target_os = "macos")]
profile_dir: Some("Chromium"),
#[cfg(target_os = "linux")]
profile_dir: Some("chromium"),
#[cfg(target_os = "windows")]
profile_dir: Some(r"Chromium\User Data"),
},
]
}
fn find_executable(candidate: &BrowserCandidate) -> Option<PathBuf> {
for path in candidate.paths {
let p = PathBuf::from(path);
if p.exists() {
return Some(p);
}
}
for name in candidate.which_names {
if let Ok(p) = which::which(name) {
return Some(p);
}
}
None
}
fn resolve_profile_dir(candidate: &BrowserCandidate) -> Option<PathBuf> {
#[cfg(target_os = "macos")]
let base = dirs::home_dir()?.join("Library/Application Support");
#[cfg(target_os = "linux")]
let base = dirs::config_dir()?;
#[cfg(target_os = "windows")]
let base = dirs::data_local_dir()?;
let rel = candidate.profile_dir?;
let dir = base.join(rel);
if dir.exists() { Some(dir) } else { None }
}
fn is_profile_locked(profile_dir: &std::path::Path) -> bool {
let lock = profile_dir.join("SingletonLock");
if lock.exists() {
return true;
}
let lock2 = profile_dir.join("Lock");
if lock2.exists() {
return true;
}
profile_dir.join("SingletonSocket").exists()
}
pub(crate) const LOCK_FILES: &[&str] = &["SingletonLock", "SingletonSocket", "Lock"];
pub(crate) const STEALTH_JS: &str = r#"
// Hide navigator.webdriver
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
// Fake chrome.runtime (present in real Chrome, missing in automation)
if (!window.chrome) { window.chrome = {}; }
if (!window.chrome.runtime) {
window.chrome.runtime = {
connect: function() {},
sendMessage: function() {},
id: undefined
};
}
// Fake plugins array (headless has 0 plugins)
Object.defineProperty(navigator, 'plugins', {
get: () => [
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer' },
{ name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai' },
{ name: 'Native Client', filename: 'internal-nacl-plugin' }
]
});
// Fake languages
Object.defineProperty(navigator, 'languages', {
get: () => ['en-US', 'en']
});
// Remove automation-related properties from navigator
const originalQuery = window.navigator.permissions.query;
window.navigator.permissions.query = (parameters) =>
parameters.name === 'notifications'
? Promise.resolve({ state: Notification.permission })
: originalQuery(parameters);
"#;
pub(crate) fn handler_is_dead(handle: Option<&tokio::task::JoinHandle<()>>) -> bool {
handle.map(|h| h.is_finished()).unwrap_or(true)
}
pub(crate) async fn wait_for_profile_unlock(profile_dir: &std::path::Path, cap_ms: u64) -> bool {
let mut waited_ms: u64 = 0;
let mut delay_ms: u64 = 250;
loop {
if !is_profile_locked(profile_dir) {
return true;
}
if waited_ms >= cap_ms {
tracing::debug!(
"browser: profile {} still locked after {}ms — caller falls back",
profile_dir.display(),
waited_ms
);
return false;
}
let step = delay_ms.min(cap_ms.saturating_sub(waited_ms));
tracing::debug!(
"browser: profile {} locked, retrying in {}ms",
profile_dir.display(),
step
);
tokio::time::sleep(std::time::Duration::from_millis(step)).await;
waited_ms += step;
delay_ms = (delay_ms * 2).min(4000);
}
}
pub(crate) fn clean_stale_locks(profile_dir: &std::path::Path) {
for name in LOCK_FILES {
let path = profile_dir.join(name);
if path.exists()
&& let Err(e) = std::fs::remove_file(&path)
{
tracing::warn!(
"browser: failed to clean stale lock {}: {}",
path.display(),
e
);
}
}
}
#[cfg(target_os = "macos")]
fn detect_default_browser_id() -> Option<String> {
let output = std::process::Command::new("defaults")
.args([
"read",
"com.apple.LaunchServices/com.apple.launchservices.secure",
"LSHandlers",
])
.output()
.ok()?;
let text = String::from_utf8_lossy(&output.stdout);
parse_ls_handlers(&text)
}
#[cfg(target_os = "macos")]
pub(crate) fn parse_ls_handlers(text: &str) -> Option<String> {
fn parse_value(line: &str) -> Option<String> {
let eq = line.find('=')?;
let rest = line[eq + 1..].trim().trim_end_matches(';').trim();
let unquoted = rest.trim_matches('"').trim();
if unquoted.is_empty() {
None
} else {
Some(unquoted.to_string())
}
}
let mut depth: i32 = 0;
let mut block_scheme: Option<String> = None;
let mut block_content_type: Option<String> = None;
let mut block_role: Option<String> = None;
for line in text.lines() {
let trimmed = line.trim();
if depth == 1 {
if trimmed.starts_with("LSHandlerURLScheme") {
block_scheme = parse_value(trimmed);
} else if trimmed.starts_with("LSHandlerContentType") {
block_content_type = parse_value(trimmed);
} else if trimmed.starts_with("LSHandlerRoleAll") {
block_role = parse_value(trimmed);
}
}
depth += trimmed.matches('{').count() as i32;
depth -= trimmed.matches('}').count() as i32;
if depth == 0 {
let scheme = block_scheme.take();
let content_type = block_content_type.take();
let role = block_role.take();
let is_web_default = scheme.as_deref().map(|s| s.eq_ignore_ascii_case("https"))
== Some(true)
|| content_type.as_deref() == Some("com.apple.default-app.web-browser");
if is_web_default
&& let Some(r) = role
&& r != "-"
{
return Some(r.to_lowercase());
}
}
}
None
}
#[cfg(target_os = "linux")]
fn detect_default_browser_id() -> Option<String> {
let output = std::process::Command::new("xdg-settings")
.args(["get", "default-web-browser"])
.output()
.ok()?;
let text = String::from_utf8_lossy(&output.stdout);
parse_xdg_default_browser(&text)
}
#[cfg(target_os = "linux")]
pub(crate) fn parse_xdg_default_browser(text: &str) -> Option<String> {
let trimmed = text.trim().to_lowercase();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
#[cfg(target_os = "windows")]
fn detect_default_browser_id() -> Option<String> {
let output = std::process::Command::new("reg")
.args([
"query",
r"HKEY_CURRENT_USER\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice",
"/v", "ProgId",
])
.output()
.ok()?;
let text = String::from_utf8_lossy(&output.stdout);
parse_windows_reg_prog_id(&text)
}
#[cfg(target_os = "windows")]
pub(crate) fn parse_windows_reg_prog_id(text: &str) -> Option<String> {
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with("ProgId") || trimmed.contains(" ProgId ") {
let parts: Vec<&str> = trimmed.split_whitespace().collect();
if parts.len() >= 3 {
return Some(parts.last().unwrap().to_lowercase());
}
}
}
None
}
fn matches_default(candidate: &BrowserCandidate, default_id: &str) -> bool {
let id = default_id.to_lowercase();
#[cfg(target_os = "macos")]
{
candidate.bundle_id.to_lowercase() == id
}
#[cfg(target_os = "linux")]
{
candidate.desktop_file.to_lowercase() == id
}
#[cfg(target_os = "windows")]
{
candidate.prog_id.to_lowercase() == id
}
}
fn detect_browser() -> Option<BrowserInfo> {
let browsers = known_browsers();
if let Some(default_id) = detect_default_browser_id() {
tracing::debug!("Default browser identifier: {default_id}");
for candidate in &browsers {
if matches_default(candidate, &default_id)
&& let Some(path) = find_executable(candidate)
{
tracing::info!(
"Default browser detected: {} ({})",
candidate.name,
default_id
);
return Some(BrowserInfo {
name: candidate.name.to_string(),
path,
user_data_dir: resolve_profile_dir(candidate),
});
}
}
tracing::debug!("Default browser '{default_id}' is not Chromium-based or not found");
}
for candidate in &browsers {
if let Some(path) = find_executable(candidate) {
tracing::info!("Found Chromium browser: {}", candidate.name);
return Some(BrowserInfo {
name: candidate.name.to_string(),
path,
user_data_dir: resolve_profile_dir(candidate),
});
}
}
tracing::warn!("No Chromium-based browser found on system");
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_manager_new() {
let mgr = BrowserManager::new();
let _ = mgr.clone();
}
#[test]
fn test_manager_with_headless() {
let mgr = BrowserManager::with_headless(false);
let _ = mgr.clone();
}
#[tokio::test]
async fn test_is_headless_default() {
let mgr = BrowserManager::with_headless(true);
assert!(mgr.is_headless().await);
}
#[tokio::test]
async fn test_is_headless_false() {
let mgr = BrowserManager::with_headless(false);
assert!(!mgr.is_headless().await);
}
#[tokio::test]
async fn test_set_headless_no_change() {
let mgr = BrowserManager::with_headless(true);
assert!(!mgr.set_headless(true).await);
}
#[tokio::test]
async fn test_set_headless_switch() {
let mgr = BrowserManager::with_headless(true);
assert!(mgr.is_headless().await);
if BrowserManager::has_display() {
assert!(mgr.set_headless(false).await);
assert!(!mgr.is_headless().await);
assert!(mgr.set_headless(true).await);
assert!(mgr.is_headless().await);
} else {
assert!(!mgr.set_headless(false).await);
assert!(mgr.is_headless().await);
}
}
#[tokio::test]
async fn test_list_pages_empty() {
let mgr = BrowserManager::new();
assert!(mgr.list_pages().await.is_empty());
}
#[tokio::test]
async fn test_close_nonexistent() {
let mgr = BrowserManager::new();
assert!(!mgr.close_page("nonexistent").await);
}
#[test]
fn test_detect_browser_finds_something() {
let result = detect_browser();
if let Some(info) = result {
assert!(!info.name.is_empty());
assert!(info.path.exists());
tracing::info!("Detected: {} at {}", info.name, info.path.display());
}
}
#[test]
fn test_known_browsers_not_empty() {
let browsers = known_browsers();
assert!(browsers.len() >= 7); }
#[test]
fn test_is_profile_locked_nonexistent() {
let dir = std::path::PathBuf::from("/tmp/nonexistent-browser-profile-test");
assert!(!is_profile_locked(&dir));
}
#[test]
fn test_detect_default_browser_id() {
let _id = detect_default_browser_id();
}
}