Skip to main content

llm_browser_testkit/
lib.rs

1//! LLM-driven browser test framework.
2//!
3//! Provides reusable building blocks for browser-based test scenarios:
4//! - Browser client via Chrome `DevTools` Protocol (headless)
5//! - LLM client for natural language element targeting and assertions
6//! - A2A agent integration for agent-to-agent communication
7//! - MCP client/server integration for tool-calling
8//! - Cost tracking, token counting, and budget enforcement
9//! - Declarative TOML scenario runner
10//! - `#[browser_test]` macros for `cargo test` integration
11
12#![allow(
13    clippy::expect_used,
14    clippy::unwrap_used,
15    clippy::panic,
16    clippy::missing_panics_doc
17)]
18
19/// A2A agent protocol client.
20pub mod a2a;
21/// Auth token resolution — Entra ID and token commands for LLM endpoints.
22mod auth;
23/// AWS Bedrock provider (`SigV4`-signed Converse calls; feature `aws`).
24#[cfg(feature = "aws")]
25mod bedrock;
26/// Budget tracking and enforcement.
27pub mod budgets;
28/// Cost calculation, usage tracking, and pricing.
29pub mod costs;
30/// Failure diagnostics — page-state capture and artifact writing.
31pub mod diagnostics;
32/// Endpoint registry and routing resolver.
33pub mod endpoints;
34/// Typed run events emitted by the runner.
35pub mod events;
36/// MCP client for connecting to external MCP servers.
37pub mod mcp_client;
38/// MCP server for exposing the framework as an MCP server.
39pub mod mcp_server;
40/// Secret redaction for every report sink.
41pub mod redact;
42/// Run reporting: console, NDJSON, JUnit, GitHub and Perfetto sinks.
43pub mod reporting;
44/// Step-by-step scenario executor (navigate, click, type, wait, assert).
45pub mod runner;
46/// Declarative TOML-based test scenario types.
47pub mod scenario;
48/// CSS selector sanitization for LLM-generated selectors.
49pub mod selectors;
50/// Vision support — screenshot capture/downscale/encode for visual asserts.
51pub mod vision;
52
53/// A2A agent server for accepting agent tasks.
54#[cfg(feature = "a2a-server")]
55pub mod a2a_server;
56
57/// `#[browser_test]` macros for `cargo test` integration.
58#[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/// Configuration for the LLM client — bundles URL, model, auth, timeouts,
74/// and provider-specific options into a single struct passed everywhere.
75#[derive(Debug, Clone)]
76pub struct LlmConfig {
77    /// OpenAI-compatible API base URL (without trailing `/v1/…`).
78    pub url: String,
79    /// Model name (e.g. `gpt-4o-mini`, `deepseek`).
80    pub model: String,
81    /// API key sent as `Authorization: Bearer <key>`.
82    pub api_key: Option<String>,
83    /// Custom headers appended to every LLM request.
84    pub headers: HashMap<String, String>,
85    /// HTTP timeout.
86    pub timeout: Duration,
87    /// Sampling temperature (0.0–1.0).
88    pub temperature: f64,
89    /// Enable extended thinking / reasoning tokens.
90    /// `None` = don't send any thinking key (provider default).
91    pub thinking: Option<bool>,
92    /// Provider-specific parameters merged into the request body
93    /// (e.g. `effort = "high"` for Anthropic).
94    pub model_params: HashMap<String, Value>,
95    /// How many times a single call to this endpoint is retried on
96    /// transient failures before giving up (or moving to the next fallback
97    /// endpoint). Default 3; override globally with
98    /// `HARNESS_LLM_CALL_ATTEMPTS`.
99    pub max_attempts: u32,
100    /// LLM provider protocol (defaults to OpenAI-compatible).
101    ///
102    /// `azure` switches to the `Azure` `OpenAI` deployments endpoint, `bedrock`
103    /// to the `SigV4`-signed AWS Bedrock Converse API (feature `aws`).
104    pub provider: Provider,
105    /// `Azure` `OpenAI` deployment name (`Provider::Azure`). Defaults to
106    /// `model` when unset.
107    pub deployment: Option<String>,
108    /// `Azure` `OpenAI` API version (`Provider::Azure`). Defaults to
109    /// `2024-10-21`.
110    pub api_version: Option<String>,
111    /// Authentication configuration (API key, token command, Entra ID).
112    pub auth: AuthConfig,
113    /// Extra HTTP headers produced by running a command per call, keyed by
114    /// header name. Provider-agnostic.
115    pub header_commands: HashMap<String, String>,
116    /// AWS credential settings (`Provider::Bedrock`).
117    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    /// Build a config from environment defaults, falling back to safe
144    /// values when no env vars are set.
145    #[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
167/// Default `Azure` `OpenAI` API version used when an endpoint does not set
168/// `api_version`.
169pub const DEFAULT_AZURE_API_VERSION: &str = "2024-10-21";
170
171/// Builds the `Azure` `OpenAI` chat completions URL: the resource endpoint
172/// (without `/openai`), the deployment name, and the API version.
173#[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/// Reads `HARNESS_LLM_CALL_ATTEMPTS` (default 3) — how many times a single
184/// chat completion is retried before the endpoint is considered failed.
185#[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/// Parses `HARNESS_LLM_HEADERS` env var (JSON object) into a header map.
195///
196/// Exposed for use by `endpoints.rs` and tests.
197#[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/// Returns the target base URL from `HARNESS_BROWSER_BASE_URL` env,
214/// defaulting to `http://localhost:4200`.
215#[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/// Returns the LLM server base URL from `HARNESS_LLM_TEST_URL` env,
221/// defaulting to `http://localhost:8080`.
222#[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/// Returns the LLM model name from `HARNESS_LLM_TEST_MODEL` env,
231/// defaulting to `deepseek`.
232#[must_use]
233pub fn llm_model() -> String {
234    std::env::var("HARNESS_LLM_TEST_MODEL").unwrap_or_else(|_| "deepseek".to_owned())
235}
236
237/// Returns whether to run the browser in headless mode from
238/// `HARNESS_BROWSER_HEADLESS` env, defaulting to `true`.
239#[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/// Builds a `reqwest::Client` with the given timeout.
246#[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/// Sends a chat completion request to the LLM.
255///
256/// Returns `Some(content)` on success, `None` on any error.
257///
258/// Prefer `llm_chat_with_usage` if you need token counting.
259#[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
267/// Sends a chat completion request to the LLM and returns both the content
268/// and token usage from the API response.
269///
270/// Retries transient failures (network errors, HTTP 429/5xx, invalid
271/// responses, and HTTP 200 + empty body — a gateway warm-up signature,
272/// retried with a 3s backoff) up to `llm.max_attempts` times, and returns
273/// the last underlying error instead of collapsing everything into a
274/// generic "server down" message. The error text includes the HTTP status
275/// and a truncated response-body snippet, so a gateway that answers with
276/// an HTML error page is identifiable in CI logs instead of surfacing as a
277/// bare JSON decode error. Deterministic client errors (401/403/404) are
278/// not retried.
279///
280/// # Errors
281///
282/// Returns the last underlying error as a human-readable string when every
283/// attempt fails (transport error, non-success HTTP status, response that is
284/// not valid JSON, an empty HTTP 200 body, or a response missing
285/// `choices[0].message.content`).
286pub 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
294/// Sends a vision-enabled chat completion request.
295///
296/// The user message carries the text prompt and one or more screenshots
297/// (JPEG/PNG data URLs, ordered from the top of the page down) as
298/// OpenAI-compatible `image_url` content parts.
299///
300/// Retries and error reporting behave like [`llm_chat_with_usage`].
301///
302/// # Errors
303///
304/// Returns the last underlying error as a human-readable string when every
305/// attempt fails (transport error, non-success HTTP status, response that is
306/// not valid JSON, or a response missing `choices[0].message.content`).
307pub 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
316/// Calls a chain of endpoints: the primary [`LlmConfig`] first, then each
317/// fallback in order. Every endpoint gets its own `max_attempts` retry
318/// budget; the first endpoint that answers wins.
319///
320/// Returns the response together with the index of the endpoint that
321/// produced it (0 = primary, 1 = first fallback, …) so the caller can
322/// attribute cost/usage to the right endpoint.
323///
324/// # Errors
325///
326/// Returns an error naming every endpoint that failed.
327pub 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
336/// Vision variant of [`llm_chat_with_usage_chain`].
337///
338/// # Errors
339///
340/// Returns an error naming every endpoint that failed.
341pub 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
351/// Shared chain loop: try each endpoint (primary then fallbacks) with its
352/// own retry budget; first success wins.
353async 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
378/// Shared retry loop for text-only and vision chat completions.
379async 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                // An HTTP 200 with an empty body is a gateway warm-up
395                // signature: give it a longer window to finish booting
396                // instead of hammering it with short retries.
397                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/// Builds the chat messages array. Text-only messages keep the plain
417/// string `content` shape (maximum provider compatibility); vision calls
418/// use the OpenAI-compatible content-part array with one `image_url` part
419/// per screenshot (ordered from the top of the page down).
420#[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
438/// Internal error type for a single LLM request attempt, distinguishing
439/// transient failures (worth retrying) from deterministic configuration
440/// errors (fail immediately).
441enum LlmCallError {
442    /// Transport-level failure (connect, timeout, TLS, …).
443    Transport { message: String },
444    /// Non-success HTTP status with a body snippet.
445    Http { status: u16, body: String },
446    /// Success status but the body is not valid JSON.
447    InvalidJson {
448        status: u16,
449        detail: String,
450        body: String,
451    },
452    /// Success status with an EMPTY body — the classic transient gateway
453    /// warm-up signature (HTTP 200, zero bytes). Retried with a longer
454    /// backoff than other errors.
455    EmptyBody { status: u16 },
456    /// Valid JSON but missing `choices[0].message.content`.
457    MissingContent { json: String },
458    /// Authentication failure: token command failed, Entra endpoint
459    /// answered with an error, credentials missing, or an unsupported
460    /// provider/feature combination.
461    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    /// Whether another attempt may succeed. Network errors, rate limits,
500    /// server errors, and 200-with-garbage responses can be transient on
501    /// flaky gateways; auth/not-found errors are deterministic.
502    #[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
519/// Single LLM chat request attempt; returns the underlying error as text.
520async 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
547/// Single attempt against the OpenAI-compatible path, shared by the
548/// `openai` and `azure` providers (`Azure` differs only in the URL and the
549/// API-key header placement).
550async 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            // Explicit header name wins on every provider.
574            (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            // Azure classic auth: the key travels in the `api-key` header.
585            (None, Some(key)) if llm.provider == Provider::Azure => {
586                headers.push(("api-key".to_owned(), key.clone()));
587            }
588            // OpenAI-compatible convention.
589            (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        // HTTP 200 + empty body: a transient gateway hiccup (cold-start /
638        // warm-up), not a client error. Retried with a longer backoff.
639        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/// Builds the OpenAI-compatible chat completions request body (shared by the
663/// `openai` and `azure` providers).
664#[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    // Merge provider-specific parameters into the request body.
685    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
695/// JavaScript to extract interactive elements from the current page.
696/// Returns a JSON array of objects with tag, selector, and label.
697pub 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/// Truncates a string to the given maximum length, appending a marker with
737/// the number of omitted characters if truncation occurred.
738///
739/// The cut point is always a UTF-8 char boundary, so multi-byte input (umlauts,
740/// emoji, CJK) can never panic the caller.
741#[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
752/// Returns the largest char boundary index in `s` that is `<= index`.
753fn 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    /// Serializes env-var-mutating tests: they race when the test binary runs
765    /// them in parallel, which intermittently failed CI.
766    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
767
768    /// Acquires the env lock for one test.
769    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    /// Starts a minimal HTTP server that answers every chat-completions
783    /// request with `status`/`body`. Returns its base URL.
784    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        // Primary always returns HTTP 200 with an EMPTY body (the gateway
842        // warm-up signature) — after `max_attempts` it must hand over to the
843        // fallback, which answers properly.
844        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        // max_len is a byte budget; the cut lands on a char boundary.
968        // 'é' is 2 bytes, so index 3 captures "hé" (h=0, é=bytes 1-2)
969        assert_eq!(truncate("héllo", 3), "hé...<truncated 3 chars>");
970        // Length 5 captures full string (5 bytes)
971        assert_eq!(truncate("hello", 5), "hello");
972    }
973
974    #[test]
975    fn test_truncate_utf8_boundary_mid_char_does_not_panic() {
976        // Previously: &s[..2] panicked with "not a char boundary" because the
977        // cut landed inside the 2-byte 'é'. The runner hit this on real pages
978        // full of umlauts/emoji — inside the diagnostics path that was
979        // supposed to save the run.
980        // floor_char_boundary(2) lands BEFORE 'é' (byte 1), keeping "h".
981        let result = truncate("héllo", 2);
982        assert_eq!(result, "h...<truncated 4 chars>");
983
984        // 4-byte emoji: max_len=4 lands exactly on the first 🎉 boundary? No —
985        // boundary 0 is the largest <= 4 only if 4 is a boundary; it is, so
986        // cut=4 keeps "🎉". Check with 5 instead: cut back to 4.
987        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        // 5 ASCII chars cut at 10 bytes → 5 omitted
995        assert_eq!(truncate("abcdefghij", 5), "abcde...<truncated 5 chars>");
996        // 3 multibyte chars cut at exactly their boundary → 0... but the
997        // guard `len <= max_len` returns the raw string first.
998        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}