返回 CodeWhale
search_host_tests.rs
根目录 / crates / tui / src / tools / web / search_host_tests.rs
1 //! Real pinned Builtin + actual Core captures; no external provider calls.
2 use super::*;
3 use crate::extension_host::tests::node_for_tests;
4 use crate::extension_host::{ExtensionHostManager, ExtensionHostOptions, TestManagerGuard};
5 use crate::features::{Feature, Features};
6 use crate::plugins::activation::TestPolicyGuard;
7 use std::sync::Arc;
8
9 fn host_context(root: &std::path::Path) -> ToolContext {
10 let mut flags = Features::with_defaults();
11 flags.enable(Feature::WebSearchHost);
12 ToolContext::new(root).with_features(flags)
13 }
14 fn new_manager(node: std::path::PathBuf, root: &std::path::Path) -> Arc<ExtensionHostManager> {
15 Arc::new(ExtensionHostManager::new(ExtensionHostOptions {
16 runtime: crate::config::ExtensionHostRuntime::Node,
17 node_override: Some(node),
18 root: Some(root.join("host")),
19 ..ExtensionHostOptions::default()
20 }))
21 }
22 #[test]
23 fn web_flags_are_separate_and_default_to_rust() {
24 let mut flags = Features::with_defaults();
25 assert!(!flags.enabled(Feature::WebSearchHost));
26 assert!(!flags.enabled(Feature::WebExtractHost));
27 flags.enable(Feature::WebSearchHost);
28 assert!(!flags.enabled(Feature::ExtensionHost));
29 assert!(!flags.enabled(Feature::WebExtractHost));
30 assert_eq!(
31 crate::features::feature_from_key("web_search_host"),
32 Some(Feature::WebSearchHost)
33 );
34 assert_eq!(
35 crate::features::feature_from_key("web_extract_host"),
36 Some(Feature::WebExtractHost)
37 );
38 }
39 #[test]
40 fn provider_snapshot_removes_unknown_credentials_and_uses_opaque_source_handles() {
41 let raw = json!({"api_key":"fixture-secret","private":{"token":"fixture-secret"},"results":[{"title":"Echo fixture-secret","url":" https://user:password@example.com/page?token=fixture-secret ","content":"Visible fixture-secret","credential":"fixture-secret"}]});
42 let mut urls = OpaqueUrls::default();
43 let captured = provider_projection(&raw, &mut urls, Some("fixture-secret"));
44 let wire = captured.to_string();
45 assert!(!wire.contains("fixture-secret"));
46 assert!(!wire.contains("password"));
47 assert!(!wire.contains("example.com"));
48 assert!(!wire.contains("api_key"));
49 let handle = captured["results"][0]["url"].as_str().unwrap();
50 assert_eq!(
51 urls.restore(handle).unwrap(),
52 raw["results"][0]["url"].as_str().unwrap()
53 );
54 assert_eq!(
55 urls.restore(handle.trim()).unwrap(),
56 raw["results"][0]["url"].as_str().unwrap().trim()
57 );
58 assert!(!urls.restore("invented-source").unwrap_err().content());
59 }
60 #[tokio::test(flavor = "current_thread")]
61 async fn real_host_all_nine_provider_decoders_match_current_rust_semantics() {
62 let _home = crate::test_support::SealedHome::new();
63 let _policy = TestPolicyGuard::extension_host(false);
64 let Some(node) = node_for_tests("real_host_web_provider_parity") else {
65 return;
66 };
67 let root = tempfile::tempdir().unwrap();
68 let manager = new_manager(node, root.path());
69 let _manager = TestManagerGuard::install(Arc::clone(&manager));
70 let context = host_context(root.path());
71 let rows = json!([{"title":" \u{0085}A\u{0085} ","url":" https://example.com/a?private=1 ","content":null,"snippet":" fallback ","score":-0.0},
72 {"title":"B","url":"https://example.com/b","content":" body ","score":"3.2"},
73 {"title":"","url":"https://example.com/empty"}]);
74 let cases = vec![
75 (
76 "tavily",
77 json!({"results":rows}),
78 parse_tavily_results as fn(&Value, usize) -> Vec<WebSearchEntry>,
79 ),
80 (
81 "firecrawl",
82 json!({"data":{"web":rows},"success":true}),
83 parse_firecrawl_results,
84 ),
85 (
86 "metaso",
87 json!({"webpages":[{"title":" A ","link":" https://example.com/a ","snippet":null,"summary":" fallback "}],"code":0}),
88 parse_metaso_results,
89 ),
90 (
91 "bocha",
92 json!({"pages":[{"name":null,"title":"unused","url":"https://example.com/unused"},{"name":" A ","link":" https://example.com/a ","summary":null,"snippet":"unused"}],"code":200}),
93 parse_bocha_results,
94 ),
95 (
96 "baidu",
97 json!({"references":[{"title":" A ","link":" https://example.com/a ","content":null,"snippet":"unused"}],"error_code":0}),
98 parse_baidu_results,
99 ),
100 ("searxng", json!({"results":rows}), parse_searxng_results),
101 ("sofya", json!({"results":rows}), parse_sofya_results),
102 (
103 "serply",
104 json!({"results":[{"title":" A ","link":" https://example.com/a ","description":" body "}]}),
105 parse_serply_results,
106 ),
107 ];
108 for (name, raw, parse) in cases {
109 let expected = parse(&raw, 5);
110 let actual = host_provider(name, &raw, 5, None, &context, 5_000)
111 .await
112 .unwrap()
113 .unwrap();
114 assert_eq!(actual, expected, "{name}");
115 }
116 let text = "```json\n{\"results\":[{\"title\":\" A \",\"url\":\" https://example.com/a?token=private \",\"snippet\":\" body \"}]}\n```";
117 let raw = json!({"output":[{"type":"message","content":[{"text":text}]}]});
118 assert_eq!(
119 host_provider("volcengine", &raw, 5, None, &context, 5_000)
120 .await
121 .unwrap()
122 .unwrap(),
123 parse_volcengine_results(text, 5)
124 );
125 for (raw, expected) in [
126 (json!({"code":2005}), Some("API key rejected")),
127 (json!({"code":2005.0}), None),
128 (json!({"code":"2005"}), None),
129 ] {
130 let result = host_provider("metaso", &raw, 5, None, &context, 5_000).await;
131 if let Some(expected) = expected {
132 let error = result.unwrap_err();
133 assert!(error.content());
134 assert!(error.to_string().contains(expected));
135 } else {
136 assert!(result.unwrap().unwrap().is_empty());
137 }
138 }
139 assert!(!crate::plugins::activation::extension_host_policy_enabled());
140 manager.shutdown().await;
141 }
142 #[tokio::test(flavor = "current_thread")]
143 async fn real_host_keyless_requests_and_final_receipt_match_rust() {
144 let _home = crate::test_support::SealedHome::new();
145 let _policy = TestPolicyGuard::extension_host(false);
146 let Some(node) = node_for_tests("real_host_web_request_parity") else {
147 return;
148 };
149 let root = tempfile::tempdir().unwrap();
150 let manager = new_manager(node, root.path());
151 let _manager = TestManagerGuard::install(Arc::clone(&manager));
152 let context = host_context(root.path());
153 let query = SearchQuery::new(
154 "authorized 漢字 query".into(),
155 5,
156 Some(Recency::Days(10)),
157 vec!["example.com".into()],
158 Some("de_DE".into()),
159 );
160 let filters = QueryFilters::of(&query);
161 let plan = host_request("tavily", &query.query, filters, 5, &context, 5_000)
162 .await
163 .unwrap()
164 .unwrap();
165 let mut expected = tavily_search_payload("private-key", &query.query, filters, 5);
166 expected.as_object_mut().unwrap().remove("api_key");
167 assert_eq!(plan.payload, expected);
168 assert_eq!(
169 host_request("baidu", &query.query, filters, 5, &context, 5_000)
170 .await
171 .unwrap()
172 .unwrap()
173 .payload,
174 baidu_search_payload(&query.query, 5)
175 );
176 let mut expected = volcengine_search_payload(&query.query, 5);
177 for key in ["model", "stream", "tools"] {
178 expected.as_object_mut().unwrap().remove(key);
179 }
180 assert_eq!(
181 host_request("volcengine", &query.query, filters, 5, &context, 5_000)
182 .await
183 .unwrap()
184 .unwrap()
185 .payload,
186 expected
187 );
188 let entries = vec![
189 SearchResult::new(1, "A".into(), "https://example.com/a".into(), None, None),
190 SearchResult::new(
191 2,
192 "Other".into(),
193 "https://other.example/a".into(),
194 None,
195 None,
196 ),
197 ];
198 let make = || BackendSearch {
199 backend: BackendId::ProviderNative,
200 source: "native".into(),
201 backend_detail: Some("api.example.com".into()),
202 results: entries.clone(),
203 degraded: vec![DegradedReason::AnswerCutByProvider],
204 note: Some("private answer retained in Core".into()),
205 };
206 let started = Instant::now();
207 let expected = finalize_search_response(
208 query.clone(),
209 crate::tools::web::contract::QueryCapabilities::count_only(),
210 make(),
211 started,
212 );
213 let actual = finalize_search_response_for_context(
214 query,
215 crate::tools::web::contract::QueryCapabilities::count_only(),
216 make(),
217 started,
218 &context,
219 Duration::from_secs(5),
220 )
221 .await
222 .unwrap();
223 assert_eq!(actual.message, expected.message);
224 assert_eq!(actual.results, expected.results);
225 assert_eq!(actual.receipt.honored, expected.receipt.honored);
226 assert_eq!(actual.receipt.degraded, expected.receipt.degraded);
227 manager.shutdown().await;
228 }
229 #[tokio::test(flavor = "current_thread")]
230 async fn real_core_http_capture_and_host_request_are_bounded_without_credentials_on_wire() {
231 let _home = crate::test_support::SealedHome::new();
232 let _policy = TestPolicyGuard::extension_host(false);
233 let Some(node) = node_for_tests("real_core_web_http_capture") else {
234 return;
235 };
236 let root = tempfile::tempdir().unwrap();
237 let manager = new_manager(node, root.path());
238 let _manager = TestManagerGuard::install(Arc::clone(&manager));
239 let context = host_context(root.path());
240 let server = wiremock::MockServer::start().await;
241 wiremock::Mock::given(wiremock::matchers::method("POST")).and(wiremock::matchers::path("/search"))
242 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({"success":true,"data":{"web":[{"title":" A ","url":" https://example.com/a ","description":" body "}]}})))
243 .mount(&server).await;
244 let endpoint = format!("{}/search", server.uri());
245 let (entries, note) = WebSearchTool
246 .run_firecrawl_search_at_for_context(
247 &endpoint,
248 "approved query",
249 QueryFilters::default(),
250 5,
251 5_000,
252 Some("private-test-key"),
253 &context,
254 )
255 .await
256 .unwrap();
257 assert_eq!(entries[0].url, "https://example.com/a");
258 assert_eq!(note, "Firecrawl authenticated");
259 let requests = server.received_requests().await.unwrap();
260 assert_eq!(requests.len(), 1);
261 assert_eq!(
262 requests[0]
263 .headers
264 .get("authorization")
265 .unwrap()
266 .to_str()
267 .unwrap(),
268 "Bearer private-test-key"
269 );
270 assert_eq!(
271 serde_json::from_slice::<Value>(&requests[0].body).unwrap()["query"],
272 "approved query"
273 );
274 wiremock::Mock::given(wiremock::matchers::path("/oversize"))
275 .respond_with(
276 wiremock::ResponseTemplate::new(200).set_body_string("x".repeat(1024 * 1024 + 1)),
277 )
278 .mount(&server)
279 .await;
280 let response = crate::tls::reqwest_client_builder()
281 .build()
282 .unwrap()
283 .get(format!("{}/oversize", server.uri()))
284 .send()
285 .await
286 .unwrap();
287 let error = adapter::read_response(response, &context)
288 .await
289 .unwrap_err();
290 assert_eq!(error.origin, adapter::FailureOrigin::CaptureGuard);
291 manager.shutdown().await;
292 }
293 #[tokio::test(flavor = "current_thread")]
294 async fn selected_web_host_refuses_unavailable_runtime_and_pre_cancel_without_transport_fallback() {
295 let _home = crate::test_support::SealedHome::new();
296 let root = tempfile::tempdir().unwrap();
297 let manager = new_manager(root.path().join("missing-node"), root.path());
298 let _manager = TestManagerGuard::install(manager);
299 let context = host_context(root.path());
300 let error = host_provider("tavily", &json!({"results":[]}), 5, None, &context, 1_000)
301 .await
302 .unwrap_err();
303 assert_eq!(error.origin, adapter::FailureOrigin::Host);
304 let token = tokio_util::sync::CancellationToken::new();
305 token.cancel();
306 let context = context.with_cancel_token(token);
307 assert!(
308 !host_request(
309 "tavily",
310 "query",
311 QueryFilters::default(),
312 5,
313 &context,
314 1_000
315 )
316 .await
317 .unwrap_err()
318 .content()
319 );
320 }
321
321 lines RUST