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/// Budget tracking and enforcement.
22pub mod budgets;
23/// Cost calculation, usage tracking, and pricing.
24pub mod costs;
25/// Endpoint registry and routing resolver.
26pub mod endpoints;
27/// MCP client for connecting to external MCP servers.
28pub mod mcp_client;
29/// MCP server for exposing the framework as an MCP server.
30pub mod mcp_server;
31/// Cost and token report printer.
32pub mod reporting;
33/// Step-by-step scenario executor (navigate, click, type, wait, assert).
34pub mod runner;
35/// Declarative TOML-based test scenario types.
36pub mod scenario;
37
38/// A2A agent server for accepting agent tasks.
39#[cfg(feature = "a2a-server")]
40pub mod a2a_server;
41
42/// `#[browser_test]` macros for `cargo test` integration.
43#[cfg(feature = "macros")]
44pub mod macros;
45
46use std::collections::HashMap;
47use std::time::Duration;
48
49use serde_json::Value;
50
51pub use costs::LlmResponse;
52pub use costs::LlmUsage;
53
54/// Configuration for the LLM client — bundles URL, model, auth, timeouts,
55/// and provider-specific options into a single struct passed everywhere.
56#[derive(Debug, Clone)]
57pub struct LlmConfig {
58    /// OpenAI-compatible API base URL (without trailing `/v1/…`).
59    pub url: String,
60    /// Model name (e.g. `gpt-4o-mini`, `deepseek`).
61    pub model: String,
62    /// API key sent as `Authorization: Bearer <key>`.
63    pub api_key: Option<String>,
64    /// Custom headers appended to every LLM request.
65    pub headers: HashMap<String, String>,
66    /// HTTP timeout.
67    pub timeout: Duration,
68    /// Sampling temperature (0.0–1.0).
69    pub temperature: f64,
70    /// Enable extended thinking / reasoning tokens.
71    /// `None` = don't send any thinking key (provider default).
72    pub thinking: Option<bool>,
73    /// Provider-specific parameters merged into the request body
74    /// (e.g. `effort = "high"` for Anthropic).
75    pub model_params: HashMap<String, Value>,
76}
77
78impl LlmConfig {
79    /// Build a config from environment defaults, falling back to safe
80    /// values when no env vars are set.
81    #[must_use]
82    pub fn from_env() -> Self {
83        Self {
84            url: llm_base_url(),
85            model: llm_model(),
86            api_key: std::env::var("HARNESS_LLM_API_KEY").ok(),
87            headers: parse_headers_env(),
88            timeout: Duration::from_secs(60),
89            temperature: 0.0,
90            thinking: None,
91            model_params: HashMap::new(),
92        }
93    }
94}
95
96/// Parses `HARNESS_LLM_HEADERS` env var (JSON object) into a header map.
97///
98/// Exposed for use by `endpoints.rs` and tests.
99#[must_use]
100pub fn parse_headers_env() -> HashMap<String, String> {
101    let Ok(raw) = std::env::var("HARNESS_LLM_HEADERS") else {
102        return HashMap::new();
103    };
104    let Ok(json) = serde_json::from_str::<Value>(&raw) else {
105        return HashMap::new();
106    };
107    let Some(obj) = json.as_object() else {
108        return HashMap::new();
109    };
110    obj.iter()
111        .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_owned())))
112        .collect()
113}
114
115/// Returns the target base URL from `HARNESS_BROWSER_BASE_URL` env,
116/// defaulting to `http://localhost:4200`.
117#[must_use]
118pub fn base_url() -> String {
119    std::env::var("HARNESS_BROWSER_BASE_URL").unwrap_or_else(|_| "http://localhost:4200".to_owned())
120}
121
122/// Returns the LLM server base URL from `HARNESS_LLM_TEST_URL` env,
123/// defaulting to `http://localhost:8080`.
124#[must_use]
125pub fn llm_base_url() -> String {
126    std::env::var("HARNESS_LLM_TEST_URL")
127        .unwrap_or_else(|_| "http://localhost:8080".to_owned())
128        .trim_end_matches('/')
129        .to_owned()
130}
131
132/// Returns the LLM model name from `HARNESS_LLM_TEST_MODEL` env,
133/// defaulting to `deepseek`.
134#[must_use]
135pub fn llm_model() -> String {
136    std::env::var("HARNESS_LLM_TEST_MODEL").unwrap_or_else(|_| "deepseek".to_owned())
137}
138
139/// Returns whether to run the browser in headless mode from
140/// `HARNESS_BROWSER_HEADLESS` env, defaulting to `true`.
141#[must_use]
142pub fn browser_headless() -> bool {
143    std::env::var("HARNESS_BROWSER_HEADLESS")
144        .map_or(true, |v| v != "0" && v.to_lowercase() != "false")
145}
146
147/// Builds a `reqwest::Client` with the given timeout.
148#[must_use]
149pub fn http_client(timeout: Duration) -> reqwest::Client {
150    reqwest::Client::builder()
151        .timeout(timeout)
152        .build()
153        .expect("build reqwest client")
154}
155
156/// Sends a chat completion request to the LLM.
157///
158/// Returns `Some(content)` on success, `None` on any error.
159///
160/// Prefer `llm_chat_with_usage` if you need token counting.
161#[must_use]
162pub async fn llm_chat(llm: &LlmConfig, system: &str, user: &str) -> Option<String> {
163    llm_chat_with_usage(llm, system, user)
164        .await
165        .map(|r| r.content)
166}
167
168/// Sends a chat completion request to the LLM and returns both the content
169/// and token usage from the API response.
170#[must_use]
171pub async fn llm_chat_with_usage(llm: &LlmConfig, system: &str, user: &str) -> Option<LlmResponse> {
172    let client = http_client(llm.timeout);
173    let mut payload = serde_json::json!({
174        "model": llm.model,
175        "messages": [
176            {"role": "system", "content": system},
177            {"role": "user", "content": user}
178        ],
179        "max_tokens": 4096,
180        "temperature": llm.temperature
181    });
182    if let Some(think) = llm.thinking {
183        if think {
184            payload["thinking"] = serde_json::json!({"type": "enabled"});
185        } else {
186            payload["thinking"] = serde_json::json!({"type": "disabled"});
187        }
188    }
189    // Merge provider-specific parameters into the request body.
190    if !llm.model_params.is_empty() {
191        if let Value::Object(ref mut map) = payload {
192            for (key, val) in &llm.model_params {
193                map.insert(key.clone(), val.clone());
194            }
195        }
196    }
197    let mut req = client
198        .post(format!("{}/v1/chat/completions", llm.url))
199        .header("Content-Type", "application/json");
200
201    if let Some(ref key) = llm.api_key {
202        req = req.header("Authorization", format!("Bearer {key}"));
203    }
204    for (name, value) in &llm.headers {
205        req = req.header(name.as_str(), value.as_str());
206    }
207
208    let resp = req.json(&payload).send().await.ok()?;
209    let json: Value = resp.json().await.ok()?;
210    let usage = costs::extract_usage(&json);
211    let content = json["choices"][0]["message"]["content"]
212        .as_str()
213        .map(String::from)?;
214
215    Some(LlmResponse { content, usage })
216}
217
218/// JavaScript to extract interactive elements from the current page.
219/// Returns a JSON array of objects with tag, selector, and label.
220pub const DOM_EXTRACT_JS: &str = r#"
221(() => {
222  const interactive = 'a, button, input, textarea, select, [role="button"], [onclick], [tabindex], [data-testid], [aria-label]';
223  const els = document.querySelectorAll(interactive);
224  const info = [];
225  const seen = new Set();
226  els.forEach((el, i) => {
227    const rect = el.getBoundingClientRect();
228    if (rect.width === 0 || rect.height === 0) return;
229    const tag = el.tagName.toLowerCase();
230    let selector = '';
231    if (el.id) selector = '#' + CSS.escape(el.id);
232    else if (el.getAttribute('data-testid')) selector = '[data-testid="' + el.getAttribute('data-testid') + '"]';
233    else if (el.name) selector = '[name="' + CSS.escape(el.name) + '"]';
234    else if (el.className && typeof el.className === 'string') {
235      const cls = el.className.trim().split(/\\s+/)[0];
236      if (cls) selector = tag + '.' + CSS.escape(cls);
237    }
238    if (!selector) selector = tag;
239    if (seen.has(selector)) return;
240    seen.add(selector);
241
242    let label = '';
243    const aria = el.getAttribute('aria-label');
244    if (aria) {
245      label = aria;
246    } else if (tag === 'input' || tag === 'textarea' || tag === 'select') {
247      label = el.placeholder || el.name || el.getAttribute('aria-label') || '';
248      if (el.type && !label) label = el.type;
249    } else {
250      label = (el.textContent || '').trim().substring(0, 80);
251    }
252
253    info.push(i + ': ' + selector + ' [' + tag + '] "' + label + '"');
254  });
255  return JSON.stringify(info);
256})()
257"#;
258
259/// Truncates a string to the given maximum length, appending a marker
260/// if truncation occurred.
261#[must_use]
262pub fn truncate(s: &str, max_len: usize) -> String {
263    if s.len() <= max_len {
264        s.to_owned()
265    } else {
266        format!("{}...<truncated>", &s[..max_len])
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use crate::costs::extract_usage;
273    use crate::truncate;
274    use crate::{llm_base_url, llm_model, parse_headers_env, LlmConfig};
275
276    #[test]
277    fn test_truncate_short() {
278        assert_eq!(truncate("hello", 10), "hello");
279    }
280
281    #[test]
282    fn test_truncate_long() {
283        let result = truncate("hello world", 5);
284        assert!(result.contains("<truncated>"));
285        assert!(result.starts_with("hello"));
286    }
287
288    #[test]
289    fn test_truncate_exact_length() {
290        assert_eq!(truncate("abcde", 5), "abcde");
291    }
292
293    #[test]
294    fn test_truncate_empty() {
295        assert_eq!(truncate("", 5), "");
296    }
297
298    #[test]
299    fn test_parse_headers_env_empty() {
300        std::env::remove_var("HARNESS_LLM_HEADERS");
301        let h = parse_headers_env();
302        assert!(h.is_empty());
303    }
304
305    #[test]
306    fn test_parse_headers_env_valid() {
307        std::env::set_var("HARNESS_LLM_HEADERS", r#"{"X-Org":"acme","X-Version":"1"}"#);
308        let h = parse_headers_env();
309        assert_eq!(h.get("X-Org").map(String::as_str), Some("acme"));
310        assert_eq!(h.get("X-Version").map(String::as_str), Some("1"));
311        std::env::remove_var("HARNESS_LLM_HEADERS");
312    }
313
314    #[test]
315    fn test_parse_headers_env_invalid_json() {
316        std::env::set_var("HARNESS_LLM_HEADERS", "not-json");
317        let h = parse_headers_env();
318        assert!(h.is_empty());
319        std::env::remove_var("HARNESS_LLM_HEADERS");
320    }
321
322    #[test]
323    fn test_llm_config_from_env_defaults() {
324        #[allow(clippy::float_cmp)]
325        {
326            let config = LlmConfig::from_env();
327            assert_eq!(config.temperature, 0.0);
328            assert!(config.thinking.is_none());
329            assert!(config.model_params.is_empty());
330        }
331    }
332
333    #[test]
334    fn test_extract_usage_full() {
335        let json = serde_json::json!({
336            "usage": {
337                "prompt_tokens": 100,
338                "completion_tokens": 200,
339                "total_tokens": 300
340            }
341        });
342        let usage = extract_usage(&json);
343        assert_eq!(usage.prompt_tokens, 100);
344        assert_eq!(usage.completion_tokens, 200);
345        assert_eq!(usage.total_tokens, 300);
346    }
347
348    #[test]
349    fn test_extract_usage_empty() {
350        let json = serde_json::json!({});
351        let usage = extract_usage(&json);
352        assert_eq!(usage.prompt_tokens, 0);
353        assert_eq!(usage.completion_tokens, 0);
354        assert_eq!(usage.total_tokens, 0);
355    }
356
357    #[test]
358    fn test_truncate_unicode() {
359        // truncate uses byte-level slicing, so max_len refers to byte count
360        // 'é' is 2 bytes, so index 3 captures "hé" (h=0, é=bytes 1-2)
361        assert_eq!(truncate("héllo", 3), "hé...<truncated>");
362        // Length 5 captures full string (5 bytes)
363        assert_eq!(truncate("hello", 5), "hello");
364    }
365
366    #[test]
367    fn test_parse_headers_env_non_object() {
368        std::env::set_var("HARNESS_LLM_HEADERS", "[1, 2, 3]");
369        let h = parse_headers_env();
370        assert!(h.is_empty());
371        std::env::remove_var("HARNESS_LLM_HEADERS");
372    }
373
374    #[test]
375    fn test_parse_headers_env_nested_values_filtered() {
376        std::env::set_var(
377            "HARNESS_LLM_HEADERS",
378            r#"{"str":"val","num":42,"bool":true}"#,
379        );
380        let h = parse_headers_env();
381        assert_eq!(h.get("str").map(String::as_str), Some("val"));
382        assert!(!h.contains_key("num"));
383        assert!(!h.contains_key("bool"));
384        std::env::remove_var("HARNESS_LLM_HEADERS");
385    }
386
387    #[test]
388    fn test_llm_config_has_default_model() {
389        let config = LlmConfig::from_env();
390        assert!(!config.model.is_empty());
391    }
392
393    #[test]
394    fn test_llm_base_url_default() {
395        std::env::remove_var("HARNESS_LLM_TEST_URL");
396        let url = llm_base_url();
397        assert_eq!(url, "http://localhost:8080");
398    }
399
400    #[test]
401    fn test_llm_base_url_custom() {
402        std::env::set_var("HARNESS_LLM_TEST_URL", "https://custom.api.com/v1");
403        let url = llm_base_url();
404        assert_eq!(url, "https://custom.api.com/v1");
405        std::env::remove_var("HARNESS_LLM_TEST_URL");
406    }
407
408    #[test]
409    fn test_llm_base_url_trailing_slash() {
410        std::env::set_var("HARNESS_LLM_TEST_URL", "https://api.com/");
411        let url = llm_base_url();
412        assert_eq!(url, "https://api.com");
413        std::env::remove_var("HARNESS_LLM_TEST_URL");
414    }
415
416    #[test]
417    fn test_llm_model_default() {
418        std::env::remove_var("HARNESS_LLM_TEST_MODEL");
419        assert_eq!(llm_model(), "deepseek");
420    }
421
422    #[test]
423    fn test_llm_model_custom() {
424        std::env::set_var("HARNESS_LLM_TEST_MODEL", "gpt-4o");
425        assert_eq!(llm_model(), "gpt-4o");
426        std::env::remove_var("HARNESS_LLM_TEST_MODEL");
427    }
428
429    #[test]
430    fn test_extract_usage_partial() {
431        let json = serde_json::json!({
432            "usage": {
433                "prompt_tokens": 50
434            }
435        });
436        let usage = extract_usage(&json);
437        assert_eq!(usage.prompt_tokens, 50);
438        assert_eq!(usage.completion_tokens, 0);
439        assert_eq!(usage.total_tokens, 0);
440    }
441
442    #[test]
443    fn test_browser_headless_default() {
444        std::env::remove_var("HARNESS_BROWSER_HEADLESS");
445        assert!(crate::browser_headless());
446    }
447}