use assert_cmd::Command;
use predicates::prelude::*;
use std::time::Instant;
fn search_cmd() -> Command {
Command::cargo_bin("search").unwrap()
}
fn has_any_provider() -> bool {
let output = search_cmd().args(["config", "check"]).output().unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
stdout.contains("[+]") || stdout.contains("OK")
}
fn has_provider(name: &str) -> bool {
let output = search_cmd().args(["config", "check"]).output().unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if line.contains(name) && (line.contains("[+]") || line.contains("OK")) {
return true;
}
}
false
}
#[test]
fn test_help_output() {
search_cmd()
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("Aggregates 11 search providers"))
.stdout(predicate::str::contains("brave"))
.stdout(predicate::str::contains("serper"))
.stdout(predicate::str::contains("exa"));
}
#[test]
fn test_version() {
search_cmd()
.arg("--version")
.assert()
.success()
.stdout(predicate::str::contains("search 0."));
}
#[test]
fn test_search_help_shows_modes() {
search_cmd()
.args(["search", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("academic"))
.stdout(predicate::str::contains("people"))
.stdout(predicate::str::contains("scholar"))
.stdout(predicate::str::contains("patents"));
}
#[test]
fn test_agent_info_json() {
let output = search_cmd()
.arg("agent-info")
.output()
.unwrap();
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(json["name"], "search");
assert!(json["modes"].as_array().unwrap().len() >= 13);
assert!(json["providers"].as_array().unwrap().len() >= 5);
assert_eq!(json["env_prefix"], "SEARCH_");
assert_eq!(json["auto_json_when_piped"], true);
}
#[test]
fn test_providers_json() {
let output = search_cmd()
.args(["providers", "--json"])
.output()
.unwrap();
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(json["status"], "success");
let providers = json["providers"].as_array().unwrap();
let names: Vec<&str> = providers.iter().map(|p| p["name"].as_str().unwrap()).collect();
assert!(names.contains(&"brave"));
assert!(names.contains(&"serper"));
assert!(names.contains(&"exa"));
assert!(names.contains(&"jina"));
assert!(names.contains(&"firecrawl"));
assert!(names.contains(&"tavily"));
}
#[test]
fn test_config_check() {
search_cmd()
.args(["config", "check"])
.assert()
.success()
.stdout(predicate::str::contains("brave"))
.stdout(predicate::str::contains("serper"))
.stdout(predicate::str::contains("exa"))
.stdout(predicate::str::contains("jina"))
.stdout(predicate::str::contains("firecrawl"))
.stdout(predicate::str::contains("tavily"));
}
#[test]
fn test_config_show() {
search_cmd()
.args(["config", "show"])
.assert()
.success()
.stdout(predicate::str::contains("timeout"))
.stdout(predicate::str::contains("count"));
}
#[test]
fn test_no_providers_error_json() {
let output = search_cmd()
.args(["search", "-q", "test", "-p", "nonexistent", "--json"])
.output()
.unwrap();
assert_ne!(output.status.code().unwrap(), 0);
let stderr = String::from_utf8_lossy(&output.stderr);
let json: serde_json::Value = serde_json::from_str(stderr.trim()).unwrap();
assert_eq!(json["status"], "error");
assert!(json["error"]["code"].as_str().is_some());
assert!(json["error"]["message"].as_str().is_some());
}
#[test]
fn test_exit_code_no_providers() {
let output = search_cmd()
.args(["search", "-q", "test", "-p", "nonexistent", "--json"])
.output()
.unwrap();
assert_eq!(output.status.code().unwrap(), 2); }
#[test]
fn test_real_general_search() {
if !has_any_provider() {
eprintln!("SKIP: no providers configured");
return;
}
let start = Instant::now();
let output = search_cmd()
.args(["search", "-q", "Rust programming language", "--json", "-c", "5"])
.output()
.unwrap();
let elapsed = start.elapsed();
assert!(output.status.success(), "search failed: {}", String::from_utf8_lossy(&output.stderr));
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(json["status"], "success");
assert_eq!(json["mode"], "general");
let results = json["results"].as_array().unwrap();
assert!(!results.is_empty(), "expected results, got 0");
let first = &results[0];
assert!(first["title"].as_str().is_some());
assert!(first["url"].as_str().is_some());
assert!(first["source"].as_str().is_some());
assert!(json["metadata"]["elapsed_ms"].as_u64().unwrap() > 0);
assert!(!json["metadata"]["providers_queried"].as_array().unwrap().is_empty());
eprintln!(
" PASS general search: {} results in {}ms (wall: {}ms)",
results.len(),
json["metadata"]["elapsed_ms"],
elapsed.as_millis()
);
}
#[test]
fn test_real_news_search() {
if !has_provider("serper") && !has_provider("brave") {
eprintln!("SKIP: need serper or brave for news");
return;
}
let output = search_cmd()
.args(["search", "-q", "technology news", "-m", "news", "--json", "-c", "5"])
.output()
.unwrap();
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(json["mode"], "news");
let results = json["results"].as_array().unwrap();
assert!(!results.is_empty(), "news search returned 0 results");
eprintln!(" PASS news search: {} results", results.len());
}
#[test]
fn test_real_academic_search() {
if !has_provider("exa") && !has_provider("serper") {
eprintln!("SKIP: need exa or serper for academic");
return;
}
let output = search_cmd()
.args(["search", "-q", "CRISPR gene editing", "-m", "academic", "--json", "-c", "5"])
.output()
.unwrap();
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(json["mode"], "academic");
let results = json["results"].as_array().unwrap();
assert!(!results.is_empty(), "academic search returned 0 results");
eprintln!(" PASS academic search: {} results", results.len());
}
#[test]
fn test_real_provider_filter_single() {
if !has_provider("exa") {
eprintln!("SKIP: need exa");
return;
}
let output = search_cmd()
.args(["search", "-q", "artificial intelligence", "-p", "exa", "--json", "-c", "3"])
.output()
.unwrap();
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let providers = json["metadata"]["providers_queried"].as_array().unwrap();
assert_eq!(providers.len(), 1);
assert_eq!(providers[0], "exa");
for r in json["results"].as_array().unwrap() {
assert!(r["source"].as_str().unwrap().starts_with("exa"), "unexpected source: {}", r["source"]);
}
eprintln!(" PASS provider filter (exa only): {} results", json["results"].as_array().unwrap().len());
}
#[test]
fn test_real_domain_filter() {
if !has_provider("exa") {
eprintln!("SKIP: need exa for domain filter");
return;
}
let output = search_cmd()
.args(["search", "-q", "machine learning", "-d", "arxiv.org", "-p", "exa", "--json", "-c", "5"])
.output()
.unwrap();
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let results = json["results"].as_array().unwrap();
assert!(!results.is_empty());
for r in results {
let url = r["url"].as_str().unwrap();
assert!(url.contains("arxiv.org"), "expected arxiv.org URL, got: {}", url);
}
eprintln!(" PASS domain filter (arxiv.org): {} results", results.len());
}
#[test]
fn test_real_freshness_filter() {
if !has_provider("serper") {
eprintln!("SKIP: need serper for freshness test");
return;
}
let output = search_cmd()
.args(["search", "-q", "AI news", "-f", "day", "-p", "serper", "--json", "-c", "5"])
.output()
.unwrap();
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let results = json["results"].as_array().unwrap();
assert!(!results.is_empty(), "freshness filter returned 0 results");
eprintln!(" PASS freshness filter (day): {} results", results.len());
}
#[test]
fn test_real_last_cache() {
if !has_any_provider() {
eprintln!("SKIP: no providers");
return;
}
let output1 = search_cmd()
.args(["search", "-q", "cache test query", "--json", "-c", "3"])
.output()
.unwrap();
assert!(output1.status.success());
let start = Instant::now();
let output2 = search_cmd()
.args(["--last", "--json"])
.output()
.unwrap();
let cache_elapsed = start.elapsed();
assert!(output2.status.success());
let json: serde_json::Value = serde_json::from_slice(&output2.stdout).unwrap();
assert_eq!(json["status"], "success");
assert!(!json["results"].as_array().unwrap().is_empty());
assert!(cache_elapsed.as_millis() < 500, "cache replay took {}ms", cache_elapsed.as_millis());
eprintln!(" PASS cache replay: {}ms", cache_elapsed.as_millis());
}
#[test]
fn test_performance_benchmark() {
if !has_any_provider() {
eprintln!("SKIP: no providers for benchmark");
return;
}
let queries = [
"rust programming",
"machine learning",
"quantum computing",
"climate change",
"blockchain technology",
];
let mut latencies = Vec::new();
for q in &queries {
let start = Instant::now();
let output = search_cmd()
.args(["search", "-q", q, "--json", "-c", "5"])
.output()
.unwrap();
let elapsed = start.elapsed().as_millis();
if output.status.success() {
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let api_ms = json["metadata"]["elapsed_ms"].as_u64().unwrap_or(0);
let count = json["results"].as_array().map(|a| a.len()).unwrap_or(0);
eprintln!(" query={:<25} results={:<3} api={}ms wall={}ms", q, count, api_ms, elapsed);
latencies.push(elapsed);
} else {
eprintln!(" query={:<25} FAILED", q);
}
}
if latencies.is_empty() {
eprintln!(" No successful queries for benchmark");
return;
}
latencies.sort();
let avg = latencies.iter().sum::<u128>() / latencies.len() as u128;
let p50 = latencies[latencies.len() / 2];
let p95 = latencies[latencies.len() * 95 / 100];
let min = latencies[0];
let max = latencies[latencies.len() - 1];
eprintln!("\n BENCHMARK ({} queries):", latencies.len());
eprintln!(" avg: {}ms", avg);
eprintln!(" p50: {}ms", p50);
eprintln!(" p95: {}ms", p95);
eprintln!(" min: {}ms", min);
eprintln!(" max: {}ms", max);
}