llm_browser_testkit/
lib.rs1#![allow(
10 clippy::expect_used,
11 clippy::unwrap_used,
12 clippy::panic,
13 clippy::missing_panics_doc
14)]
15
16pub mod runner;
18pub mod scenario;
20
21#[cfg(feature = "macros")]
23pub mod macros;
24
25use std::collections::HashMap;
26use std::time::Duration;
27
28use serde_json::Value;
29
30#[derive(Debug, Clone)]
33pub struct LlmConfig {
34 pub url: String,
36 pub model: String,
38 pub api_key: Option<String>,
40 pub headers: HashMap<String, String>,
42 pub timeout: Duration,
44 pub temperature: f64,
46 pub thinking: Option<bool>,
49 pub model_params: HashMap<String, Value>,
52}
53
54impl LlmConfig {
55 #[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#[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#[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#[must_use]
107pub fn llm_model() -> String {
108 std::env::var("HARNESS_LLM_TEST_MODEL").unwrap_or_else(|_| "deepseek".to_owned())
109}
110
111#[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#[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
128pub 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 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
178pub 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#[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}