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//! - Declarative TOML scenario runner
7//! - `#[browser_test]` macros for `cargo test` integration
8
9#![allow(
10    clippy::expect_used,
11    clippy::unwrap_used,
12    clippy::panic,
13    clippy::missing_panics_doc
14)]
15
16/// Step-by-step scenario executor (navigate, click, type, wait, assert).
17pub mod runner;
18/// Declarative TOML-based test scenario types.
19pub mod scenario;
20
21/// `#[browser_test]` macros for `cargo test` integration.
22#[cfg(feature = "macros")]
23pub mod macros;
24
25use std::collections::HashMap;
26use std::time::Duration;
27
28use serde_json::Value;
29
30/// Configuration for the LLM client — bundles URL, model, auth, timeouts,
31/// and provider-specific options into a single struct passed everywhere.
32#[derive(Debug, Clone)]
33pub struct LlmConfig {
34    /// OpenAI-compatible API base URL (without trailing `/v1/…`).
35    pub url: String,
36    /// Model name (e.g. `gpt-4o-mini`, `deepseek`).
37    pub model: String,
38    /// API key sent as `Authorization: Bearer <key>`.
39    pub api_key: Option<String>,
40    /// Custom headers appended to every LLM request.
41    pub headers: HashMap<String, String>,
42    /// HTTP timeout.
43    pub timeout: Duration,
44    /// Sampling temperature (0.0–1.0).
45    pub temperature: f64,
46    /// Enable extended thinking / reasoning tokens.
47    /// `None` = don't send any thinking key (provider default).
48    pub thinking: Option<bool>,
49    /// Provider-specific parameters merged into the request body
50    /// (e.g. `effort = "high"` for Anthropic).
51    pub model_params: HashMap<String, Value>,
52}
53
54impl LlmConfig {
55    /// Build a config from environment defaults, falling back to safe
56    /// values when no env vars are set.
57    #[must_use]
58    pub fn from_env() -> Self {
59        Self {
60            url: llm_base_url(),
61            model: llm_model(),
62            api_key: std::env::var("HARNESS_LLM_API_KEY").ok(),
63            headers: parse_headers_env(),
64            timeout: Duration::from_secs(60),
65            temperature: 0.0,
66            thinking: None,
67            model_params: HashMap::new(),
68        }
69    }
70}
71
72fn parse_headers_env() -> HashMap<String, String> {
73    let Ok(raw) = std::env::var("HARNESS_LLM_HEADERS") else {
74        return HashMap::new();
75    };
76    let Ok(json) = serde_json::from_str::<Value>(&raw) else {
77        return HashMap::new();
78    };
79    let Some(obj) = json.as_object() else {
80        return HashMap::new();
81    };
82    obj.iter()
83        .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_owned())))
84        .collect()
85}
86
87/// Returns the target base URL from `HARNESS_BROWSER_BASE_URL` env,
88/// defaulting to `http://localhost:4200`.
89#[must_use]
90pub fn base_url() -> String {
91    std::env::var("HARNESS_BROWSER_BASE_URL").unwrap_or_else(|_| "http://localhost:4200".to_owned())
92}
93
94/// Returns the LLM server base URL from `HARNESS_LLM_TEST_URL` env,
95/// defaulting to `http://localhost:8080`.
96#[must_use]
97pub fn llm_base_url() -> String {
98    std::env::var("HARNESS_LLM_TEST_URL")
99        .unwrap_or_else(|_| "http://localhost:8080".to_owned())
100        .trim_end_matches('/')
101        .to_owned()
102}
103
104/// Returns the LLM model name from `HARNESS_LLM_TEST_MODEL` env,
105/// defaulting to `deepseek`.
106#[must_use]
107pub fn llm_model() -> String {
108    std::env::var("HARNESS_LLM_TEST_MODEL").unwrap_or_else(|_| "deepseek".to_owned())
109}
110
111/// Returns whether to run the browser in headless mode from
112/// `HARNESS_BROWSER_HEADLESS` env, defaulting to `true`.
113#[must_use]
114pub fn browser_headless() -> bool {
115    std::env::var("HARNESS_BROWSER_HEADLESS")
116        .map_or(true, |v| v != "0" && v.to_lowercase() != "false")
117}
118
119/// Builds a `reqwest::Client` with the given timeout.
120#[must_use]
121pub fn http_client(timeout: Duration) -> reqwest::Client {
122    reqwest::Client::builder()
123        .timeout(timeout)
124        .build()
125        .expect("build reqwest client")
126}
127
128/// Sends a chat completion request to the LLM.
129///
130/// Returns `Some(content)` on success, `None` on any error.
131pub async fn llm_chat(llm: &LlmConfig, system: &str, user: &str) -> Option<String> {
132    let client = http_client(llm.timeout);
133    let mut payload = serde_json::json!({
134        "model": llm.model,
135        "messages": [
136            {"role": "system", "content": system},
137            {"role": "user", "content": user}
138        ],
139        "max_tokens": 4096,
140        "temperature": llm.temperature
141    });
142    if let Some(think) = llm.thinking {
143        if think {
144            payload["thinking"] = serde_json::json!({"type": "enabled"});
145        } else {
146            payload["thinking"] = serde_json::json!({"type": "disabled"});
147        }
148    }
149    // Merge provider-specific parameters into the request body.
150    //
151    // This supports non-OpenAI params like Anthropic's `effort` or
152    // provider-specific reasoning controls.
153    if !llm.model_params.is_empty() {
154        if let Value::Object(ref mut map) = payload {
155            for (key, val) in &llm.model_params {
156                map.insert(key.clone(), val.clone());
157            }
158        }
159    }
160    let mut req = client
161        .post(format!("{}/v1/chat/completions", llm.url))
162        .header("Content-Type", "application/json");
163
164    if let Some(ref key) = llm.api_key {
165        req = req.header("Authorization", format!("Bearer {key}"));
166    }
167    for (name, value) in &llm.headers {
168        req = req.header(name.as_str(), value.as_str());
169    }
170
171    let resp = req.json(&payload).send().await.ok()?;
172    let json: Value = resp.json().await.ok()?;
173    json["choices"][0]["message"]["content"]
174        .as_str()
175        .map(String::from)
176}
177
178/// JavaScript to extract interactive elements from the current page.
179/// Returns a JSON array of objects with tag, selector, and label.
180pub const DOM_EXTRACT_JS: &str = r#"
181(() => {
182  const interactive = 'a, button, input, textarea, select, [role="button"], [onclick], [tabindex], [data-testid], [aria-label]';
183  const els = document.querySelectorAll(interactive);
184  const info = [];
185  const seen = new Set();
186  els.forEach((el, i) => {
187    const rect = el.getBoundingClientRect();
188    if (rect.width === 0 || rect.height === 0) return;
189    const tag = el.tagName.toLowerCase();
190    let selector = '';
191    if (el.id) selector = '#' + CSS.escape(el.id);
192    else if (el.getAttribute('data-testid')) selector = '[data-testid="' + el.getAttribute('data-testid') + '"]';
193    else if (el.name) selector = '[name="' + CSS.escape(el.name) + '"]';
194    else if (el.className && typeof el.className === 'string') {
195      const cls = el.className.trim().split(/\\s+/)[0];
196      if (cls) selector = tag + '.' + CSS.escape(cls);
197    }
198    if (!selector) selector = tag;
199    if (seen.has(selector)) return;
200    seen.add(selector);
201
202    let label = '';
203    const aria = el.getAttribute('aria-label');
204    if (aria) {
205      label = aria;
206    } else if (tag === 'input' || tag === 'textarea' || tag === 'select') {
207      label = el.placeholder || el.name || el.getAttribute('aria-label') || '';
208      if (el.type && !label) label = el.type;
209    } else {
210      label = (el.textContent || '').trim().substring(0, 80);
211    }
212
213    info.push(i + ': ' + selector + ' [' + tag + '] "' + label + '"');
214  });
215  return JSON.stringify(info);
216})()
217"#;
218
219/// Truncates a string to the given maximum length, appending a marker
220/// if truncation occurred.
221#[must_use]
222pub fn truncate(s: &str, max_len: usize) -> String {
223    if s.len() <= max_len {
224        s.to_owned()
225    } else {
226        format!("{}...<truncated>", &s[..max_len])
227    }
228}