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(
306 llm: &LlmConfig,
307 system: &str,
308 user: &str,
309 image_data_url: &str,
310) -> Result<LlmResponse, String> {
311 chat_with_retry(llm, system, user, Some(image_data_url)).await
312}
313
314pub async fn llm_chat_with_usage_chain(
326 primary: &LlmConfig,
327 fallbacks: &[LlmConfig],
328 system: &str,
329 user: &str,
330) -> Result<(LlmResponse, usize), String> {
331 chat_chain_with_retry(primary, fallbacks, system, user, None).await
332}
333
334pub async fn llm_chat_vision_with_usage_chain(
340 primary: &LlmConfig,
341 fallbacks: &[LlmConfig],
342 system: &str,
343 user: &str,
344 image_data_url: &str,
345) -> Result<(LlmResponse, usize), String> {
346 chat_chain_with_retry(primary, fallbacks, system, user, Some(image_data_url)).await
347}
348
349async fn chat_chain_with_retry(
352 primary: &LlmConfig,
353 fallbacks: &[LlmConfig],
354 system: &str,
355 user: &str,
356 image_data_url: Option<&str>,
357) -> Result<(LlmResponse, usize), String> {
358 let mut failures: Vec<String> = Vec::new();
359 for (i, llm) in std::iter::once(primary).chain(fallbacks.iter()).enumerate() {
360 match chat_with_retry(llm, system, user, image_data_url).await {
361 Ok(resp) => return Ok((resp, i)),
362 Err(e) => failures.push(format!("endpoint '{}' ({:?}): {e}", llm.url, llm.model)),
363 }
364 }
365 let details = failures.iter().fold(String::new(), |mut acc, f| {
366 use std::fmt::Write as _;
367 let _ = writeln!(acc, " - {f}");
368 acc
369 });
370 Err(format!(
371 "LLM call failed on all {} endpoint(s):\n{details}",
372 failures.len()
373 ))
374}
375
376async fn chat_with_retry(
378 llm: &LlmConfig,
379 system: &str,
380 user: &str,
381 image_data_url: Option<&str>,
382) -> Result<LlmResponse, String> {
383 let client = http_client(llm.timeout);
384 let mut last_err = String::from("LLM call failed");
385 let mut attempts: u32 = 0;
386
387 while attempts < llm.max_attempts {
388 attempts += 1;
389 match llm_chat_once(&client, llm, system, user, image_data_url).await {
390 Ok(resp) => return Ok(resp),
391 Err(err) => {
392 let backoff = match &err {
396 LlmCallError::EmptyBody { .. } => Duration::from_secs(3),
397 _ => Duration::from_millis(500 * u64::from(attempts)),
398 };
399 last_err = err.to_string();
400 if attempts >= llm.max_attempts || !err.is_retryable() {
401 break;
402 }
403 tokio::time::sleep(backoff).await;
404 }
405 }
406 }
407
408 Err(format!(
409 "LLM call failed after {attempts} attempt(s) (endpoint {url}): {last_err}",
410 url = llm.url
411 ))
412}
413
414#[must_use]
418fn build_messages(system: &str, user: &str, image_data_url: Option<&str>) -> Value {
419 let user_content = image_data_url.map_or_else(
420 || Value::String(user.to_owned()),
421 |url| {
422 json!([
423 {"type": "text", "text": user},
424 {"type": "image_url", "image_url": {"url": url}}
425 ])
426 },
427 );
428 json!([
429 {"role": "system", "content": system},
430 {"role": "user", "content": user_content}
431 ])
432}
433
434enum LlmCallError {
438 Transport { message: String },
440 Http { status: u16, body: String },
442 InvalidJson {
444 status: u16,
445 detail: String,
446 body: String,
447 },
448 EmptyBody { status: u16 },
452 MissingContent { json: String },
454 Auth { message: String },
458}
459
460impl std::fmt::Display for LlmCallError {
461 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
462 match self {
463 Self::Transport { message } => write!(f, "LLM HTTP request failed: {message}"),
464 Self::Http { status, body } => {
465 write!(
466 f,
467 "LLM endpoint returned HTTP {status}: {}",
468 truncate(body, 300)
469 )
470 }
471 Self::InvalidJson {
472 status,
473 detail,
474 body,
475 } => write!(
476 f,
477 "LLM endpoint returned HTTP {status} with non-JSON body ({detail}): {}",
478 truncate(body, 300)
479 ),
480 Self::EmptyBody { status } => write!(
481 f,
482 "LLM endpoint returned HTTP {status} with an empty response (likely gateway warm-up)"
483 ),
484 Self::MissingContent { json } => write!(
485 f,
486 "LLM response missing choices[0].message.content: {}",
487 truncate(json, 300)
488 ),
489 Self::Auth { message } => write!(f, "LLM authentication failed: {message}"),
490 }
491 }
492}
493
494impl LlmCallError {
495 #[must_use]
499 fn is_retryable(&self) -> bool {
500 match self {
501 Self::Transport { .. }
502 | Self::MissingContent { .. }
503 | Self::EmptyBody { .. }
504 | Self::Auth { .. } => true,
505 Self::Http { status, .. } => {
506 *status == 408 || *status == 429 || (500..600).contains(status)
507 }
508 Self::InvalidJson { status, .. } => {
509 *status == 200 || *status == 408 || *status == 429 || (500..600).contains(status)
510 }
511 }
512 }
513}
514
515async fn llm_chat_once(
517 client: &reqwest::Client,
518 llm: &LlmConfig,
519 system: &str,
520 user: &str,
521 image_data_url: Option<&str>,
522) -> Result<LlmResponse, LlmCallError> {
523 match llm.provider {
524 Provider::Openai | Provider::Azure => {
525 chat_openai_compat_once(client, llm, system, user, image_data_url).await
526 }
527 Provider::Bedrock => {
528 #[cfg(feature = "aws")]
529 let result = crate::bedrock::chat_once(client, llm, system, user, image_data_url).await;
530 #[cfg(not(feature = "aws"))]
531 let result = Err(LlmCallError::Auth {
532 message:
533 "provider = \"bedrock\" requires building llm-browser-testkit with the `aws` \
534 cargo feature"
535 .to_owned(),
536 });
537 result
538 }
539 }
540}
541
542async fn chat_openai_compat_once(
546 client: &reqwest::Client,
547 llm: &LlmConfig,
548 system: &str,
549 user: &str,
550 image_data_url: Option<&str>,
551) -> Result<LlmResponse, LlmCallError> {
552 let url = match llm.provider {
553 Provider::Openai => format!("{}/v1/chat/completions", llm.url),
554 Provider::Azure => {
555 let deployment = llm.deployment.clone().unwrap_or_else(|| llm.model.clone());
556 let api_version = llm
557 .api_version
558 .clone()
559 .unwrap_or_else(|| DEFAULT_AZURE_API_VERSION.to_owned());
560 build_azure_url(&llm.url, &deployment, &api_version)
561 }
562 Provider::Bedrock => unreachable!("bedrock is dispatched before this function"),
563 };
564
565 let mut headers: Vec<(String, String)> = Vec::new();
566 if llm.auth.mode == AuthMode::ApiKey {
567 match (&llm.auth.api_key_header, &llm.api_key) {
568 (Some(header_name), Some(key)) => {
570 headers.push((header_name.clone(), key.clone()));
571 }
572 (Some(header_name), None) => {
573 return Err(LlmCallError::Auth {
574 message: format!(
575 "auth.api_key_header `{header_name}` requires endpoint api_key to be set"
576 ),
577 });
578 }
579 (None, Some(key)) if llm.provider == Provider::Azure => {
581 headers.push(("api-key".to_owned(), key.clone()));
582 }
583 (None, Some(key)) => {
585 headers.push(("Authorization".to_owned(), format!("Bearer {key}")));
586 }
587 (None, None) => {}
588 }
589 } else if let Some(bearer) = auth::resolve_bearer_token(&llm.auth, llm.api_key.as_deref())
590 .await
591 .map_err(|message| LlmCallError::Auth { message })?
592 {
593 headers.push(("Authorization".to_owned(), format!("Bearer {bearer}")));
594 }
595 for (name, value) in &llm.headers {
596 headers.push((name.clone(), value.clone()));
597 }
598 for (name, command) in &llm.header_commands {
599 let value = auth::run_header_command(command)
600 .await
601 .map_err(|e| LlmCallError::Auth {
602 message: format!("header command for `{name}` failed: {e}"),
603 })?;
604 headers.push((name.clone(), value));
605 }
606
607 let payload = build_openai_payload(llm, system, user, image_data_url);
608
609 let mut req = client.post(&url).header("Content-Type", "application/json");
610
611 for (name, value) in headers {
612 req = req.header(name.as_str(), value.as_str());
613 }
614
615 let resp = req
616 .json(&payload)
617 .send()
618 .await
619 .map_err(|e| LlmCallError::Transport {
620 message: e.to_string(),
621 })?;
622 let status = resp.status();
623 let status_u16 = status.as_u16();
624 let body = resp.text().await.unwrap_or_default();
625 if !status.is_success() {
626 return Err(LlmCallError::Http {
627 status: status_u16,
628 body,
629 });
630 }
631 if body.trim().is_empty() {
632 return Err(LlmCallError::EmptyBody { status: status_u16 });
635 }
636 let json: Value = match serde_json::from_str(&body) {
637 Ok(v) => v,
638 Err(e) => {
639 return Err(LlmCallError::InvalidJson {
640 status: status_u16,
641 detail: e.to_string(),
642 body,
643 });
644 }
645 };
646 let usage = costs::extract_usage(&json);
647 let content = json["choices"][0]["message"]["content"]
648 .as_str()
649 .map(String::from)
650 .ok_or_else(|| LlmCallError::MissingContent {
651 json: json.to_string(),
652 })?;
653
654 Ok(LlmResponse { content, usage })
655}
656
657#[must_use]
660fn build_openai_payload(
661 llm: &LlmConfig,
662 system: &str,
663 user: &str,
664 image_data_url: Option<&str>,
665) -> Value {
666 let mut payload = serde_json::json!({
667 "model": llm.model,
668 "messages": build_messages(system, user, image_data_url),
669 "max_tokens": 4096,
670 "temperature": llm.temperature
671 });
672 if let Some(think) = llm.thinking {
673 if think {
674 payload["thinking"] = serde_json::json!({"type": "enabled"});
675 } else {
676 payload["thinking"] = serde_json::json!({"type": "disabled"});
677 }
678 }
679 if !llm.model_params.is_empty() {
681 if let Value::Object(ref mut map) = payload {
682 for (key, val) in &llm.model_params {
683 map.insert(key.clone(), val.clone());
684 }
685 }
686 }
687 payload
688}
689
690pub const DOM_EXTRACT_JS: &str = r#"
693(() => {
694 const interactive = 'a, button, input, textarea, select, [role="button"], [onclick], [tabindex], [data-testid], [aria-label]';
695 const els = document.querySelectorAll(interactive);
696 const info = [];
697 const seen = new Set();
698 els.forEach((el, i) => {
699 const rect = el.getBoundingClientRect();
700 if (rect.width === 0 || rect.height === 0) return;
701 const tag = el.tagName.toLowerCase();
702 let selector = '';
703 if (el.id) selector = '#' + CSS.escape(el.id);
704 else if (el.getAttribute('data-testid')) selector = '[data-testid="' + el.getAttribute('data-testid') + '"]';
705 else if (el.name) selector = '[name="' + CSS.escape(el.name) + '"]';
706 else if (el.className && typeof el.className === 'string') {
707 const cls = el.className.trim().split(/\\s+/)[0];
708 if (cls) selector = tag + '.' + CSS.escape(cls);
709 }
710 if (!selector) selector = tag;
711 if (seen.has(selector)) return;
712 seen.add(selector);
713
714 let label = '';
715 const aria = el.getAttribute('aria-label');
716 if (aria) {
717 label = aria;
718 } else if (tag === 'input' || tag === 'textarea' || tag === 'select') {
719 label = el.placeholder || el.name || el.getAttribute('aria-label') || '';
720 if (el.type && !label) label = el.type;
721 } else {
722 label = (el.textContent || '').trim().substring(0, 80);
723 }
724
725 info.push(i + ': ' + selector + ' [' + tag + '] "' + label + '"');
726 });
727 return JSON.stringify(info);
728})()
729"#;
730
731#[must_use]
737pub fn truncate(s: &str, max_len: usize) -> String {
738 if s.len() <= max_len {
739 s.to_owned()
740 } else {
741 let cut = floor_char_boundary(s, max_len);
742 let omitted = s[cut..].chars().count();
743 format!("{}...<truncated {omitted} chars>", &s[..cut])
744 }
745}
746
747fn floor_char_boundary(s: &str, index: usize) -> usize {
749 let index = index.min(s.len());
750 let mut i = index;
751 while i > 0 && !s.is_char_boundary(i) {
752 i -= 1;
753 }
754 i
755}
756
757#[cfg(test)]
758mod tests {
759 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
762
763 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
765 ENV_LOCK
766 .lock()
767 .unwrap_or_else(std::sync::PoisonError::into_inner)
768 }
769
770 use crate::costs::extract_usage;
771 use crate::truncate;
772 use crate::{
773 default_llm_attempts, llm_base_url, llm_model, parse_headers_env, AuthConfig, AwsConfig,
774 LlmConfig, Provider,
775 };
776
777 fn mock_llm_server(status: u16, body: &'static str) -> String {
780 use std::io::{Read, Write};
781 use std::net::TcpListener;
782 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
783 let addr = listener.local_addr().unwrap();
784 std::thread::spawn(move || {
785 for stream in listener.incoming() {
786 let Ok(mut stream) = stream else { break };
787 let mut buf = [0u8; 4096];
788 let _ = stream.read(&mut buf);
789 let resp = format!(
790 "HTTP/1.1 {status} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
791 if status == 200 { "OK" } else { "ERROR" },
792 body.len(),
793 body
794 );
795 let _ = stream.write_all(resp.as_bytes());
796 }
797 });
798 format!("http://{addr}")
799 }
800
801 const PASS_BODY: &str = r#"{"choices":[{"message":{"content":"PASS"}}],"usage":{"prompt_tokens":7,"completion_tokens":2}}"#;
802
803 fn cfg(url: &str, attempts: u32) -> LlmConfig {
804 LlmConfig {
805 url: url.to_owned(),
806 model: "mock".to_owned(),
807 api_key: None,
808 headers: std::collections::HashMap::new(),
809 timeout: std::time::Duration::from_secs(10),
810 temperature: 0.0,
811 thinking: None,
812 model_params: std::collections::HashMap::new(),
813 max_attempts: attempts,
814 provider: Provider::Openai,
815 deployment: None,
816 api_version: None,
817 auth: AuthConfig::default(),
818 header_commands: std::collections::HashMap::new(),
819 aws: AwsConfig::default(),
820 }
821 }
822
823 #[tokio::test]
824 async fn test_chain_primary_success_returns_index_zero() {
825 let good = mock_llm_server(200, PASS_BODY);
826 let (resp, idx) = crate::llm_chat_with_usage_chain(&cfg(&good, 2), &[], "s", "u")
827 .await
828 .expect("primary endpoint should answer");
829 assert_eq!(idx, 0);
830 assert_eq!(resp.content, "PASS");
831 assert_eq!(resp.usage.prompt_tokens, 7);
832 }
833
834 #[tokio::test]
835 async fn test_chain_falls_back_on_empty_200() {
836 let broken = mock_llm_server(200, "");
840 let good = mock_llm_server(200, PASS_BODY);
841 let (resp, idx) =
842 crate::llm_chat_with_usage_chain(&cfg(&broken, 2), &[cfg(&good, 2)], "s", "u")
843 .await
844 .expect("fallback endpoint should answer");
845 assert_eq!(idx, 1);
846 assert_eq!(resp.content, "PASS");
847 }
848
849 #[tokio::test]
850 async fn test_chain_reports_all_endpoints_on_total_failure() {
851 let broken1 = mock_llm_server(200, "");
852 let broken2 = mock_llm_server(503, "unavailable");
853 let err =
854 crate::llm_chat_with_usage_chain(&cfg(&broken1, 2), &[cfg(&broken2, 2)], "s", "u")
855 .await
856 .expect_err("both endpoints fail");
857 assert!(err.contains("all 2 endpoint(s)"), "got: {err}");
858 assert!(err.contains(&broken1), "primary URL missing: {err}");
859 assert!(err.contains(&broken2), "fallback URL missing: {err}");
860 }
861
862 #[test]
863 fn test_default_llm_attempts_env() {
864 let _env = env_guard();
865 std::env::set_var("HARNESS_LLM_CALL_ATTEMPTS", "7");
866 assert_eq!(default_llm_attempts(), 7);
867 std::env::set_var("HARNESS_LLM_CALL_ATTEMPTS", "0");
868 assert_eq!(default_llm_attempts(), 3, "0 must fall back to default");
869 std::env::set_var("HARNESS_LLM_CALL_ATTEMPTS", "junk");
870 assert_eq!(default_llm_attempts(), 3, "non-numeric must fall back");
871 std::env::remove_var("HARNESS_LLM_CALL_ATTEMPTS");
872 assert_eq!(default_llm_attempts(), 3);
873 }
874
875 #[test]
876 fn test_truncate_short() {
877 assert_eq!(truncate("hello", 10), "hello");
878 }
879
880 #[test]
881 fn test_truncate_long() {
882 let result = truncate("hello world", 5);
883 assert!(result.contains("<truncated 6 chars>"));
884 assert!(result.starts_with("hello"));
885 }
886
887 #[test]
888 fn test_truncate_exact_length() {
889 assert_eq!(truncate("abcde", 5), "abcde");
890 }
891
892 #[test]
893 fn test_truncate_empty() {
894 assert_eq!(truncate("", 5), "");
895 }
896
897 #[test]
898 fn test_parse_headers_env_empty() {
899 let _env = env_guard();
900 std::env::remove_var("HARNESS_LLM_HEADERS");
901 let h = parse_headers_env();
902 assert!(h.is_empty());
903 }
904
905 #[test]
906 fn test_parse_headers_env_valid() {
907 let _env = env_guard();
908 std::env::set_var("HARNESS_LLM_HEADERS", r#"{"X-Org":"acme","X-Version":"1"}"#);
909 let h = parse_headers_env();
910 assert_eq!(h.get("X-Org").map(String::as_str), Some("acme"));
911 assert_eq!(h.get("X-Version").map(String::as_str), Some("1"));
912 std::env::remove_var("HARNESS_LLM_HEADERS");
913 }
914
915 #[test]
916 fn test_parse_headers_env_invalid_json() {
917 let _env = env_guard();
918 std::env::set_var("HARNESS_LLM_HEADERS", "not-json");
919 let h = parse_headers_env();
920 assert!(h.is_empty());
921 std::env::remove_var("HARNESS_LLM_HEADERS");
922 }
923
924 #[test]
925 fn test_llm_config_from_env_defaults() {
926 let _env = env_guard();
927 #[allow(clippy::float_cmp)]
928 {
929 let config = LlmConfig::from_env();
930 assert_eq!(config.temperature, 0.0);
931 assert!(config.thinking.is_none());
932 assert!(config.model_params.is_empty());
933 }
934 }
935
936 #[test]
937 fn test_extract_usage_full() {
938 let json = serde_json::json!({
939 "usage": {
940 "prompt_tokens": 100,
941 "completion_tokens": 200,
942 "total_tokens": 300
943 }
944 });
945 let usage = extract_usage(&json);
946 assert_eq!(usage.prompt_tokens, 100);
947 assert_eq!(usage.completion_tokens, 200);
948 assert_eq!(usage.total_tokens, 300);
949 }
950
951 #[test]
952 fn test_extract_usage_empty() {
953 let json = serde_json::json!({});
954 let usage = extract_usage(&json);
955 assert_eq!(usage.prompt_tokens, 0);
956 assert_eq!(usage.completion_tokens, 0);
957 assert_eq!(usage.total_tokens, 0);
958 }
959
960 #[test]
961 fn test_truncate_unicode() {
962 assert_eq!(truncate("héllo", 3), "hé...<truncated 3 chars>");
965 assert_eq!(truncate("hello", 5), "hello");
967 }
968
969 #[test]
970 fn test_truncate_utf8_boundary_mid_char_does_not_panic() {
971 let result = truncate("héllo", 2);
977 assert_eq!(result, "h...<truncated 4 chars>");
978
979 let cut_inside = truncate("🎉🎉🎉 boom", 5);
983 assert_eq!(cut_inside, "🎉...<truncated 7 chars>");
984 assert!(is_valid_utf8(&cut_inside), "result must stay valid UTF-8");
985 }
986
987 #[test]
988 fn test_truncate_utf8_exact_omitted_count() {
989 assert_eq!(truncate("abcdefghij", 5), "abcde...<truncated 5 chars>");
991 assert_eq!(truncate("ééé", 6), "ééé");
994 assert_eq!(truncate("ééé", 5), "éé...<truncated 1 chars>");
995 }
996
997 fn is_valid_utf8(s: &str) -> bool {
998 std::str::from_utf8(s.as_bytes()).is_ok()
999 }
1000
1001 #[test]
1002 fn test_parse_headers_env_non_object() {
1003 let _env = env_guard();
1004 std::env::set_var("HARNESS_LLM_HEADERS", "[1, 2, 3]");
1005 let h = parse_headers_env();
1006 assert!(h.is_empty());
1007 std::env::remove_var("HARNESS_LLM_HEADERS");
1008 }
1009
1010 #[test]
1011 fn test_parse_headers_env_nested_values_filtered() {
1012 let _env = env_guard();
1013 std::env::set_var(
1014 "HARNESS_LLM_HEADERS",
1015 r#"{"str":"val","num":42,"bool":true}"#,
1016 );
1017 let h = parse_headers_env();
1018 assert_eq!(h.get("str").map(String::as_str), Some("val"));
1019 assert!(!h.contains_key("num"));
1020 assert!(!h.contains_key("bool"));
1021 std::env::remove_var("HARNESS_LLM_HEADERS");
1022 }
1023
1024 #[test]
1025 fn test_llm_config_has_default_model() {
1026 let _env = env_guard();
1027 let config = LlmConfig::from_env();
1028 assert!(!config.model.is_empty());
1029 }
1030
1031 #[test]
1032 fn test_llm_base_url_default() {
1033 let _env = env_guard();
1034 std::env::remove_var("HARNESS_LLM_TEST_URL");
1035 let url = llm_base_url();
1036 assert_eq!(url, "http://localhost:8080");
1037 }
1038
1039 #[test]
1040 fn test_llm_base_url_custom() {
1041 let _env = env_guard();
1042 std::env::set_var("HARNESS_LLM_TEST_URL", "https://custom.api.com/v1");
1043 let url = llm_base_url();
1044 assert_eq!(url, "https://custom.api.com/v1");
1045 std::env::remove_var("HARNESS_LLM_TEST_URL");
1046 }
1047
1048 #[test]
1049 fn test_llm_base_url_trailing_slash() {
1050 let _env = env_guard();
1051 std::env::set_var("HARNESS_LLM_TEST_URL", "https://api.com/");
1052 let url = llm_base_url();
1053 assert_eq!(url, "https://api.com");
1054 std::env::remove_var("HARNESS_LLM_TEST_URL");
1055 }
1056
1057 #[test]
1058 fn test_llm_model_default() {
1059 let _env = env_guard();
1060 std::env::remove_var("HARNESS_LLM_TEST_MODEL");
1061 assert_eq!(llm_model(), "deepseek");
1062 }
1063
1064 #[test]
1065 fn test_llm_model_custom() {
1066 let _env = env_guard();
1067 std::env::set_var("HARNESS_LLM_TEST_MODEL", "gpt-4o");
1068 assert_eq!(llm_model(), "gpt-4o");
1069 std::env::remove_var("HARNESS_LLM_TEST_MODEL");
1070 }
1071
1072 #[test]
1073 fn test_extract_usage_partial() {
1074 let _env = env_guard();
1075 let json = serde_json::json!({
1076 "usage": {
1077 "prompt_tokens": 50
1078 }
1079 });
1080 let usage = extract_usage(&json);
1081 assert_eq!(usage.prompt_tokens, 50);
1082 assert_eq!(usage.completion_tokens, 0);
1083 assert_eq!(usage.total_tokens, 0);
1084 }
1085
1086 #[test]
1087 fn test_browser_headless_default() {
1088 let _env = env_guard();
1089 std::env::remove_var("HARNESS_BROWSER_HEADLESS");
1090 assert!(crate::browser_headless());
1091 }
1092}