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/// Run reporting: console, NDJSON, JUnit, GitHub and Perfetto sinks.
41pub mod reporting;
42/// Step-by-step scenario executor (navigate, click, type, wait, assert).
43pub mod runner;
44/// Declarative TOML-based test scenario types.
45pub mod scenario;
46/// CSS selector sanitization for LLM-generated selectors.
47pub mod selectors;
48/// Vision support — screenshot capture/downscale/encode for visual asserts.
49pub mod vision;
50
51/// A2A agent server for accepting agent tasks.
52#[cfg(feature = "a2a-server")]
53pub mod a2a_server;
54
55/// `#[browser_test]` macros for `cargo test` integration.
56#[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/// Configuration for the LLM client — bundles URL, model, auth, timeouts,
72/// and provider-specific options into a single struct passed everywhere.
73#[derive(Debug, Clone)]
74pub struct LlmConfig {
75    /// OpenAI-compatible API base URL (without trailing `/v1/…`).
76    pub url: String,
77    /// Model name (e.g. `gpt-4o-mini`, `deepseek`).
78    pub model: String,
79    /// API key sent as `Authorization: Bearer <key>`.
80    pub api_key: Option<String>,
81    /// Custom headers appended to every LLM request.
82    pub headers: HashMap<String, String>,
83    /// HTTP timeout.
84    pub timeout: Duration,
85    /// Sampling temperature (0.0–1.0).
86    pub temperature: f64,
87    /// Enable extended thinking / reasoning tokens.
88    /// `None` = don't send any thinking key (provider default).
89    pub thinking: Option<bool>,
90    /// Provider-specific parameters merged into the request body
91    /// (e.g. `effort = "high"` for Anthropic).
92    pub model_params: HashMap<String, Value>,
93    /// How many times a single call to this endpoint is retried on
94    /// transient failures before giving up (or moving to the next fallback
95    /// endpoint). Default 3; override globally with
96    /// `HARNESS_LLM_CALL_ATTEMPTS`.
97    pub max_attempts: u32,
98    /// LLM provider protocol (defaults to OpenAI-compatible).
99    ///
100    /// `azure` switches to the `Azure` `OpenAI` deployments endpoint, `bedrock`
101    /// to the `SigV4`-signed AWS Bedrock Converse API (feature `aws`).
102    pub provider: Provider,
103    /// `Azure` `OpenAI` deployment name (`Provider::Azure`). Defaults to
104    /// `model` when unset.
105    pub deployment: Option<String>,
106    /// `Azure` `OpenAI` API version (`Provider::Azure`). Defaults to
107    /// `2024-10-21`.
108    pub api_version: Option<String>,
109    /// Authentication configuration (API key, token command, Entra ID).
110    pub auth: AuthConfig,
111    /// Extra HTTP headers produced by running a command per call, keyed by
112    /// header name. Provider-agnostic.
113    pub header_commands: HashMap<String, String>,
114    /// AWS credential settings (`Provider::Bedrock`).
115    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    /// Build a config from environment defaults, falling back to safe
142    /// values when no env vars are set.
143    #[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
165/// Default `Azure` `OpenAI` API version used when an endpoint does not set
166/// `api_version`.
167pub const DEFAULT_AZURE_API_VERSION: &str = "2024-10-21";
168
169/// Builds the `Azure` `OpenAI` chat completions URL: the resource endpoint
170/// (without `/openai`), the deployment name, and the API version.
171#[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/// Reads `HARNESS_LLM_CALL_ATTEMPTS` (default 3) — how many times a single
182/// chat completion is retried before the endpoint is considered failed.
183#[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/// Parses `HARNESS_LLM_HEADERS` env var (JSON object) into a header map.
193///
194/// Exposed for use by `endpoints.rs` and tests.
195#[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/// Returns the target base URL from `HARNESS_BROWSER_BASE_URL` env,
212/// defaulting to `http://localhost:4200`.
213#[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/// Returns the LLM server base URL from `HARNESS_LLM_TEST_URL` env,
219/// defaulting to `http://localhost:8080`.
220#[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/// Returns the LLM model name from `HARNESS_LLM_TEST_MODEL` env,
229/// defaulting to `deepseek`.
230#[must_use]
231pub fn llm_model() -> String {
232    std::env::var("HARNESS_LLM_TEST_MODEL").unwrap_or_else(|_| "deepseek".to_owned())
233}
234
235/// Returns whether to run the browser in headless mode from
236/// `HARNESS_BROWSER_HEADLESS` env, defaulting to `true`.
237#[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/// Builds a `reqwest::Client` with the given timeout.
244#[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/// Sends a chat completion request to the LLM.
253///
254/// Returns `Some(content)` on success, `None` on any error.
255///
256/// Prefer `llm_chat_with_usage` if you need token counting.
257#[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
265/// Sends a chat completion request to the LLM and returns both the content
266/// and token usage from the API response.
267///
268/// Retries transient failures (network errors, HTTP 429/5xx, invalid
269/// responses, and HTTP 200 + empty body — a gateway warm-up signature,
270/// retried with a 3s backoff) up to `llm.max_attempts` times, and returns
271/// the last underlying error instead of collapsing everything into a
272/// generic "server down" message. The error text includes the HTTP status
273/// and a truncated response-body snippet, so a gateway that answers with
274/// an HTML error page is identifiable in CI logs instead of surfacing as a
275/// bare JSON decode error. Deterministic client errors (401/403/404) are
276/// not retried.
277///
278/// # Errors
279///
280/// Returns the last underlying error as a human-readable string when every
281/// attempt fails (transport error, non-success HTTP status, response that is
282/// not valid JSON, an empty HTTP 200 body, or a response missing
283/// `choices[0].message.content`).
284pub 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
292/// Sends a vision-enabled chat completion request: the user message carries
293/// both the text prompt and a screenshot (JPEG/PNG data URL) as an
294/// OpenAI-compatible `image_url` content part.
295///
296/// Retries and error reporting behave like [`llm_chat_with_usage`].
297///
298/// # Errors
299///
300/// Returns the last underlying error as a human-readable string when every
301/// attempt fails (transport error, non-success HTTP status, response that is
302/// not valid JSON, or a response missing `choices[0].message.content`).
303pub 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
312/// Calls a chain of endpoints: the primary [`LlmConfig`] first, then each
313/// fallback in order. Every endpoint gets its own `max_attempts` retry
314/// budget; the first endpoint that answers wins.
315///
316/// Returns the response together with the index of the endpoint that
317/// produced it (0 = primary, 1 = first fallback, …) so the caller can
318/// attribute cost/usage to the right endpoint.
319///
320/// # Errors
321///
322/// Returns an error naming every endpoint that failed.
323pub 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
332/// Vision variant of [`llm_chat_with_usage_chain`].
333///
334/// # Errors
335///
336/// Returns an error naming every endpoint that failed.
337pub 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
347/// Shared chain loop: try each endpoint (primary then fallbacks) with its
348/// own retry budget; first success wins.
349async 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
374/// Shared retry loop for text-only and vision chat completions.
375async 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                // An HTTP 200 with an empty body is a gateway warm-up
391                // signature: give it a longer window to finish booting
392                // instead of hammering it with short retries.
393                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/// Builds the chat messages array. Text-only messages keep the plain
413/// string `content` shape (maximum provider compatibility); vision calls
414/// use the OpenAI-compatible content-part array with a `data:` image URL.
415#[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
432/// Internal error type for a single LLM request attempt, distinguishing
433/// transient failures (worth retrying) from deterministic configuration
434/// errors (fail immediately).
435enum LlmCallError {
436    /// Transport-level failure (connect, timeout, TLS, …).
437    Transport { message: String },
438    /// Non-success HTTP status with a body snippet.
439    Http { status: u16, body: String },
440    /// Success status but the body is not valid JSON.
441    InvalidJson {
442        status: u16,
443        detail: String,
444        body: String,
445    },
446    /// Success status with an EMPTY body — the classic transient gateway
447    /// warm-up signature (HTTP 200, zero bytes). Retried with a longer
448    /// backoff than other errors.
449    EmptyBody { status: u16 },
450    /// Valid JSON but missing `choices[0].message.content`.
451    MissingContent { json: String },
452    /// Authentication failure: token command failed, Entra endpoint
453    /// answered with an error, credentials missing, or an unsupported
454    /// provider/feature combination.
455    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    /// Whether another attempt may succeed. Network errors, rate limits,
494    /// server errors, and 200-with-garbage responses can be transient on
495    /// flaky gateways; auth/not-found errors are deterministic.
496    #[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
513/// Single LLM chat request attempt; returns the underlying error as text.
514async 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
540/// Single attempt against the OpenAI-compatible path, shared by the
541/// `openai` and `azure` providers (`Azure` differs only in the URL and the
542/// API-key header placement).
543async 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            // Explicit header name wins on every provider.
567            (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            // Azure classic auth: the key travels in the `api-key` header.
578            (None, Some(key)) if llm.provider == Provider::Azure => {
579                headers.push(("api-key".to_owned(), key.clone()));
580            }
581            // OpenAI-compatible convention.
582            (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        // HTTP 200 + empty body: a transient gateway hiccup (cold-start /
631        // warm-up), not a client error. Retried with a longer backoff.
632        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/// Builds the OpenAI-compatible chat completions request body (shared by the
656/// `openai` and `azure` providers).
657#[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    // Merge provider-specific parameters into the request body.
678    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
688/// JavaScript to extract interactive elements from the current page.
689/// Returns a JSON array of objects with tag, selector, and label.
690pub 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/// Truncates a string to the given maximum length, appending a marker with
730/// the number of omitted characters if truncation occurred.
731///
732/// The cut point is always a UTF-8 char boundary, so multi-byte input (umlauts,
733/// emoji, CJK) can never panic the caller.
734#[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
745/// Returns the largest char boundary index in `s` that is `<= index`.
746fn 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    /// Serializes env-var-mutating tests: they race when the test binary runs
758    /// them in parallel, which intermittently failed CI.
759    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
760
761    /// Acquires the env lock for one test.
762    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    /// Starts a minimal HTTP server that answers every chat-completions
776    /// request with `status`/`body`. Returns its base URL.
777    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        // Primary always returns HTTP 200 with an EMPTY body (the gateway
835        // warm-up signature) — after `max_attempts` it must hand over to the
836        // fallback, which answers properly.
837        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        // max_len is a byte budget; the cut lands on a char boundary.
961        // 'é' is 2 bytes, so index 3 captures "hé" (h=0, é=bytes 1-2)
962        assert_eq!(truncate("héllo", 3), "hé...<truncated 3 chars>");
963        // Length 5 captures full string (5 bytes)
964        assert_eq!(truncate("hello", 5), "hello");
965    }
966
967    #[test]
968    fn test_truncate_utf8_boundary_mid_char_does_not_panic() {
969        // Previously: &s[..2] panicked with "not a char boundary" because the
970        // cut landed inside the 2-byte 'é'. The runner hit this on real pages
971        // full of umlauts/emoji — inside the diagnostics path that was
972        // supposed to save the run.
973        // floor_char_boundary(2) lands BEFORE 'é' (byte 1), keeping "h".
974        let result = truncate("héllo", 2);
975        assert_eq!(result, "h...<truncated 4 chars>");
976
977        // 4-byte emoji: max_len=4 lands exactly on the first 🎉 boundary? No —
978        // boundary 0 is the largest <= 4 only if 4 is a boundary; it is, so
979        // cut=4 keeps "🎉". Check with 5 instead: cut back to 4.
980        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        // 5 ASCII chars cut at 10 bytes → 5 omitted
988        assert_eq!(truncate("abcdefghij", 5), "abcde...<truncated 5 chars>");
989        // 3 multibyte chars cut at exactly their boundary → 0... but the
990        // guard `len <= max_len` returns the raw string first.
991        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}