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: bool,
48}
49
50impl LlmConfig {
51 #[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#[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#[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#[must_use]
102pub fn llm_model() -> String {
103 std::env::var("HARNESS_LLM_TEST_MODEL").unwrap_or_else(|_| "deepseek".to_owned())
104}
105
106#[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#[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
123pub 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
164pub 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#[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}