Option C: Rewrite gyandoot-browser for camoufox-browser@0.1.0 daemon IPC
Complete rewrite removing old HTTP REST client. The latest camoufox-browser npm package (v0.1.0) uses Unix socket JSON-line IPC, not HTTP REST on :9377. Changes: - gyandoot-browser/client.rs: Full daemon IPC via Unix socket (~/.camoufox-browser/*.sock) - JSON-line protocol matching ensureDaemonRunning + daemon.js - Auto-launches browser on first command (daemon manages lifecycle) - Supports navigate, click, type, scroll, snapshot, search macros, tab management - Proper HeathResponse, NavigationResult, PageSnapshot types - gyandoot-browser/lib.rs: Exports new client API (no SearchMacro enum, no SessionManager) - gyandoot-browser/Cargo.toml: reqwest -> tokio Unix socket deps - Removed: macros.rs, session.rs, snapshot.rs (all merged into client.rs) - browser_manager.rs: Triggers daemon via npx, waits for Unix socket - browser_tools.rs: Simplified API (no tab_id, no create_tab/close_tab) Fix: camoufox-browser process exited with status 1 error on fresh install
This commit is contained in:
@@ -1,174 +1,145 @@
|
||||
//! BrowserManager — Auto-start, health-check, and lifecycle for Camoufox.
|
||||
//! BrowserManager — Start, connect, and manage lifecycle of the camoufox-browser daemon.
|
||||
//!
|
||||
//! The `camoufox-browser@0.1.0` CLI auto-starts a background daemon
|
||||
//! (Node.js process) on any command. The daemon listens on a Unix socket at
|
||||
//! `~/.camoufox-browser/<session>.sock` and auto-launches the camoufox browser
|
||||
//! on the first browser-dependent command.
|
||||
//!
|
||||
//! On `gyandoot run`, the BrowserManager:
|
||||
//! 1. Checks if Camoufox is already running (health endpoint)
|
||||
//! 2. If not, starts it as a background child process
|
||||
//! 3. Waits for it to become healthy (up to 30 seconds)
|
||||
//! 1. Checks if the daemon socket already exists and is connected
|
||||
//! 2. If not, runs a quick CLI command to trigger daemon auto-start
|
||||
//! 3. Waits for the Unix socket to appear (up to 30 seconds)
|
||||
//! 4. Returns a ready-to-use `BrowserClient`
|
||||
//!
|
||||
//! Camoufox is installed globally via `npx camoufox-browser` by `install.sh`.
|
||||
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::process::Command;
|
||||
use tracing::{info, warn, error};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use gyandoot_browser::BrowserClient;
|
||||
use gyandoot_browser::{check_daemon_socket, default_socket_path, BrowserClient};
|
||||
|
||||
/// Default Camoufox API endpoint.
|
||||
const CAMOFOX_URL: &str = "http://localhost:9377";
|
||||
/// Default session name.
|
||||
const DEFAULT_SESSION: &str = "default";
|
||||
|
||||
/// Maximum time to wait for Camoufox to become healthy.
|
||||
const STARTUP_TIMEOUT_SECS: u64 = 30;
|
||||
/// Maximum time to wait for the daemon socket to appear.
|
||||
const DAEMON_STARTUP_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
/// Health-check retry interval.
|
||||
const HEALTH_RETRY_MS: u64 = 500;
|
||||
/// Poll interval while waiting for daemon to start.
|
||||
const DAEMON_POLL_MS: u64 = 500;
|
||||
|
||||
/// Result of browser initialization.
|
||||
#[derive(Debug)]
|
||||
pub enum BrowserInitResult {
|
||||
/// Browser is running and ready.
|
||||
/// Browser daemon is running and client is ready.
|
||||
Ready(BrowserClient),
|
||||
/// Could not start browser (user needs to install/start manually).
|
||||
/// Could not start daemon.
|
||||
Unavailable(String),
|
||||
}
|
||||
|
||||
/// Initialize the Camoufox browser: check health, start if needed.
|
||||
///
|
||||
/// Returns a `BrowserInitResult` — either a ready client or an explanation.
|
||||
/// Initialize the camoufox-browser daemon and return a ready client.
|
||||
pub async fn init_browser() -> BrowserInitResult {
|
||||
let client = BrowserClient::new();
|
||||
let session = DEFAULT_SESSION;
|
||||
let socket_path = default_socket_path(session);
|
||||
|
||||
// 1. Quick health check — is it already running?
|
||||
match try_health_check(&client).await {
|
||||
Ok(true) => {
|
||||
info!("Camoufox already running on localhost:9377");
|
||||
return BrowserInitResult::Ready(client);
|
||||
}
|
||||
Ok(false) => {
|
||||
info!("Camoufox health check returned not-ready, attempting to start...");
|
||||
}
|
||||
Err(_) => {
|
||||
info!("Camoufox not running, attempting to start...");
|
||||
}
|
||||
// 1. Quick check — is the daemon already running?
|
||||
if check_daemon_socket(session).await {
|
||||
info!("Camoufox daemon already running on socket: {}", socket_path.display());
|
||||
let client = BrowserClient::new().with_session(session);
|
||||
return BrowserInitResult::Ready(client);
|
||||
}
|
||||
|
||||
// 2. Try to start Camoufox as a background process
|
||||
match start_camoufox().await {
|
||||
info!("Camoufox daemon not running, triggering startup...");
|
||||
|
||||
// 2. Trigger daemon auto-start via CLI
|
||||
// The CLI calls `ensureDaemonRunning()` internally which spawns the daemon
|
||||
match trigger_daemon_start(session).await {
|
||||
Ok(()) => {
|
||||
// 3. Wait for it to become healthy
|
||||
info!("Waiting for Camoufox to become healthy...");
|
||||
match wait_for_health(&client).await {
|
||||
// 3. Wait for socket to appear
|
||||
info!("Waiting for daemon socket at {}...", socket_path.display());
|
||||
match wait_for_socket(session).await {
|
||||
Ok(true) => {
|
||||
info!("Camoufox started successfully");
|
||||
return BrowserInitResult::Ready(client);
|
||||
info!("Camoufox daemon started successfully");
|
||||
let client = BrowserClient::new().with_session(session);
|
||||
// Brief pause for daemon to fully initialize
|
||||
tokio::time::sleep(Duration::from_millis(1000)).await;
|
||||
BrowserInitResult::Ready(client)
|
||||
}
|
||||
Ok(false) => {
|
||||
let msg = format!(
|
||||
"Camoufox process started but did not become healthy within {}s. \
|
||||
Check the logs at ~/.gyandoot/camoufox.log",
|
||||
STARTUP_TIMEOUT_SECS
|
||||
"Daemon did not start within {}s — socket not found at {}",
|
||||
DAEMON_STARTUP_TIMEOUT_SECS,
|
||||
socket_path.display()
|
||||
);
|
||||
warn!("{}", msg);
|
||||
return BrowserInitResult::Unavailable(msg);
|
||||
BrowserInitResult::Unavailable(msg)
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("Camoufox health-check error after startup: {}", e);
|
||||
let msg = format!("Daemon socket error: {}", e);
|
||||
warn!("{}", msg);
|
||||
return BrowserInitResult::Unavailable(msg);
|
||||
BrowserInitResult::Unavailable(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!(
|
||||
"Could not start Camoufox: {}\n\n\
|
||||
Install it manually:\n npm install -g camoufox-browser\n \
|
||||
"Could not start camoufox daemon: {}\n\n\
|
||||
Make sure Node.js v20+ and the package are installed:\n \
|
||||
npm install -g camoufox-browser\n \
|
||||
npx camoufox-browser@latest install\n\n\
|
||||
Then start before running gyandoot:\n npx camoufox-browser start &",
|
||||
Then try again.",
|
||||
e
|
||||
);
|
||||
warn!("{}", msg);
|
||||
return BrowserInitResult::Unavailable(msg);
|
||||
BrowserInitResult::Unavailable(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Quick health check — returns true if browser is connected and running.
|
||||
async fn try_health_check(client: &BrowserClient) -> Result<bool, String> {
|
||||
match tokio::time::timeout(Duration::from_secs(3), client.health()).await {
|
||||
Ok(Ok(health)) => {
|
||||
Ok(health.browser_connected && health.browser_running)
|
||||
}
|
||||
Ok(Err(e)) => Err(e.to_string()),
|
||||
Err(_) => Err("timeout".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start Camoufox as a background child process.
|
||||
async fn start_camoufox() -> Result<(), String> {
|
||||
// Create log directory
|
||||
/// Trigger daemon startup by running a quick CLI command.
|
||||
///
|
||||
/// The `camoufox-browser@latest` CLI auto-starts the daemon via
|
||||
/// `ensureDaemonRunning()` on any command. We run a harmless
|
||||
/// `tab_list` which spawns the daemon in the background and
|
||||
/// returns once complete.
|
||||
async fn trigger_daemon_start(session: &str) -> Result<(), String> {
|
||||
// Create log directory for any stderr output
|
||||
let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
|
||||
let log_dir = home.join(".gyandoot");
|
||||
std::fs::create_dir_all(&log_dir).map_err(|e| format!("Cannot create {}: {}", log_dir.display(), e))?;
|
||||
let log_file = log_dir.join("camoufox.log");
|
||||
std::fs::create_dir_all(&log_dir)
|
||||
.map_err(|e| format!("Cannot create {}: {}", log_dir.display(), e))?;
|
||||
let log_file = log_dir.join("camoufox-daemon-spawn.log");
|
||||
|
||||
let log = std::fs::File::create(&log_file)
|
||||
.map_err(|e| format!("Cannot create log file {}: {}", log_file.display(), e))?;
|
||||
.map_err(|e| format!("Cannot create log file: {}", e))?;
|
||||
|
||||
// Try npx first (npm global install), then npx direct
|
||||
let result = Command::new("npx")
|
||||
// Run a command — the CLI auto-starts the daemon as a side effect
|
||||
let _output = Command::new("npx")
|
||||
.args(&[
|
||||
"camoufox-browser@latest",
|
||||
"start",
|
||||
"--port", "9377",
|
||||
"tab_list",
|
||||
"--session", session,
|
||||
])
|
||||
.stdout(Stdio::from(log.try_clone().map_err(|e| e.to_string())?))
|
||||
.stderr(Stdio::from(log))
|
||||
.kill_on_drop(true)
|
||||
.spawn();
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("npx failed: {}. Is Node.js installed?", e))?;
|
||||
|
||||
match result {
|
||||
Ok(mut child) => {
|
||||
// Detach — let it run in background
|
||||
tokio::spawn(async move {
|
||||
let status = child.wait().await;
|
||||
if let Ok(s) = status {
|
||||
if !s.success() {
|
||||
warn!("Camoufox process exited with status: {}", s);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Give it a moment to bind
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
// npx not available — try directly via node_modules
|
||||
Err(format!("npx failed: {}. Is Node.js installed?", e))
|
||||
}
|
||||
}
|
||||
// Return immediately — socket polling in the caller will detect the daemon
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll the health endpoint until the browser is ready or timeout.
|
||||
async fn wait_for_health(client: &BrowserClient) -> Result<bool, String> {
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(STARTUP_TIMEOUT_SECS);
|
||||
let mut last_error = String::new();
|
||||
/// Poll for the daemon Unix socket to appear.
|
||||
async fn wait_for_socket(session: &str) -> Result<bool, String> {
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(DAEMON_STARTUP_TIMEOUT_SECS);
|
||||
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
match try_health_check(client).await {
|
||||
Ok(true) => return Ok(true),
|
||||
Ok(false) => {
|
||||
last_error = "browser not connected".to_string();
|
||||
}
|
||||
Err(e) => {
|
||||
last_error = e;
|
||||
}
|
||||
if check_daemon_socket(session).await {
|
||||
return Ok(true);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(HEALTH_RETRY_MS)).await;
|
||||
tokio::time::sleep(Duration::from_millis(DAEMON_POLL_MS)).await;
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Timed out after {}s. Last status: {}",
|
||||
STARTUP_TIMEOUT_SECS, last_error
|
||||
))
|
||||
Ok(false)
|
||||
}
|
||||
@@ -3,16 +3,68 @@
|
||||
//! These tools are registered with the `ToolExecutor` and provide
|
||||
//! LLM-accessible functions for searching the web, browsing pages,
|
||||
//! clicking elements, and extracting content.
|
||||
//!
|
||||
//! Uses the camoufox-browser daemon protocol (Unix socket IPC).
|
||||
|
||||
use serde_json;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use gyandoot_browser::{BrowserClient, SearchMacro};
|
||||
use gyandoot_browser::BrowserClient;
|
||||
use gyandoot_core::ToolDefinition;
|
||||
|
||||
use crate::tools::Tool;
|
||||
|
||||
/// Tool for searching the web using camofox-browser search macros.
|
||||
/// Search macro name strings sent to the camoufox daemon.
|
||||
const MACRO_GOOGLE: &str = "@google_search";
|
||||
const MACRO_YOUTUBE: &str = "@youtube_search";
|
||||
const MACRO_AMAZON: &str = "@amazon_search";
|
||||
const MACRO_REDDIT: &str = "@reddit_search";
|
||||
const MACRO_WIKIPEDIA: &str = "@wikipedia_search";
|
||||
const MACRO_TWITTER: &str = "@twitter_search";
|
||||
const MACRO_YELP: &str = "@yelp_search";
|
||||
const MACRO_SPOTIFY: &str = "@spotify_search";
|
||||
const MACRO_LINKEDIN: &str = "@linkedin_search";
|
||||
const MACRO_INSTAGRAM: &str = "@instagram_search";
|
||||
const MACRO_TIKTOK: &str = "@tiktok_search";
|
||||
const MACRO_TWITCH: &str = "@twitch_search";
|
||||
|
||||
fn site_to_macro(site: &str) -> &'static str {
|
||||
match site {
|
||||
"google" => MACRO_GOOGLE,
|
||||
"youtube" => MACRO_YOUTUBE,
|
||||
"amazon" => MACRO_AMAZON,
|
||||
"reddit" => MACRO_REDDIT,
|
||||
"wikipedia" => MACRO_WIKIPEDIA,
|
||||
"twitter" | "x" => MACRO_TWITTER,
|
||||
"yelp" => MACRO_YELP,
|
||||
"spotify" => MACRO_SPOTIFY,
|
||||
"linkedin" => MACRO_LINKEDIN,
|
||||
"instagram" => MACRO_INSTAGRAM,
|
||||
"tiktok" => MACRO_TIKTOK,
|
||||
"twitch" => MACRO_TWITCH,
|
||||
_ => MACRO_GOOGLE,
|
||||
}
|
||||
}
|
||||
|
||||
fn site_name(site: &str) -> &'static str {
|
||||
match site {
|
||||
"google" => "Google",
|
||||
"youtube" => "YouTube",
|
||||
"amazon" => "Amazon",
|
||||
"reddit" => "Reddit",
|
||||
"wikipedia" => "Wikipedia",
|
||||
"twitter" | "x" => "Twitter/X",
|
||||
"yelp" => "Yelp",
|
||||
"spotify" => "Spotify",
|
||||
"linkedin" => "LinkedIn",
|
||||
"instagram" => "Instagram",
|
||||
"tiktok" => "TikTok",
|
||||
"twitch" => "Twitch",
|
||||
_ => "Google",
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool for searching the web using camoufox daemon macros.
|
||||
pub struct WebSearchTool {
|
||||
client: BrowserClient,
|
||||
}
|
||||
@@ -61,46 +113,23 @@ impl Tool for WebSearchTool {
|
||||
return Err(anyhow::anyhow!("query is required"));
|
||||
}
|
||||
|
||||
let macro_name = match site.as_str() {
|
||||
"google" => SearchMacro::GoogleSearch,
|
||||
"youtube" => SearchMacro::YoutubeSearch,
|
||||
"amazon" => SearchMacro::AmazonSearch,
|
||||
"reddit" => SearchMacro::RedditSearch,
|
||||
"wikipedia" => SearchMacro::WikipediaSearch,
|
||||
"twitter" | "x" => SearchMacro::TwitterSearch,
|
||||
"yelp" => SearchMacro::YelpSearch,
|
||||
"spotify" => SearchMacro::SpotifySearch,
|
||||
"linkedin" => SearchMacro::LinkedinSearch,
|
||||
"instagram" => SearchMacro::InstagramSearch,
|
||||
"tiktok" => SearchMacro::TiktokSearch,
|
||||
"twitch" => SearchMacro::TwitchSearch,
|
||||
_ => SearchMacro::GoogleSearch,
|
||||
};
|
||||
let mname = site_to_macro(&site);
|
||||
let sname = site_name(&site);
|
||||
|
||||
info!("web_search: searching {} for '{}'", macro_name.site_name(), query);
|
||||
info!("web_search: searching {} for '{}'", sname, query);
|
||||
|
||||
let session_key = format!("web_search_{}", std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs());
|
||||
|
||||
let tab = self.client.create_tab("gyandoot", &session_key, None).await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create browser tab: {}", e))?;
|
||||
let tab_id = tab.tab_id;
|
||||
|
||||
let result = self.client.search(&tab_id, "gyandoot", ¯o_name, &query, Some(3000)).await
|
||||
// Execute search — daemon auto-launches browser on first call
|
||||
let result = self.client.search(mname, &query).await
|
||||
.map_err(|e| anyhow::anyhow!("Search failed: {}", e))?;
|
||||
|
||||
let snap = self.client.snapshot(&tab_id, "gyandoot").await
|
||||
// Get snapshot of results
|
||||
let snap = self.client.snapshot(true).await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get snapshot: {}", e))?;
|
||||
|
||||
// Close the tab to free resources
|
||||
let _ = self.client.close_tab(&tab_id, "gyandoot").await;
|
||||
|
||||
let refs = snap.extract_refs();
|
||||
let response = format!(
|
||||
"## {} Search Results\n\n**Query:** {}\n**Page:** {}\n**Title:** {}\n\n**Snapshot content:**\n{}\n\n**Available element refs:** {:?}\n\n*Use `web_browse` to interact with specific results.*",
|
||||
macro_name.site_name(),
|
||||
sname,
|
||||
query,
|
||||
result.url,
|
||||
result.title,
|
||||
@@ -112,7 +141,7 @@ impl Tool for WebSearchTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool for browsing a specific URL using camofox-browser.
|
||||
/// Tool for browsing a specific URL using camoufox daemon browser.
|
||||
pub struct WebBrowseTool {
|
||||
client: BrowserClient,
|
||||
}
|
||||
@@ -189,62 +218,52 @@ impl Tool for WebBrowseTool {
|
||||
return Err(anyhow::anyhow!("url is required"));
|
||||
}
|
||||
|
||||
let session_key = format!("web_browse_{}", std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs());
|
||||
|
||||
let tab = self.client.create_tab("gyandoot", &session_key, Some(&url)).await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create browser tab: {}", e))?;
|
||||
let tab_id = tab.tab_id;
|
||||
|
||||
match action.as_str() {
|
||||
"navigate" => {
|
||||
self.client.navigate(&url).await
|
||||
.map_err(|e| anyhow::anyhow!("Navigation failed: {}", e))?;
|
||||
}
|
||||
"click" => {
|
||||
if let Some(ref r#ref) = element_ref {
|
||||
self.client.click(&tab_id, "gyandoot", r#ref).await
|
||||
.map_err(|e| anyhow::anyhow!("Click failed: {}", e))?;
|
||||
} else {
|
||||
return Err(anyhow::anyhow!("element_ref is required for click action"));
|
||||
}
|
||||
let r#ref = element_ref.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("element_ref is required for click action"))?;
|
||||
self.client.click(r#ref).await
|
||||
.map_err(|e| anyhow::anyhow!("Click failed: {}", e))?;
|
||||
}
|
||||
"type" => {
|
||||
if let Some(ref r#ref) = element_ref {
|
||||
if let Some(ref t) = text {
|
||||
self.client.type_text(&tab_id, "gyandoot", r#ref, t, press_enter).await
|
||||
.map_err(|e| anyhow::anyhow!("Type failed: {}", e))?;
|
||||
}
|
||||
} else {
|
||||
return Err(anyhow::anyhow!("element_ref and text are required for type action"));
|
||||
}
|
||||
let r#ref = element_ref.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("element_ref is required for type action"))?;
|
||||
let t = text.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("text is required for type action"))?;
|
||||
self.client.type_text(r#ref, t, press_enter).await
|
||||
.map_err(|e| anyhow::anyhow!("Type failed: {}", e))?;
|
||||
}
|
||||
"scroll" => {
|
||||
self.client.scroll(&tab_id, "gyandoot", &direction, amount).await
|
||||
self.client.scroll(&direction, amount).await
|
||||
.map_err(|e| anyhow::anyhow!("Scroll failed: {}", e))?;
|
||||
}
|
||||
"back" => {
|
||||
self.client.go_back(&tab_id, "gyandoot").await
|
||||
self.client.go_back().await
|
||||
.map_err(|e| anyhow::anyhow!("Go back failed: {}", e))?;
|
||||
}
|
||||
"forward" => {
|
||||
self.client.go_forward(&tab_id, "gyandoot").await
|
||||
self.client.go_forward().await
|
||||
.map_err(|e| anyhow::anyhow!("Go forward failed: {}", e))?;
|
||||
}
|
||||
"refresh" => {
|
||||
self.client.refresh(&tab_id, "gyandoot").await
|
||||
self.client.refresh().await
|
||||
.map_err(|e| anyhow::anyhow!("Refresh failed: {}", e))?;
|
||||
}
|
||||
_ => {} // already navigated via create_tab
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let snap = self.client.snapshot(&tab_id, "gyandoot").await
|
||||
let snap = self.client.snapshot(false).await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get snapshot: {}", e))?;
|
||||
|
||||
let _ = self.client.close_tab(&tab_id, "gyandoot").await;
|
||||
|
||||
let refs = snap.extract_refs();
|
||||
let current_url = if snap.url.is_empty() { &url } else { &snap.url };
|
||||
let response = format!(
|
||||
"## Page Snapshot\n\n**URL:** {}\n\n**Snapshot:**\n{}\n\n**Element refs:** {:?}\n\n*Use `web_browse` with action=\"click\" and element_ref to interact with elements.*",
|
||||
url,
|
||||
current_url,
|
||||
snap.snapshot,
|
||||
refs,
|
||||
);
|
||||
@@ -281,4 +300,11 @@ mod tests {
|
||||
let params = tool.parameters();
|
||||
assert!(params["required"].as_array().unwrap().contains(&"url".to_string().into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_site_to_macro() {
|
||||
assert_eq!(site_to_macro("google"), "@google_search");
|
||||
assert_eq!(site_to_macro("youtube"), "@youtube_search");
|
||||
assert_eq!(site_to_macro("reddit"), "@reddit_search");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user