1#![allow(
13 clippy::expect_used,
14 clippy::unwrap_used,
15 clippy::panic,
16 clippy::missing_panics_doc
17)]
18
19pub mod a2a;
21pub mod budgets;
23pub mod costs;
25pub mod endpoints;
27pub mod mcp_client;
29pub mod mcp_server;
31pub mod reporting;
33pub mod runner;
35pub mod scenario;
37
38#[cfg(feature = "a2a-server")]
40pub mod a2a_server;
41
42#[cfg(feature = "macros")]
44pub mod macros;
45
46use std::collections::HashMap;
47use std::time::Duration;
48
49use serde_json::Value;
50
51pub use costs::LlmResponse;
52pub use costs::LlmUsage;
53
54#[derive(Debug, Clone)]
57pub struct LlmConfig {
58 pub url: String,
60 pub model: String,
62 pub api_key: Option<String>,
64 pub headers: HashMap<String, String>,
66 pub timeout: Duration,
68 pub temperature: f64,
70 pub thinking: Option<bool>,
73 pub model_params: HashMap<String, Value>,
76}
77
78impl LlmConfig {
79 #[must_use]
82 pub fn from_env() -> Self {
83 Self {
84 url: llm_base_url(),
85 model: llm_model(),
86 api_key: std::env::var("HARNESS_LLM_API_KEY").ok(),
87 headers: parse_headers_env(),
88 timeout: Duration::from_secs(60),
89 temperature: 0.0,
90 thinking: None,
91 model_params: HashMap::new(),
92 }
93 }
94}
95
96#[must_use]
100pub fn parse_headers_env() -> HashMap<String, String> {
101 let Ok(raw) = std::env::var("HARNESS_LLM_HEADERS") else {
102 return HashMap::new();
103 };
104 let Ok(json) = serde_json::from_str::<Value>(&raw) else {
105 return HashMap::new();
106 };
107 let Some(obj) = json.as_object() else {
108 return HashMap::new();
109 };
110 obj.iter()
111 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_owned())))
112 .collect()
113}
114
115#[must_use]
118pub fn base_url() -> String {
119 std::env::var("HARNESS_BROWSER_BASE_URL").unwrap_or_else(|_| "http://localhost:4200".to_owned())
120}
121
122#[must_use]
125pub fn llm_base_url() -> String {
126 std::env::var("HARNESS_LLM_TEST_URL")
127 .unwrap_or_else(|_| "http://localhost:8080".to_owned())
128 .trim_end_matches('/')
129 .to_owned()
130}
131
132#[must_use]
135pub fn llm_model() -> String {
136 std::env::var("HARNESS_LLM_TEST_MODEL").unwrap_or_else(|_| "deepseek".to_owned())
137}
138
139#[must_use]
142pub fn browser_headless() -> bool {
143 std::env::var("HARNESS_BROWSER_HEADLESS")
144 .map_or(true, |v| v != "0" && v.to_lowercase() != "false")
145}
146
147#[must_use]
149pub fn http_client(timeout: Duration) -> reqwest::Client {
150 reqwest::Client::builder()
151 .timeout(timeout)
152 .build()
153 .expect("build reqwest client")
154}
155
156#[must_use]
162pub async fn llm_chat(llm: &LlmConfig, system: &str, user: &str) -> Option<String> {
163 llm_chat_with_usage(llm, system, user)
164 .await
165 .map(|r| r.content)
166}
167
168#[must_use]
171pub async fn llm_chat_with_usage(llm: &LlmConfig, system: &str, user: &str) -> Option<LlmResponse> {
172 let client = http_client(llm.timeout);
173 let mut payload = serde_json::json!({
174 "model": llm.model,
175 "messages": [
176 {"role": "system", "content": system},
177 {"role": "user", "content": user}
178 ],
179 "max_tokens": 4096,
180 "temperature": llm.temperature
181 });
182 if let Some(think) = llm.thinking {
183 if think {
184 payload["thinking"] = serde_json::json!({"type": "enabled"});
185 } else {
186 payload["thinking"] = serde_json::json!({"type": "disabled"});
187 }
188 }
189 if !llm.model_params.is_empty() {
191 if let Value::Object(ref mut map) = payload {
192 for (key, val) in &llm.model_params {
193 map.insert(key.clone(), val.clone());
194 }
195 }
196 }
197 let mut req = client
198 .post(format!("{}/v1/chat/completions", llm.url))
199 .header("Content-Type", "application/json");
200
201 if let Some(ref key) = llm.api_key {
202 req = req.header("Authorization", format!("Bearer {key}"));
203 }
204 for (name, value) in &llm.headers {
205 req = req.header(name.as_str(), value.as_str());
206 }
207
208 let resp = req.json(&payload).send().await.ok()?;
209 let json: Value = resp.json().await.ok()?;
210 let usage = costs::extract_usage(&json);
211 let content = json["choices"][0]["message"]["content"]
212 .as_str()
213 .map(String::from)?;
214
215 Some(LlmResponse { content, usage })
216}
217
218pub const DOM_EXTRACT_JS: &str = r#"
221(() => {
222 const interactive = 'a, button, input, textarea, select, [role="button"], [onclick], [tabindex], [data-testid], [aria-label]';
223 const els = document.querySelectorAll(interactive);
224 const info = [];
225 const seen = new Set();
226 els.forEach((el, i) => {
227 const rect = el.getBoundingClientRect();
228 if (rect.width === 0 || rect.height === 0) return;
229 const tag = el.tagName.toLowerCase();
230 let selector = '';
231 if (el.id) selector = '#' + CSS.escape(el.id);
232 else if (el.getAttribute('data-testid')) selector = '[data-testid="' + el.getAttribute('data-testid') + '"]';
233 else if (el.name) selector = '[name="' + CSS.escape(el.name) + '"]';
234 else if (el.className && typeof el.className === 'string') {
235 const cls = el.className.trim().split(/\\s+/)[0];
236 if (cls) selector = tag + '.' + CSS.escape(cls);
237 }
238 if (!selector) selector = tag;
239 if (seen.has(selector)) return;
240 seen.add(selector);
241
242 let label = '';
243 const aria = el.getAttribute('aria-label');
244 if (aria) {
245 label = aria;
246 } else if (tag === 'input' || tag === 'textarea' || tag === 'select') {
247 label = el.placeholder || el.name || el.getAttribute('aria-label') || '';
248 if (el.type && !label) label = el.type;
249 } else {
250 label = (el.textContent || '').trim().substring(0, 80);
251 }
252
253 info.push(i + ': ' + selector + ' [' + tag + '] "' + label + '"');
254 });
255 return JSON.stringify(info);
256})()
257"#;
258
259#[must_use]
262pub fn truncate(s: &str, max_len: usize) -> String {
263 if s.len() <= max_len {
264 s.to_owned()
265 } else {
266 format!("{}...<truncated>", &s[..max_len])
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use crate::costs::extract_usage;
273 use crate::truncate;
274 use crate::{llm_base_url, llm_model, parse_headers_env, LlmConfig};
275
276 #[test]
277 fn test_truncate_short() {
278 assert_eq!(truncate("hello", 10), "hello");
279 }
280
281 #[test]
282 fn test_truncate_long() {
283 let result = truncate("hello world", 5);
284 assert!(result.contains("<truncated>"));
285 assert!(result.starts_with("hello"));
286 }
287
288 #[test]
289 fn test_truncate_exact_length() {
290 assert_eq!(truncate("abcde", 5), "abcde");
291 }
292
293 #[test]
294 fn test_truncate_empty() {
295 assert_eq!(truncate("", 5), "");
296 }
297
298 #[test]
299 fn test_parse_headers_env_empty() {
300 std::env::remove_var("HARNESS_LLM_HEADERS");
301 let h = parse_headers_env();
302 assert!(h.is_empty());
303 }
304
305 #[test]
306 fn test_parse_headers_env_valid() {
307 std::env::set_var("HARNESS_LLM_HEADERS", r#"{"X-Org":"acme","X-Version":"1"}"#);
308 let h = parse_headers_env();
309 assert_eq!(h.get("X-Org").map(String::as_str), Some("acme"));
310 assert_eq!(h.get("X-Version").map(String::as_str), Some("1"));
311 std::env::remove_var("HARNESS_LLM_HEADERS");
312 }
313
314 #[test]
315 fn test_parse_headers_env_invalid_json() {
316 std::env::set_var("HARNESS_LLM_HEADERS", "not-json");
317 let h = parse_headers_env();
318 assert!(h.is_empty());
319 std::env::remove_var("HARNESS_LLM_HEADERS");
320 }
321
322 #[test]
323 fn test_llm_config_from_env_defaults() {
324 #[allow(clippy::float_cmp)]
325 {
326 let config = LlmConfig::from_env();
327 assert_eq!(config.temperature, 0.0);
328 assert!(config.thinking.is_none());
329 assert!(config.model_params.is_empty());
330 }
331 }
332
333 #[test]
334 fn test_extract_usage_full() {
335 let json = serde_json::json!({
336 "usage": {
337 "prompt_tokens": 100,
338 "completion_tokens": 200,
339 "total_tokens": 300
340 }
341 });
342 let usage = extract_usage(&json);
343 assert_eq!(usage.prompt_tokens, 100);
344 assert_eq!(usage.completion_tokens, 200);
345 assert_eq!(usage.total_tokens, 300);
346 }
347
348 #[test]
349 fn test_extract_usage_empty() {
350 let json = serde_json::json!({});
351 let usage = extract_usage(&json);
352 assert_eq!(usage.prompt_tokens, 0);
353 assert_eq!(usage.completion_tokens, 0);
354 assert_eq!(usage.total_tokens, 0);
355 }
356
357 #[test]
358 fn test_truncate_unicode() {
359 assert_eq!(truncate("héllo", 3), "hé...<truncated>");
362 assert_eq!(truncate("hello", 5), "hello");
364 }
365
366 #[test]
367 fn test_parse_headers_env_non_object() {
368 std::env::set_var("HARNESS_LLM_HEADERS", "[1, 2, 3]");
369 let h = parse_headers_env();
370 assert!(h.is_empty());
371 std::env::remove_var("HARNESS_LLM_HEADERS");
372 }
373
374 #[test]
375 fn test_parse_headers_env_nested_values_filtered() {
376 std::env::set_var(
377 "HARNESS_LLM_HEADERS",
378 r#"{"str":"val","num":42,"bool":true}"#,
379 );
380 let h = parse_headers_env();
381 assert_eq!(h.get("str").map(String::as_str), Some("val"));
382 assert!(!h.contains_key("num"));
383 assert!(!h.contains_key("bool"));
384 std::env::remove_var("HARNESS_LLM_HEADERS");
385 }
386
387 #[test]
388 fn test_llm_config_has_default_model() {
389 let config = LlmConfig::from_env();
390 assert!(!config.model.is_empty());
391 }
392
393 #[test]
394 fn test_llm_base_url_default() {
395 std::env::remove_var("HARNESS_LLM_TEST_URL");
396 let url = llm_base_url();
397 assert_eq!(url, "http://localhost:8080");
398 }
399
400 #[test]
401 fn test_llm_base_url_custom() {
402 std::env::set_var("HARNESS_LLM_TEST_URL", "https://custom.api.com/v1");
403 let url = llm_base_url();
404 assert_eq!(url, "https://custom.api.com/v1");
405 std::env::remove_var("HARNESS_LLM_TEST_URL");
406 }
407
408 #[test]
409 fn test_llm_base_url_trailing_slash() {
410 std::env::set_var("HARNESS_LLM_TEST_URL", "https://api.com/");
411 let url = llm_base_url();
412 assert_eq!(url, "https://api.com");
413 std::env::remove_var("HARNESS_LLM_TEST_URL");
414 }
415
416 #[test]
417 fn test_llm_model_default() {
418 std::env::remove_var("HARNESS_LLM_TEST_MODEL");
419 assert_eq!(llm_model(), "deepseek");
420 }
421
422 #[test]
423 fn test_llm_model_custom() {
424 std::env::set_var("HARNESS_LLM_TEST_MODEL", "gpt-4o");
425 assert_eq!(llm_model(), "gpt-4o");
426 std::env::remove_var("HARNESS_LLM_TEST_MODEL");
427 }
428
429 #[test]
430 fn test_extract_usage_partial() {
431 let json = serde_json::json!({
432 "usage": {
433 "prompt_tokens": 50
434 }
435 });
436 let usage = extract_usage(&json);
437 assert_eq!(usage.prompt_tokens, 50);
438 assert_eq!(usage.completion_tokens, 0);
439 assert_eq!(usage.total_tokens, 0);
440 }
441
442 #[test]
443 fn test_browser_headless_default() {
444 std::env::remove_var("HARNESS_BROWSER_HEADLESS");
445 assert!(crate::browser_headless());
446 }
447}