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