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