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    pub thinking: bool,
48}
49
50impl LlmConfig {
51    /// Build a config from environment defaults, falling back to safe
52    /// values when no env vars are set.
53    #[must_use]
54    pub fn from_env() -> Self {
55        Self {
56            url: llm_base_url(),
57            model: llm_model(),
58            api_key: std::env::var("HARNESS_LLM_API_KEY").ok(),
59            headers: parse_headers_env(),
60            timeout: Duration::from_secs(60),
61            temperature: 0.0,
62            thinking: false,
63        }
64    }
65}
66
67fn parse_headers_env() -> HashMap<String, String> {
68    let Ok(raw) = std::env::var("HARNESS_LLM_HEADERS") else {
69        return HashMap::new();
70    };
71    let Ok(json) = serde_json::from_str::<Value>(&raw) else {
72        return HashMap::new();
73    };
74    let Some(obj) = json.as_object() else {
75        return HashMap::new();
76    };
77    obj.iter()
78        .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_owned())))
79        .collect()
80}
81
82/// Returns the target base URL from `HARNESS_BROWSER_BASE_URL` env,
83/// defaulting to `http://localhost:4200`.
84#[must_use]
85pub fn base_url() -> String {
86    std::env::var("HARNESS_BROWSER_BASE_URL").unwrap_or_else(|_| "http://localhost:4200".to_owned())
87}
88
89/// Returns the LLM server base URL from `HARNESS_LLM_TEST_URL` env,
90/// defaulting to `http://localhost:8080`.
91#[must_use]
92pub fn llm_base_url() -> String {
93    std::env::var("HARNESS_LLM_TEST_URL")
94        .unwrap_or_else(|_| "http://localhost:8080".to_owned())
95        .trim_end_matches('/')
96        .to_owned()
97}
98
99/// Returns the LLM model name from `HARNESS_LLM_TEST_MODEL` env,
100/// defaulting to `deepseek`.
101#[must_use]
102pub fn llm_model() -> String {
103    std::env::var("HARNESS_LLM_TEST_MODEL").unwrap_or_else(|_| "deepseek".to_owned())
104}
105
106/// Returns whether to run the browser in headless mode from
107/// `HARNESS_BROWSER_HEADLESS` env, defaulting to `true`.
108#[must_use]
109pub fn browser_headless() -> bool {
110    std::env::var("HARNESS_BROWSER_HEADLESS")
111        .map_or(true, |v| v != "0" && v.to_lowercase() != "false")
112}
113
114/// Builds a `reqwest::Client` with the given timeout.
115#[must_use]
116pub fn http_client(timeout: Duration) -> reqwest::Client {
117    reqwest::Client::builder()
118        .timeout(timeout)
119        .build()
120        .expect("build reqwest client")
121}
122
123/// Sends a chat completion request to the LLM.
124///
125/// Returns `Some(content)` on success, `None` on any error.
126pub async fn llm_chat(
127    llm: &LlmConfig,
128    system: &str,
129    user: &str,
130) -> Option<String> {
131    let client = http_client(llm.timeout);
132    let mut payload = serde_json::json!({
133        "model": llm.model,
134        "messages": [
135            {"role": "system", "content": system},
136            {"role": "user", "content": user}
137        ],
138        "max_tokens": 4096,
139        "temperature": llm.temperature
140    });
141    if llm.thinking {
142        payload["thinking"] = serde_json::json!({"type": "enabled"});
143    } else {
144        payload["thinking"] = serde_json::json!({"type": "disabled"});
145    }
146    let mut req = client
147        .post(format!("{}/v1/chat/completions", llm.url))
148        .header("Content-Type", "application/json");
149
150    if let Some(ref key) = llm.api_key {
151        req = req.header("Authorization", format!("Bearer {key}"));
152    }
153    for (name, value) in &llm.headers {
154        req = req.header(name.as_str(), value.as_str());
155    }
156
157    let resp = req.json(&payload).send().await.ok()?;
158    let json: Value = resp.json().await.ok()?;
159    json["choices"][0]["message"]["content"]
160        .as_str()
161        .map(String::from)
162}
163
164/// JavaScript to extract interactive elements from the current page.
165/// Returns a JSON array of objects with tag, selector, and label.
166pub const DOM_EXTRACT_JS: &str = r#"
167(() => {
168  const interactive = 'a, button, input, textarea, select, [role="button"], [onclick], [tabindex], [data-testid], [aria-label]';
169  const els = document.querySelectorAll(interactive);
170  const info = [];
171  const seen = new Set();
172  els.forEach((el, i) => {
173    const rect = el.getBoundingClientRect();
174    if (rect.width === 0 || rect.height === 0) return;
175    const tag = el.tagName.toLowerCase();
176    let selector = '';
177    if (el.id) selector = '#' + CSS.escape(el.id);
178    else if (el.getAttribute('data-testid')) selector = '[data-testid="' + el.getAttribute('data-testid') + '"]';
179    else if (el.name) selector = '[name="' + CSS.escape(el.name) + '"]';
180    else if (el.className && typeof el.className === 'string') {
181      const cls = el.className.trim().split(/\\s+/)[0];
182      if (cls) selector = tag + '.' + CSS.escape(cls);
183    }
184    if (!selector) selector = tag;
185    if (seen.has(selector)) return;
186    seen.add(selector);
187
188    let label = '';
189    const aria = el.getAttribute('aria-label');
190    if (aria) {
191      label = aria;
192    } else if (tag === 'input' || tag === 'textarea' || tag === 'select') {
193      label = el.placeholder || el.name || el.getAttribute('aria-label') || '';
194      if (el.type && !label) label = el.type;
195    } else {
196      label = (el.textContent || '').trim().substring(0, 80);
197    }
198
199    info.push(i + ': ' + selector + ' [' + tag + '] "' + label + '"');
200  });
201  return JSON.stringify(info);
202})()
203"#;
204
205/// Truncates a string to the given maximum length, appending a marker
206/// if truncation occurred.
207#[must_use]
208pub fn truncate(s: &str, max_len: usize) -> String {
209    if s.len() <= max_len {
210        s.to_owned()
211    } else {
212        format!("{}...<truncated>", &s[..max_len])
213    }
214}