1#![allow(
13 clippy::expect_used,
14 clippy::unwrap_used,
15 clippy::panic,
16 clippy::missing_panics_doc
17)]
18
19pub mod a2a;
21mod auth;
23#[cfg(feature = "aws")]
25mod bedrock;
26pub mod budgets;
28pub mod costs;
30pub mod diagnostics;
32pub mod endpoints;
34pub mod events;
36pub mod mcp_client;
38pub mod mcp_server;
40pub mod redact;
42pub mod reporting;
44pub mod runner;
46pub mod scenario;
48pub mod selectors;
50pub mod vision;
52
53#[cfg(feature = "a2a-server")]
55pub mod a2a_server;
56
57#[cfg(feature = "macros")]
59pub mod macros;
60
61use std::collections::HashMap;
62use std::time::Duration;
63
64use serde_json::{json, Value};
65
66pub use costs::LlmResponse;
67pub use costs::LlmUsage;
68pub use scenario::AuthConfig;
69pub use scenario::AuthMode;
70pub use scenario::AwsConfig;
71pub use scenario::Provider;
72
73#[derive(Debug, Clone)]
76pub struct LlmConfig {
77 pub url: String,
79 pub model: String,
81 pub api_key: Option<String>,
83 pub headers: HashMap<String, String>,
85 pub timeout: Duration,
87 pub temperature: f64,
89 pub thinking: Option<bool>,
92 pub model_params: HashMap<String, Value>,
95 pub max_attempts: u32,
100 pub provider: Provider,
105 pub deployment: Option<String>,
108 pub api_version: Option<String>,
111 pub auth: AuthConfig,
113 pub header_commands: HashMap<String, String>,
116 pub aws: AwsConfig,
118}
119
120impl Default for LlmConfig {
121 fn default() -> Self {
122 Self {
123 url: String::new(),
124 model: String::new(),
125 api_key: None,
126 headers: HashMap::new(),
127 timeout: Duration::from_secs(60),
128 temperature: 0.0,
129 thinking: None,
130 model_params: HashMap::new(),
131 max_attempts: default_llm_attempts(),
132 provider: Provider::Openai,
133 deployment: None,
134 api_version: None,
135 auth: AuthConfig::default(),
136 header_commands: HashMap::new(),
137 aws: AwsConfig::default(),
138 }
139 }
140}
141
142impl LlmConfig {
143 #[must_use]
146 pub fn from_env() -> Self {
147 Self {
148 url: llm_base_url(),
149 model: llm_model(),
150 api_key: std::env::var("HARNESS_LLM_API_KEY").ok(),
151 headers: parse_headers_env(),
152 timeout: Duration::from_secs(60),
153 temperature: 0.0,
154 thinking: None,
155 model_params: HashMap::new(),
156 max_attempts: default_llm_attempts(),
157 provider: Provider::Openai,
158 deployment: None,
159 api_version: None,
160 auth: AuthConfig::default(),
161 header_commands: HashMap::new(),
162 aws: AwsConfig::default(),
163 }
164 }
165}
166
167pub const DEFAULT_AZURE_API_VERSION: &str = "2024-10-21";
170
171#[must_use]
174pub fn build_azure_url(base: &str, deployment: &str, api_version: &str) -> String {
175 let base = base.trim_end_matches('/');
176 let base = base
177 .strip_suffix("/openai")
178 .unwrap_or(base)
179 .trim_end_matches('/');
180 format!("{base}/openai/deployments/{deployment}/chat/completions?api-version={api_version}")
181}
182
183#[must_use]
186pub fn default_llm_attempts() -> u32 {
187 std::env::var("HARNESS_LLM_CALL_ATTEMPTS")
188 .ok()
189 .and_then(|v| v.parse().ok())
190 .filter(|n| *n >= 1)
191 .unwrap_or(3)
192}
193
194#[must_use]
198pub fn parse_headers_env() -> HashMap<String, String> {
199 let Ok(raw) = std::env::var("HARNESS_LLM_HEADERS") else {
200 return HashMap::new();
201 };
202 let Ok(json) = serde_json::from_str::<Value>(&raw) else {
203 return HashMap::new();
204 };
205 let Some(obj) = json.as_object() else {
206 return HashMap::new();
207 };
208 obj.iter()
209 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_owned())))
210 .collect()
211}
212
213#[must_use]
216pub fn base_url() -> String {
217 std::env::var("HARNESS_BROWSER_BASE_URL").unwrap_or_else(|_| "http://localhost:4200".to_owned())
218}
219
220#[must_use]
223pub fn llm_base_url() -> String {
224 std::env::var("HARNESS_LLM_TEST_URL")
225 .unwrap_or_else(|_| "http://localhost:8080".to_owned())
226 .trim_end_matches('/')
227 .to_owned()
228}
229
230#[must_use]
233pub fn llm_model() -> String {
234 std::env::var("HARNESS_LLM_TEST_MODEL").unwrap_or_else(|_| "deepseek".to_owned())
235}
236
237#[must_use]
240pub fn browser_headless() -> bool {
241 std::env::var("HARNESS_BROWSER_HEADLESS")
242 .map_or(true, |v| v != "0" && v.to_lowercase() != "false")
243}
244
245#[must_use]
247pub fn http_client(timeout: Duration) -> reqwest::Client {
248 reqwest::Client::builder()
249 .timeout(timeout)
250 .build()
251 .expect("build reqwest client")
252}
253
254#[must_use]
260pub async fn llm_chat(llm: &LlmConfig, system: &str, user: &str) -> Option<String> {
261 llm_chat_with_usage(llm, system, user)
262 .await
263 .map(|r| r.content)
264 .ok()
265}
266
267pub async fn llm_chat_with_usage(
287 llm: &LlmConfig,
288 system: &str,
289 user: &str,
290) -> Result<LlmResponse, String> {
291 chat_with_retry(llm, system, user, None).await
292}
293
294pub async fn llm_chat_vision_with_usage(
308 llm: &LlmConfig,
309 system: &str,
310 user: &str,
311 image_data_urls: Option<&[String]>,
312) -> Result<LlmResponse, String> {
313 chat_with_retry(llm, system, user, image_data_urls).await
314}
315
316pub async fn llm_chat_with_usage_chain(
328 primary: &LlmConfig,
329 fallbacks: &[LlmConfig],
330 system: &str,
331 user: &str,
332) -> Result<(LlmResponse, usize), String> {
333 chat_chain_with_retry(primary, fallbacks, system, user, None).await
334}
335
336pub async fn llm_chat_vision_with_usage_chain(
342 primary: &LlmConfig,
343 fallbacks: &[LlmConfig],
344 system: &str,
345 user: &str,
346 image_data_urls: Option<&[String]>,
347) -> Result<(LlmResponse, usize), String> {
348 chat_chain_with_retry(primary, fallbacks, system, user, image_data_urls).await
349}
350
351async fn chat_chain_with_retry(
354 primary: &LlmConfig,
355 fallbacks: &[LlmConfig],
356 system: &str,
357 user: &str,
358 image_data_urls: Option<&[String]>,
359) -> Result<(LlmResponse, usize), String> {
360 let mut failures: Vec<String> = Vec::new();
361 for (i, llm) in std::iter::once(primary).chain(fallbacks.iter()).enumerate() {
362 match chat_with_retry(llm, system, user, image_data_urls).await {
363 Ok(resp) => return Ok((resp, i)),
364 Err(e) => failures.push(format!("endpoint '{}' ({:?}): {e}", llm.url, llm.model)),
365 }
366 }
367 let details = failures.iter().fold(String::new(), |mut acc, f| {
368 use std::fmt::Write as _;
369 let _ = writeln!(acc, " - {f}");
370 acc
371 });
372 Err(format!(
373 "LLM call failed on all {} endpoint(s):\n{details}",
374 failures.len()
375 ))
376}
377
378async fn chat_with_retry(
380 llm: &LlmConfig,
381 system: &str,
382 user: &str,
383 image_data_urls: Option<&[String]>,
384) -> Result<LlmResponse, String> {
385 let client = http_client(llm.timeout);
386 let mut last_err = String::from("LLM call failed");
387 let mut attempts: u32 = 0;
388
389 while attempts < llm.max_attempts {
390 attempts += 1;
391 match llm_chat_once(&client, llm, system, user, image_data_urls).await {
392 Ok(resp) => return Ok(resp),
393 Err(err) => {
394 let backoff = match &err {
398 LlmCallError::EmptyBody { .. } => Duration::from_secs(3),
399 _ => Duration::from_millis(500 * u64::from(attempts)),
400 };
401 last_err = err.to_string();
402 if attempts >= llm.max_attempts || !err.is_retryable() {
403 break;
404 }
405 tokio::time::sleep(backoff).await;
406 }
407 }
408 }
409
410 Err(format!(
411 "LLM call failed after {attempts} attempt(s) (endpoint {url}): {last_err}",
412 url = llm.url
413 ))
414}
415
416#[must_use]
421fn build_messages(system: &str, user: &str, image_data_urls: Option<&[String]>) -> Value {
422 let user_content = image_data_urls.map_or_else(
423 || Value::String(user.to_owned()),
424 |urls| {
425 let mut parts = vec![json!({"type": "text", "text": user})];
426 for url in urls {
427 parts.push(json!({"type": "image_url", "image_url": {"url": url}}));
428 }
429 Value::Array(parts)
430 },
431 );
432 json!([
433 {"role": "system", "content": system},
434 {"role": "user", "content": user_content}
435 ])
436}
437
438enum LlmCallError {
442 Transport { message: String },
444 Http { status: u16, body: String },
446 InvalidJson {
448 status: u16,
449 detail: String,
450 body: String,
451 },
452 EmptyBody { status: u16 },
456 MissingContent { json: String },
458 Auth { message: String },
462}
463
464impl std::fmt::Display for LlmCallError {
465 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
466 match self {
467 Self::Transport { message } => write!(f, "LLM HTTP request failed: {message}"),
468 Self::Http { status, body } => {
469 write!(
470 f,
471 "LLM endpoint returned HTTP {status}: {}",
472 truncate(body, 300)
473 )
474 }
475 Self::InvalidJson {
476 status,
477 detail,
478 body,
479 } => write!(
480 f,
481 "LLM endpoint returned HTTP {status} with non-JSON body ({detail}): {}",
482 truncate(body, 300)
483 ),
484 Self::EmptyBody { status } => write!(
485 f,
486 "LLM endpoint returned HTTP {status} with an empty response (likely gateway warm-up)"
487 ),
488 Self::MissingContent { json } => write!(
489 f,
490 "LLM response missing choices[0].message.content: {}",
491 truncate(json, 300)
492 ),
493 Self::Auth { message } => write!(f, "LLM authentication failed: {message}"),
494 }
495 }
496}
497
498impl LlmCallError {
499 #[must_use]
503 fn is_retryable(&self) -> bool {
504 match self {
505 Self::Transport { .. }
506 | Self::MissingContent { .. }
507 | Self::EmptyBody { .. }
508 | Self::Auth { .. } => true,
509 Self::Http { status, .. } => {
510 *status == 408 || *status == 429 || (500..600).contains(status)
511 }
512 Self::InvalidJson { status, .. } => {
513 *status == 200 || *status == 408 || *status == 429 || (500..600).contains(status)
514 }
515 }
516 }
517}
518
519async fn llm_chat_once(
521 client: &reqwest::Client,
522 llm: &LlmConfig,
523 system: &str,
524 user: &str,
525 image_data_urls: Option<&[String]>,
526) -> Result<LlmResponse, LlmCallError> {
527 match llm.provider {
528 Provider::Openai | Provider::Azure => {
529 chat_openai_compat_once(client, llm, system, user, image_data_urls).await
530 }
531 Provider::Bedrock => {
532 #[cfg(feature = "aws")]
533 let result =
534 crate::bedrock::chat_once(client, llm, system, user, image_data_urls).await;
535 #[cfg(not(feature = "aws"))]
536 let result = Err(LlmCallError::Auth {
537 message:
538 "provider = \"bedrock\" requires building llm-browser-testkit with the `aws` \
539 cargo feature"
540 .to_owned(),
541 });
542 result
543 }
544 }
545}
546
547async fn chat_openai_compat_once(
551 client: &reqwest::Client,
552 llm: &LlmConfig,
553 system: &str,
554 user: &str,
555 image_data_urls: Option<&[String]>,
556) -> Result<LlmResponse, LlmCallError> {
557 let url = match llm.provider {
558 Provider::Openai => format!("{}/v1/chat/completions", llm.url),
559 Provider::Azure => {
560 let deployment = llm.deployment.clone().unwrap_or_else(|| llm.model.clone());
561 let api_version = llm
562 .api_version
563 .clone()
564 .unwrap_or_else(|| DEFAULT_AZURE_API_VERSION.to_owned());
565 build_azure_url(&llm.url, &deployment, &api_version)
566 }
567 Provider::Bedrock => unreachable!("bedrock is dispatched before this function"),
568 };
569
570 let mut headers: Vec<(String, String)> = Vec::new();
571 if llm.auth.mode == AuthMode::ApiKey {
572 match (&llm.auth.api_key_header, &llm.api_key) {
573 (Some(header_name), Some(key)) => {
575 headers.push((header_name.clone(), key.clone()));
576 }
577 (Some(header_name), None) => {
578 return Err(LlmCallError::Auth {
579 message: format!(
580 "auth.api_key_header `{header_name}` requires endpoint api_key to be set"
581 ),
582 });
583 }
584 (None, Some(key)) if llm.provider == Provider::Azure => {
586 headers.push(("api-key".to_owned(), key.clone()));
587 }
588 (None, Some(key)) => {
590 headers.push(("Authorization".to_owned(), format!("Bearer {key}")));
591 }
592 (None, None) => {}
593 }
594 } else if let Some(bearer) = auth::resolve_bearer_token(&llm.auth, llm.api_key.as_deref())
595 .await
596 .map_err(|message| LlmCallError::Auth { message })?
597 {
598 headers.push(("Authorization".to_owned(), format!("Bearer {bearer}")));
599 }
600 for (name, value) in &llm.headers {
601 headers.push((name.clone(), value.clone()));
602 }
603 for (name, command) in &llm.header_commands {
604 let value = auth::run_header_command(command)
605 .await
606 .map_err(|e| LlmCallError::Auth {
607 message: format!("header command for `{name}` failed: {e}"),
608 })?;
609 headers.push((name.clone(), value));
610 }
611
612 let payload = build_openai_payload(llm, system, user, image_data_urls);
613
614 let mut req = client.post(&url).header("Content-Type", "application/json");
615
616 for (name, value) in headers {
617 req = req.header(name.as_str(), value.as_str());
618 }
619
620 let resp = req
621 .json(&payload)
622 .send()
623 .await
624 .map_err(|e| LlmCallError::Transport {
625 message: e.to_string(),
626 })?;
627 let status = resp.status();
628 let status_u16 = status.as_u16();
629 let body = resp.text().await.unwrap_or_default();
630 if !status.is_success() {
631 return Err(LlmCallError::Http {
632 status: status_u16,
633 body,
634 });
635 }
636 if body.trim().is_empty() {
637 return Err(LlmCallError::EmptyBody { status: status_u16 });
640 }
641 let json: Value = match serde_json::from_str(&body) {
642 Ok(v) => v,
643 Err(e) => {
644 return Err(LlmCallError::InvalidJson {
645 status: status_u16,
646 detail: e.to_string(),
647 body,
648 });
649 }
650 };
651 let usage = costs::extract_usage(&json);
652 let content = json["choices"][0]["message"]["content"]
653 .as_str()
654 .map(String::from)
655 .ok_or_else(|| LlmCallError::MissingContent {
656 json: json.to_string(),
657 })?;
658
659 Ok(LlmResponse { content, usage })
660}
661
662#[must_use]
665fn build_openai_payload(
666 llm: &LlmConfig,
667 system: &str,
668 user: &str,
669 image_data_urls: Option<&[String]>,
670) -> Value {
671 let mut payload = serde_json::json!({
672 "model": llm.model,
673 "messages": build_messages(system, user, image_data_urls),
674 "max_tokens": 4096,
675 "temperature": llm.temperature
676 });
677 if let Some(think) = llm.thinking {
678 if think {
679 payload["thinking"] = serde_json::json!({"type": "enabled"});
680 } else {
681 payload["thinking"] = serde_json::json!({"type": "disabled"});
682 }
683 }
684 if !llm.model_params.is_empty() {
686 if let Value::Object(ref mut map) = payload {
687 for (key, val) in &llm.model_params {
688 map.insert(key.clone(), val.clone());
689 }
690 }
691 }
692 payload
693}
694
695pub const DOM_EXTRACT_JS: &str = r#"
698(() => {
699 const interactive = 'a, button, input, textarea, select, [role="button"], [onclick], [tabindex], [data-testid], [aria-label]';
700 const els = document.querySelectorAll(interactive);
701 const info = [];
702 const seen = new Set();
703 els.forEach((el, i) => {
704 const rect = el.getBoundingClientRect();
705 if (rect.width === 0 || rect.height === 0) return;
706 const tag = el.tagName.toLowerCase();
707 let selector = '';
708 if (el.id) selector = '#' + CSS.escape(el.id);
709 else if (el.getAttribute('data-testid')) selector = '[data-testid="' + el.getAttribute('data-testid') + '"]';
710 else if (el.name) selector = '[name="' + CSS.escape(el.name) + '"]';
711 else if (el.className && typeof el.className === 'string') {
712 const cls = el.className.trim().split(/\\s+/)[0];
713 if (cls) selector = tag + '.' + CSS.escape(cls);
714 }
715 if (!selector) selector = tag;
716 if (seen.has(selector)) return;
717 seen.add(selector);
718
719 let label = '';
720 const aria = el.getAttribute('aria-label');
721 if (aria) {
722 label = aria;
723 } else if (tag === 'input' || tag === 'textarea' || tag === 'select') {
724 label = el.placeholder || el.name || el.getAttribute('aria-label') || '';
725 if (el.type && !label) label = el.type;
726 } else {
727 label = (el.textContent || '').trim().substring(0, 80);
728 }
729
730 info.push(i + ': ' + selector + ' [' + tag + '] "' + label + '"');
731 });
732 return JSON.stringify(info);
733})()
734"#;
735
736#[must_use]
742pub fn truncate(s: &str, max_len: usize) -> String {
743 if s.len() <= max_len {
744 s.to_owned()
745 } else {
746 let cut = floor_char_boundary(s, max_len);
747 let omitted = s[cut..].chars().count();
748 format!("{}...<truncated {omitted} chars>", &s[..cut])
749 }
750}
751
752fn floor_char_boundary(s: &str, index: usize) -> usize {
754 let index = index.min(s.len());
755 let mut i = index;
756 while i > 0 && !s.is_char_boundary(i) {
757 i -= 1;
758 }
759 i
760}
761
762#[cfg(test)]
763mod tests {
764 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
767
768 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
770 ENV_LOCK
771 .lock()
772 .unwrap_or_else(std::sync::PoisonError::into_inner)
773 }
774
775 use crate::costs::extract_usage;
776 use crate::truncate;
777 use crate::{
778 default_llm_attempts, llm_base_url, llm_model, parse_headers_env, AuthConfig, AwsConfig,
779 LlmConfig, Provider,
780 };
781
782 fn mock_llm_server(status: u16, body: &'static str) -> String {
785 use std::io::{Read, Write};
786 use std::net::TcpListener;
787 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
788 let addr = listener.local_addr().unwrap();
789 std::thread::spawn(move || {
790 for stream in listener.incoming() {
791 let Ok(mut stream) = stream else { break };
792 let mut buf = [0u8; 4096];
793 let _ = stream.read(&mut buf);
794 let resp = format!(
795 "HTTP/1.1 {status} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
796 if status == 200 { "OK" } else { "ERROR" },
797 body.len(),
798 body
799 );
800 let _ = stream.write_all(resp.as_bytes());
801 }
802 });
803 format!("http://{addr}")
804 }
805
806 const PASS_BODY: &str = r#"{"choices":[{"message":{"content":"PASS"}}],"usage":{"prompt_tokens":7,"completion_tokens":2}}"#;
807
808 fn cfg(url: &str, attempts: u32) -> LlmConfig {
809 LlmConfig {
810 url: url.to_owned(),
811 model: "mock".to_owned(),
812 api_key: None,
813 headers: std::collections::HashMap::new(),
814 timeout: std::time::Duration::from_secs(10),
815 temperature: 0.0,
816 thinking: None,
817 model_params: std::collections::HashMap::new(),
818 max_attempts: attempts,
819 provider: Provider::Openai,
820 deployment: None,
821 api_version: None,
822 auth: AuthConfig::default(),
823 header_commands: std::collections::HashMap::new(),
824 aws: AwsConfig::default(),
825 }
826 }
827
828 #[tokio::test]
829 async fn test_chain_primary_success_returns_index_zero() {
830 let good = mock_llm_server(200, PASS_BODY);
831 let (resp, idx) = crate::llm_chat_with_usage_chain(&cfg(&good, 2), &[], "s", "u")
832 .await
833 .expect("primary endpoint should answer");
834 assert_eq!(idx, 0);
835 assert_eq!(resp.content, "PASS");
836 assert_eq!(resp.usage.prompt_tokens, 7);
837 }
838
839 #[tokio::test]
840 async fn test_chain_falls_back_on_empty_200() {
841 let broken = mock_llm_server(200, "");
845 let good = mock_llm_server(200, PASS_BODY);
846 let (resp, idx) =
847 crate::llm_chat_with_usage_chain(&cfg(&broken, 2), &[cfg(&good, 2)], "s", "u")
848 .await
849 .expect("fallback endpoint should answer");
850 assert_eq!(idx, 1);
851 assert_eq!(resp.content, "PASS");
852 }
853
854 #[tokio::test]
855 async fn test_chain_reports_all_endpoints_on_total_failure() {
856 let broken1 = mock_llm_server(200, "");
857 let broken2 = mock_llm_server(503, "unavailable");
858 let err =
859 crate::llm_chat_with_usage_chain(&cfg(&broken1, 2), &[cfg(&broken2, 2)], "s", "u")
860 .await
861 .expect_err("both endpoints fail");
862 assert!(err.contains("all 2 endpoint(s)"), "got: {err}");
863 assert!(err.contains(&broken1), "primary URL missing: {err}");
864 assert!(err.contains(&broken2), "fallback URL missing: {err}");
865 }
866
867 #[test]
868 fn test_default_llm_attempts_env() {
869 let _env = env_guard();
870 std::env::set_var("HARNESS_LLM_CALL_ATTEMPTS", "7");
871 assert_eq!(default_llm_attempts(), 7);
872 std::env::set_var("HARNESS_LLM_CALL_ATTEMPTS", "0");
873 assert_eq!(default_llm_attempts(), 3, "0 must fall back to default");
874 std::env::set_var("HARNESS_LLM_CALL_ATTEMPTS", "junk");
875 assert_eq!(default_llm_attempts(), 3, "non-numeric must fall back");
876 std::env::remove_var("HARNESS_LLM_CALL_ATTEMPTS");
877 assert_eq!(default_llm_attempts(), 3);
878 }
879
880 #[test]
881 fn test_truncate_short() {
882 assert_eq!(truncate("hello", 10), "hello");
883 }
884
885 #[test]
886 fn test_truncate_long() {
887 let result = truncate("hello world", 5);
888 assert!(result.contains("<truncated 6 chars>"));
889 assert!(result.starts_with("hello"));
890 }
891
892 #[test]
893 fn test_truncate_exact_length() {
894 assert_eq!(truncate("abcde", 5), "abcde");
895 }
896
897 #[test]
898 fn test_truncate_empty() {
899 assert_eq!(truncate("", 5), "");
900 }
901
902 #[test]
903 fn test_parse_headers_env_empty() {
904 let _env = env_guard();
905 std::env::remove_var("HARNESS_LLM_HEADERS");
906 let h = parse_headers_env();
907 assert!(h.is_empty());
908 }
909
910 #[test]
911 fn test_parse_headers_env_valid() {
912 let _env = env_guard();
913 std::env::set_var("HARNESS_LLM_HEADERS", r#"{"X-Org":"acme","X-Version":"1"}"#);
914 let h = parse_headers_env();
915 assert_eq!(h.get("X-Org").map(String::as_str), Some("acme"));
916 assert_eq!(h.get("X-Version").map(String::as_str), Some("1"));
917 std::env::remove_var("HARNESS_LLM_HEADERS");
918 }
919
920 #[test]
921 fn test_parse_headers_env_invalid_json() {
922 let _env = env_guard();
923 std::env::set_var("HARNESS_LLM_HEADERS", "not-json");
924 let h = parse_headers_env();
925 assert!(h.is_empty());
926 std::env::remove_var("HARNESS_LLM_HEADERS");
927 }
928
929 #[test]
930 fn test_llm_config_from_env_defaults() {
931 let _env = env_guard();
932 #[allow(clippy::float_cmp)]
933 {
934 let config = LlmConfig::from_env();
935 assert_eq!(config.temperature, 0.0);
936 assert!(config.thinking.is_none());
937 assert!(config.model_params.is_empty());
938 }
939 }
940
941 #[test]
942 fn test_extract_usage_full() {
943 let json = serde_json::json!({
944 "usage": {
945 "prompt_tokens": 100,
946 "completion_tokens": 200,
947 "total_tokens": 300
948 }
949 });
950 let usage = extract_usage(&json);
951 assert_eq!(usage.prompt_tokens, 100);
952 assert_eq!(usage.completion_tokens, 200);
953 assert_eq!(usage.total_tokens, 300);
954 }
955
956 #[test]
957 fn test_extract_usage_empty() {
958 let json = serde_json::json!({});
959 let usage = extract_usage(&json);
960 assert_eq!(usage.prompt_tokens, 0);
961 assert_eq!(usage.completion_tokens, 0);
962 assert_eq!(usage.total_tokens, 0);
963 }
964
965 #[test]
966 fn test_truncate_unicode() {
967 assert_eq!(truncate("héllo", 3), "hé...<truncated 3 chars>");
970 assert_eq!(truncate("hello", 5), "hello");
972 }
973
974 #[test]
975 fn test_truncate_utf8_boundary_mid_char_does_not_panic() {
976 let result = truncate("héllo", 2);
982 assert_eq!(result, "h...<truncated 4 chars>");
983
984 let cut_inside = truncate("🎉🎉🎉 boom", 5);
988 assert_eq!(cut_inside, "🎉...<truncated 7 chars>");
989 assert!(is_valid_utf8(&cut_inside), "result must stay valid UTF-8");
990 }
991
992 #[test]
993 fn test_truncate_utf8_exact_omitted_count() {
994 assert_eq!(truncate("abcdefghij", 5), "abcde...<truncated 5 chars>");
996 assert_eq!(truncate("ééé", 6), "ééé");
999 assert_eq!(truncate("ééé", 5), "éé...<truncated 1 chars>");
1000 }
1001
1002 fn is_valid_utf8(s: &str) -> bool {
1003 std::str::from_utf8(s.as_bytes()).is_ok()
1004 }
1005
1006 #[test]
1007 fn test_parse_headers_env_non_object() {
1008 let _env = env_guard();
1009 std::env::set_var("HARNESS_LLM_HEADERS", "[1, 2, 3]");
1010 let h = parse_headers_env();
1011 assert!(h.is_empty());
1012 std::env::remove_var("HARNESS_LLM_HEADERS");
1013 }
1014
1015 #[test]
1016 fn test_parse_headers_env_nested_values_filtered() {
1017 let _env = env_guard();
1018 std::env::set_var(
1019 "HARNESS_LLM_HEADERS",
1020 r#"{"str":"val","num":42,"bool":true}"#,
1021 );
1022 let h = parse_headers_env();
1023 assert_eq!(h.get("str").map(String::as_str), Some("val"));
1024 assert!(!h.contains_key("num"));
1025 assert!(!h.contains_key("bool"));
1026 std::env::remove_var("HARNESS_LLM_HEADERS");
1027 }
1028
1029 #[test]
1030 fn test_llm_config_has_default_model() {
1031 let _env = env_guard();
1032 let config = LlmConfig::from_env();
1033 assert!(!config.model.is_empty());
1034 }
1035
1036 #[test]
1037 fn test_llm_base_url_default() {
1038 let _env = env_guard();
1039 std::env::remove_var("HARNESS_LLM_TEST_URL");
1040 let url = llm_base_url();
1041 assert_eq!(url, "http://localhost:8080");
1042 }
1043
1044 #[test]
1045 fn test_llm_base_url_custom() {
1046 let _env = env_guard();
1047 std::env::set_var("HARNESS_LLM_TEST_URL", "https://custom.api.com/v1");
1048 let url = llm_base_url();
1049 assert_eq!(url, "https://custom.api.com/v1");
1050 std::env::remove_var("HARNESS_LLM_TEST_URL");
1051 }
1052
1053 #[test]
1054 fn test_llm_base_url_trailing_slash() {
1055 let _env = env_guard();
1056 std::env::set_var("HARNESS_LLM_TEST_URL", "https://api.com/");
1057 let url = llm_base_url();
1058 assert_eq!(url, "https://api.com");
1059 std::env::remove_var("HARNESS_LLM_TEST_URL");
1060 }
1061
1062 #[test]
1063 fn test_llm_model_default() {
1064 let _env = env_guard();
1065 std::env::remove_var("HARNESS_LLM_TEST_MODEL");
1066 assert_eq!(llm_model(), "deepseek");
1067 }
1068
1069 #[test]
1070 fn test_llm_model_custom() {
1071 let _env = env_guard();
1072 std::env::set_var("HARNESS_LLM_TEST_MODEL", "gpt-4o");
1073 assert_eq!(llm_model(), "gpt-4o");
1074 std::env::remove_var("HARNESS_LLM_TEST_MODEL");
1075 }
1076
1077 #[test]
1078 fn test_extract_usage_partial() {
1079 let _env = env_guard();
1080 let json = serde_json::json!({
1081 "usage": {
1082 "prompt_tokens": 50
1083 }
1084 });
1085 let usage = extract_usage(&json);
1086 assert_eq!(usage.prompt_tokens, 50);
1087 assert_eq!(usage.completion_tokens, 0);
1088 assert_eq!(usage.total_tokens, 0);
1089 }
1090
1091 #[test]
1092 fn test_browser_headless_default() {
1093 let _env = env_guard();
1094 std::env::remove_var("HARNESS_BROWSER_HEADLESS");
1095 assert!(crate::browser_headless());
1096 }
1097}