1. /quit and /help commands restored: - /quit: exits the interactive loop - /help: shows formatted help box with available commands & tips - Unknown commands get a friendly error pointing to /help - Slash commands intercepted before LLM processing (no token waste) 2. Zero warnings across entire workspace (13 files fixed): - Fixed ambiguous glob re-exports in gyandoot-core - Added #[allow(dead_code)] on intentional dead fields - Fixed unused imports, variables, and macros - Fixed unused assignments in subagents - cleaned up service.rs, profile, kanban warnings install.sh now produces a completely clean build with zero warnings.
663 lines
23 KiB
Rust
663 lines
23 KiB
Rust
//! Daemon IPC client for camoufox-browser.
|
|
//!
|
|
//! Communicates with the camoufox-browser daemon via JSON-line protocol
|
|
//! over a Unix socket at `~/.camoufox-browser/<session>.sock`.
|
|
//!
|
|
//! The daemon is started by `camoufox-browser@latest` (npx) which spawns
|
|
//! a background Node.js process that auto-launches the camoufox browser
|
|
//! on the first browser-dependent command.
|
|
|
|
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|
use tokio::net::UnixStream;
|
|
use tracing::{debug, warn};
|
|
|
|
// ─── Constants ──────────────────────────────────────────────────────────
|
|
|
|
/// Default session name.
|
|
pub const DEFAULT_SESSION: &str = "default";
|
|
|
|
/// Default daemon socket directory inside `~/.camoufox-browser/`.
|
|
fn default_socket_dir() -> PathBuf {
|
|
dirs::home_dir()
|
|
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
|
.join(".camoufox-browser")
|
|
}
|
|
|
|
/// Default daemon socket path.
|
|
pub fn default_socket_path(session: &str) -> PathBuf {
|
|
default_socket_dir().join(format!("{}.sock", session))
|
|
}
|
|
|
|
// ─── Error ──────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum BrowserError {
|
|
#[error("IO error: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
|
|
#[error("Daemon error: {0}")]
|
|
Daemon(String),
|
|
|
|
#[error("Socket not found: {0}")]
|
|
SocketNotFound(String),
|
|
|
|
#[error("Daemon response missing 'data' field")]
|
|
MissingData,
|
|
|
|
#[error("Tab not found: {0}")]
|
|
TabNotFound(String),
|
|
|
|
#[error("Navigation failed: {0}")]
|
|
NavigationFailed(String),
|
|
|
|
#[error("Snapshot unavailable")]
|
|
SnapshotUnavailable,
|
|
}
|
|
|
|
pub type BrowserResult<T> = Result<T, BrowserError>;
|
|
|
|
// ─── IPC Protocol Types ─────────────────────────────────────────────────
|
|
|
|
/// A command sent to the daemon as JSON-line.
|
|
#[derive(Debug, Serialize)]
|
|
struct DaemonCommand {
|
|
id: String,
|
|
action: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
url: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
query: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
macro_name: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
wait: Option<u64>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
selector: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
r#ref: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
text: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
press_enter: Option<bool>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
headless: Option<bool>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
index: Option<u32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
direction: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
amount: Option<u32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
interactive: Option<bool>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
compact: Option<bool>,
|
|
}
|
|
|
|
/// A response from the daemon.
|
|
#[derive(Debug, Deserialize)]
|
|
struct DaemonResponse {
|
|
#[allow(dead_code)]
|
|
id: String,
|
|
success: bool,
|
|
#[serde(default)]
|
|
data: Option<Value>,
|
|
#[serde(default)]
|
|
error: Option<String>,
|
|
}
|
|
|
|
/// Health status returned by the daemon.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct HealthResponse {
|
|
pub ok: bool,
|
|
pub engine: String,
|
|
#[serde(default)]
|
|
pub browser_connected: bool,
|
|
#[serde(default)]
|
|
pub browser_running: bool,
|
|
#[serde(default)]
|
|
pub active_tabs: u32,
|
|
#[serde(default)]
|
|
pub active_sessions: u32,
|
|
}
|
|
|
|
/// Navigation result from the daemon.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct NavigationResult {
|
|
pub url: String,
|
|
pub title: String,
|
|
}
|
|
|
|
/// Snapshot of a page (accessibility tree).
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PageSnapshot {
|
|
pub snapshot: String,
|
|
#[serde(default)]
|
|
pub url: String,
|
|
#[serde(default)]
|
|
pub title: String,
|
|
#[serde(default)]
|
|
pub screenshot: Option<String>,
|
|
#[serde(default)]
|
|
pub elements: std::collections::HashMap<String, ElementInfo>,
|
|
}
|
|
|
|
/// Element info in a snapshot.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ElementInfo {
|
|
#[serde(default)]
|
|
pub tag: String,
|
|
#[serde(default)]
|
|
pub text: String,
|
|
#[serde(default)]
|
|
pub href: Option<String>,
|
|
#[serde(default)]
|
|
pub interactive: bool,
|
|
}
|
|
|
|
impl PageSnapshot {
|
|
pub fn extract_refs(&self) -> Vec<String> {
|
|
let mut refs = Vec::new();
|
|
for line in self.snapshot.lines() {
|
|
if let Some(start) = line.find("[e") {
|
|
let after = &line[start + 2..];
|
|
if let Some(end) = after.find(']') {
|
|
let ref_str = line[start..=start + 1 + end].to_string();
|
|
if !refs.contains(&ref_str) {
|
|
refs.push(ref_str);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
refs.sort();
|
|
refs
|
|
}
|
|
}
|
|
|
|
/// Interaction result (click/type).
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct InteractionResult {
|
|
#[serde(default)]
|
|
pub ok: bool,
|
|
#[serde(default)]
|
|
pub snapshot: Option<PageSnapshot>,
|
|
}
|
|
|
|
/// Tab information.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TabInfo {
|
|
pub index: u32,
|
|
pub title: String,
|
|
pub url: String,
|
|
pub active: bool,
|
|
}
|
|
|
|
// ─── BrowserClient ──────────────────────────────────────────────────────
|
|
|
|
/// Client that talks to the camoufox-browser daemon over Unix socket.
|
|
#[derive(Debug, Clone)]
|
|
pub struct BrowserClient {
|
|
/// Session name.
|
|
session: String,
|
|
/// Socket path.
|
|
socket_path: PathBuf,
|
|
/// Timeout for daemon responses.
|
|
timeout: Duration,
|
|
}
|
|
|
|
impl BrowserClient {
|
|
/// Create a new client for the default session.
|
|
pub fn new() -> Self {
|
|
Self {
|
|
session: DEFAULT_SESSION.to_string(),
|
|
socket_path: default_socket_path(DEFAULT_SESSION),
|
|
timeout: Duration::from_secs(30),
|
|
}
|
|
}
|
|
|
|
/// Create a client with a custom session name.
|
|
pub fn with_session(mut self, session: &str) -> Self {
|
|
self.session = session.to_string();
|
|
self.socket_path = default_socket_path(session);
|
|
self
|
|
}
|
|
|
|
/// Create a client with a custom socket path.
|
|
pub fn with_socket(mut self, path: &str) -> Self {
|
|
self.socket_path = PathBuf::from(path);
|
|
self
|
|
}
|
|
|
|
/// Get the socket path.
|
|
pub fn socket_path(&self) -> &PathBuf {
|
|
&self.socket_path
|
|
}
|
|
|
|
/// Get the session name.
|
|
pub fn session(&self) -> &str {
|
|
&self.session
|
|
}
|
|
|
|
// ─── Low-level IPC ──────────────────────────────────────────────
|
|
|
|
/// Send a command to the daemon and receive a response.
|
|
async fn send_command(&self, action: &str, build: impl FnOnce(&mut DaemonCommand)) -> BrowserResult<DaemonResponse> {
|
|
let id = format!("g{}", chrono::Utc::now().timestamp_millis());
|
|
let mut cmd = DaemonCommand {
|
|
id: id.clone(),
|
|
action: action.to_string(),
|
|
url: None,
|
|
query: None,
|
|
macro_name: None,
|
|
wait: None,
|
|
selector: None,
|
|
r#ref: None,
|
|
text: None,
|
|
press_enter: None,
|
|
headless: None,
|
|
index: None,
|
|
direction: None,
|
|
amount: None,
|
|
interactive: None,
|
|
compact: None,
|
|
};
|
|
build(&mut cmd);
|
|
|
|
let payload = serde_json::to_string(&cmd)
|
|
.map_err(|e| BrowserError::Daemon(format!("Serialize command: {}", e)))?;
|
|
|
|
debug!("camoufox IPC → {} (action={})", payload, action);
|
|
|
|
// Connect and send
|
|
let stream = tokio::time::timeout(
|
|
Duration::from_secs(5),
|
|
UnixStream::connect(&self.socket_path),
|
|
)
|
|
.await
|
|
.map_err(|_| BrowserError::SocketNotFound(self.socket_path.display().to_string()))?
|
|
.map_err(BrowserError::Io)?;
|
|
|
|
let (reader, mut writer) = stream.into_split();
|
|
|
|
// Write command
|
|
writer
|
|
.write_all(format!("{}\n", payload).as_bytes())
|
|
.await
|
|
.map_err(BrowserError::Io)?;
|
|
writer.shutdown().await.map_err(BrowserError::Io)?;
|
|
|
|
// Read response
|
|
let mut buf_reader = BufReader::new(reader);
|
|
let mut line = String::new();
|
|
tokio::time::timeout(self.timeout, buf_reader.read_line(&mut line))
|
|
.await
|
|
.map_err(|_| BrowserError::Daemon("Response timeout".to_string()))?
|
|
.map_err(BrowserError::Io)?;
|
|
|
|
if line.is_empty() {
|
|
return Err(BrowserError::Daemon("Empty response from daemon".to_string()));
|
|
}
|
|
|
|
let response: DaemonResponse = serde_json::from_str(line.trim())
|
|
.map_err(|e| BrowserError::Daemon(format!("Parse response: {} — payload: {}", e, line.trim())))?;
|
|
|
|
debug!("camoufox IPC ← {:?}", response);
|
|
|
|
Ok(response)
|
|
}
|
|
|
|
/// Extract data payload from a successful response.
|
|
fn check_response(&self, resp: DaemonResponse) -> BrowserResult<Value> {
|
|
if !resp.success {
|
|
let err = resp.error.unwrap_or_else(|| "Unknown error".to_string());
|
|
return Err(BrowserError::Daemon(err));
|
|
}
|
|
resp.data.ok_or(BrowserError::MissingData)
|
|
}
|
|
|
|
// ─── Health ─────────────────────────────────────────────────────
|
|
|
|
/// Check daemon health by sending a quick snapshot command.
|
|
/// The daemon has no dedicated /health endpoint — we check socket existence + connectivity.
|
|
pub async fn health(&self) -> BrowserResult<HealthResponse> {
|
|
// Check if socket exists
|
|
if !self.socket_path.exists() {
|
|
return Ok(HealthResponse {
|
|
ok: false,
|
|
engine: "camoufox".to_string(),
|
|
browser_connected: false,
|
|
browser_running: false,
|
|
active_tabs: 0,
|
|
active_sessions: 0,
|
|
});
|
|
}
|
|
|
|
// Try a quick "tab_list" command as health check
|
|
match self.send_command("tab_list", |_| {}).await {
|
|
Ok(resp) => {
|
|
match self.check_response(resp) {
|
|
Ok(data) => {
|
|
let tabs = data["tabs"].as_array().map(|a| a.len() as u32).unwrap_or(0);
|
|
Ok(HealthResponse {
|
|
ok: true,
|
|
engine: "camoufox".to_string(),
|
|
browser_connected: true,
|
|
browser_running: true,
|
|
active_tabs: tabs,
|
|
active_sessions: 1,
|
|
})
|
|
}
|
|
Err(e) => {
|
|
warn!("Health check daemon error: {}", e);
|
|
Ok(HealthResponse {
|
|
ok: true,
|
|
engine: "camoufox".to_string(),
|
|
browser_connected: false,
|
|
browser_running: false,
|
|
active_tabs: 0,
|
|
active_sessions: 0,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
debug!("Health check failed: {}", e);
|
|
Ok(HealthResponse {
|
|
ok: false,
|
|
engine: "camoufox".to_string(),
|
|
browser_connected: false,
|
|
browser_running: false,
|
|
active_tabs: 0,
|
|
active_sessions: 0,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Navigation ─────────────────────────────────────────────────
|
|
|
|
/// Navigate to a URL. The daemon auto-launches the browser on first call.
|
|
pub async fn navigate(&self, url: &str) -> BrowserResult<NavigationResult> {
|
|
let resp = self.send_command("navigate", |cmd| {
|
|
cmd.url = Some(url.to_string());
|
|
}).await?;
|
|
let data = self.check_response(resp)?;
|
|
Ok(NavigationResult {
|
|
url: data["url"].as_str().unwrap_or(url).to_string(),
|
|
title: data["title"].as_str().unwrap_or("").to_string(),
|
|
})
|
|
}
|
|
|
|
/// Navigate back.
|
|
pub async fn go_back(&self) -> BrowserResult<NavigationResult> {
|
|
let resp = self.send_command("back", |_| {}).await?;
|
|
let data = self.check_response(resp)?;
|
|
Ok(NavigationResult {
|
|
url: data["url"].as_str().unwrap_or("").to_string(),
|
|
title: "".to_string(),
|
|
})
|
|
}
|
|
|
|
/// Navigate forward.
|
|
pub async fn go_forward(&self) -> BrowserResult<NavigationResult> {
|
|
let resp = self.send_command("forward", |_| {}).await?;
|
|
let data = self.check_response(resp)?;
|
|
Ok(NavigationResult {
|
|
url: data["url"].as_str().unwrap_or("").to_string(),
|
|
title: "".to_string(),
|
|
})
|
|
}
|
|
|
|
/// Refresh the current page.
|
|
pub async fn refresh(&self) -> BrowserResult<NavigationResult> {
|
|
let resp = self.send_command("reload", |_| {}).await?;
|
|
let data = self.check_response(resp)?;
|
|
Ok(NavigationResult {
|
|
url: data["url"].as_str().unwrap_or("").to_string(),
|
|
title: "".to_string(),
|
|
})
|
|
}
|
|
|
|
// ─── Snapshot ───────────────────────────────────────────────────
|
|
|
|
/// Get an accessibility snapshot of the current page.
|
|
pub async fn snapshot(&self, interactive: bool) -> BrowserResult<PageSnapshot> {
|
|
let resp = self.send_command("snapshot", |cmd| {
|
|
cmd.interactive = Some(interactive);
|
|
cmd.compact = Some(false);
|
|
}).await?;
|
|
let data = self.check_response(resp)?;
|
|
|
|
let raw_snapshot = data["snapshot"].as_str().unwrap_or("").to_string();
|
|
Ok(PageSnapshot {
|
|
snapshot: raw_snapshot,
|
|
url: data["url"].as_str().unwrap_or("").to_string(),
|
|
title: data["title"].as_str().unwrap_or("").to_string(),
|
|
screenshot: data["screenshot"].as_str().map(|s| s.to_string()),
|
|
elements: std::collections::HashMap::new(),
|
|
})
|
|
}
|
|
|
|
// ─── Interaction ────────────────────────────────────────────────
|
|
|
|
/// Click an element by its ref (e.g., "e1").
|
|
pub async fn click(&self, element_ref: &str) -> BrowserResult<InteractionResult> {
|
|
let resp = self.send_command("click", |cmd| {
|
|
cmd.selector = Some(format!("[ref=\"{}\"]", element_ref));
|
|
}).await?;
|
|
let data = self.check_response(resp)?;
|
|
Ok(InteractionResult {
|
|
ok: true,
|
|
snapshot: self.extract_optional_snapshot(&data),
|
|
})
|
|
}
|
|
|
|
/// Type text into an element and optionally press Enter.
|
|
pub async fn type_text(&self, element_ref: &str, text: &str, press_enter: bool) -> BrowserResult<InteractionResult> {
|
|
let resp = self.send_command("fill", |cmd| {
|
|
cmd.selector = Some(format!("[ref=\"{}\"]", element_ref));
|
|
cmd.text = Some(text.to_string());
|
|
}).await?;
|
|
let data = self.check_response(resp)?;
|
|
|
|
if press_enter {
|
|
self.press("Enter").await?;
|
|
}
|
|
|
|
Ok(InteractionResult {
|
|
ok: true,
|
|
snapshot: self.extract_optional_snapshot(&data),
|
|
})
|
|
}
|
|
|
|
/// Press a keyboard key.
|
|
pub async fn press(&self, key: &str) -> BrowserResult<()> {
|
|
let resp = self.send_command("press", |cmd| {
|
|
cmd.text = Some(key.to_string());
|
|
}).await?;
|
|
self.check_response(resp)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Scroll the page.
|
|
pub async fn scroll(&self, direction: &str, amount: u32) -> BrowserResult<()> {
|
|
let resp = self.send_command("scroll", |cmd| {
|
|
cmd.direction = Some(direction.to_string());
|
|
cmd.amount = Some(amount);
|
|
}).await?;
|
|
self.check_response(resp)?;
|
|
Ok(())
|
|
}
|
|
|
|
// ─── Tab Management ─────────────────────────────────────────────
|
|
|
|
/// List all open tabs.
|
|
pub async fn list_tabs(&self) -> BrowserResult<Vec<TabInfo>> {
|
|
let resp = self.send_command("tab_list", |_| {}).await?;
|
|
let data = self.check_response(resp)?;
|
|
|
|
let tabs = data["tabs"].as_array()
|
|
.map(|arr| {
|
|
arr.iter().map(|t| TabInfo {
|
|
index: t["index"].as_u64().unwrap_or(0) as u32,
|
|
title: t["title"].as_str().unwrap_or("").to_string(),
|
|
url: t["url"].as_str().unwrap_or("").to_string(),
|
|
active: t["active"].as_bool().unwrap_or(false),
|
|
}).collect()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
Ok(tabs)
|
|
}
|
|
|
|
/// Create a new tab with an optional URL.
|
|
pub async fn new_tab(&self, url: Option<&str>) -> BrowserResult<TabInfo> {
|
|
let resp = self.send_command("tab_new", |cmd| {
|
|
cmd.url = url.map(|s| s.to_string());
|
|
}).await?;
|
|
let data = self.check_response(resp)?;
|
|
let idx = data["index"].as_u64().unwrap_or(0) as u32;
|
|
Ok(TabInfo {
|
|
index: idx,
|
|
title: String::new(),
|
|
url: url.unwrap_or("about:blank").to_string(),
|
|
active: true,
|
|
})
|
|
}
|
|
|
|
/// Switch to a tab by index.
|
|
pub async fn switch_tab(&self, index: u32) -> BrowserResult<TabInfo> {
|
|
let resp = self.send_command("tab_switch", |cmd| {
|
|
cmd.index = Some(index);
|
|
}).await?;
|
|
let data = self.check_response(resp)?;
|
|
Ok(TabInfo {
|
|
index,
|
|
title: data["title"].as_str().unwrap_or("").to_string(),
|
|
url: data["url"].as_str().unwrap_or("").to_string(),
|
|
active: true,
|
|
})
|
|
}
|
|
|
|
/// Close the browser.
|
|
pub async fn close_browser(&self) -> BrowserResult<()> {
|
|
let resp = self.send_command("close", |_| {}).await;
|
|
match resp {
|
|
Ok(_) => Ok(()),
|
|
Err(e) => {
|
|
warn!("Close browser returned: {}", e);
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Search Macros ──────────────────────────────────────────────
|
|
|
|
/// Execute a search using a built-in daemon macro.
|
|
pub async fn search(&self, macro_name: &str, query: &str) -> BrowserResult<NavigationResult> {
|
|
let resp = self.send_command("navigate", |cmd| {
|
|
cmd.macro_name = Some(macro_name.to_string());
|
|
cmd.query = Some(query.to_string());
|
|
cmd.wait = Some(3000);
|
|
}).await?;
|
|
let data = self.check_response(resp)?;
|
|
Ok(NavigationResult {
|
|
url: data["url"].as_str().unwrap_or("").to_string(),
|
|
title: data["title"].as_str().unwrap_or("").to_string(),
|
|
})
|
|
}
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────
|
|
|
|
fn extract_optional_snapshot(&self, data: &Value) -> Option<PageSnapshot> {
|
|
data["snapshot"].as_str().map(|snap_text| PageSnapshot {
|
|
snapshot: snap_text.to_string(),
|
|
url: data["url"].as_str().unwrap_or("").to_string(),
|
|
title: data["title"].as_str().unwrap_or("").to_string(),
|
|
screenshot: None,
|
|
elements: std::collections::HashMap::new(),
|
|
})
|
|
}
|
|
|
|
/// Wait for a selector or text to appear.
|
|
pub async fn wait(&self, timeout_ms: u64) -> BrowserResult<()> {
|
|
let resp = self.send_command("wait", |cmd| {
|
|
cmd.selector = Some(format!("{}", timeout_ms));
|
|
}).await?;
|
|
self.check_response(resp)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Default for BrowserClient {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
// ─── Connection / Daemon Management ────────────────────────────────
|
|
|
|
/// Check if the daemon socket is reachable.
|
|
pub async fn check_daemon_socket(session: &str) -> bool {
|
|
let path = default_socket_path(session);
|
|
if !path.exists() {
|
|
return false;
|
|
}
|
|
// Quick connect test
|
|
match tokio::time::timeout(Duration::from_secs(1), UnixStream::connect(&path)).await {
|
|
Ok(Ok(_)) => true,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_default_socket_path() {
|
|
let path = default_socket_path("default");
|
|
assert!(path.to_string_lossy().ends_with(".camoufox-browser/default.sock"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_refs() {
|
|
let snap = PageSnapshot {
|
|
snapshot: "- heading \"Hello\" [level=1]\n- link \"Click\" [e1]\n- paragraph: text [e2]\n".into(),
|
|
url: String::new(),
|
|
title: String::new(),
|
|
screenshot: None,
|
|
elements: std::collections::HashMap::new(),
|
|
};
|
|
let refs = snap.extract_refs();
|
|
assert!(refs.contains(&"[e1]".to_string()));
|
|
assert!(refs.contains(&"[e2]".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_navigation_result_deserialize() {
|
|
let json = r#"{"url":"https://example.com","title":"Example"}"#;
|
|
let result: NavigationResult = serde_json::from_str(json).unwrap();
|
|
assert_eq!(result.url, "https://example.com");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_health_no_daemon_running() {
|
|
// With no daemon running, health should report not connected
|
|
// Don't fail — just verify the shape
|
|
let client = BrowserClient::new();
|
|
let health = client.health().await.unwrap_or(HealthResponse {
|
|
ok: false,
|
|
engine: "camoufox".to_string(),
|
|
browser_connected: false,
|
|
browser_running: false,
|
|
active_tabs: 0,
|
|
active_sessions: 0,
|
|
});
|
|
assert_eq!(health.engine, "camoufox");
|
|
}
|
|
} |