#![allow(
clippy::expect_used,
clippy::unwrap_used,
clippy::panic,
clippy::missing_panics_doc
)]
pub mod runner;
pub mod scenario;
#[cfg(feature = "macros")]
pub mod macros;
use std::collections::HashMap;
use std::time::Duration;
use serde_json::Value;
#[derive(Debug, Clone)]
pub struct LlmConfig {
pub url: String,
pub model: String,
pub api_key: Option<String>,
pub headers: HashMap<String, String>,
pub timeout: Duration,
pub temperature: f64,
pub thinking: bool,
}
impl LlmConfig {
#[must_use]
pub fn from_env() -> Self {
Self {
url: llm_base_url(),
model: llm_model(),
api_key: std::env::var("HARNESS_LLM_API_KEY").ok(),
headers: parse_headers_env(),
timeout: Duration::from_secs(60),
temperature: 0.0,
thinking: false,
}
}
}
fn parse_headers_env() -> HashMap<String, String> {
let Ok(raw) = std::env::var("HARNESS_LLM_HEADERS") else {
return HashMap::new();
};
let Ok(json) = serde_json::from_str::<Value>(&raw) else {
return HashMap::new();
};
let Some(obj) = json.as_object() else {
return HashMap::new();
};
obj.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_owned())))
.collect()
}
#[must_use]
pub fn base_url() -> String {
std::env::var("HARNESS_BROWSER_BASE_URL").unwrap_or_else(|_| "http://localhost:4200".to_owned())
}
#[must_use]
pub fn llm_base_url() -> String {
std::env::var("HARNESS_LLM_TEST_URL")
.unwrap_or_else(|_| "http://localhost:8080".to_owned())
.trim_end_matches('/')
.to_owned()
}
#[must_use]
pub fn llm_model() -> String {
std::env::var("HARNESS_LLM_TEST_MODEL").unwrap_or_else(|_| "deepseek".to_owned())
}
#[must_use]
pub fn browser_headless() -> bool {
std::env::var("HARNESS_BROWSER_HEADLESS")
.map_or(true, |v| v != "0" && v.to_lowercase() != "false")
}
#[must_use]
pub fn http_client(timeout: Duration) -> reqwest::Client {
reqwest::Client::builder()
.timeout(timeout)
.build()
.expect("build reqwest client")
}
pub async fn llm_chat(
llm: &LlmConfig,
system: &str,
user: &str,
) -> Option<String> {
let client = http_client(llm.timeout);
let mut payload = serde_json::json!({
"model": llm.model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user}
],
"max_tokens": 4096,
"temperature": llm.temperature
});
if llm.thinking {
payload["thinking"] = serde_json::json!({"type": "enabled"});
} else {
payload["thinking"] = serde_json::json!({"type": "disabled"});
}
let mut req = client
.post(format!("{}/v1/chat/completions", llm.url))
.header("Content-Type", "application/json");
if let Some(ref key) = llm.api_key {
req = req.header("Authorization", format!("Bearer {key}"));
}
for (name, value) in &llm.headers {
req = req.header(name.as_str(), value.as_str());
}
let resp = req.json(&payload).send().await.ok()?;
let json: Value = resp.json().await.ok()?;
json["choices"][0]["message"]["content"]
.as_str()
.map(String::from)
}
pub const DOM_EXTRACT_JS: &str = r#"
(() => {
const interactive = 'a, button, input, textarea, select, [role="button"], [onclick], [tabindex], [data-testid], [aria-label]';
const els = document.querySelectorAll(interactive);
const info = [];
const seen = new Set();
els.forEach((el, i) => {
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const tag = el.tagName.toLowerCase();
let selector = '';
if (el.id) selector = '#' + CSS.escape(el.id);
else if (el.getAttribute('data-testid')) selector = '[data-testid="' + el.getAttribute('data-testid') + '"]';
else if (el.name) selector = '[name="' + CSS.escape(el.name) + '"]';
else if (el.className && typeof el.className === 'string') {
const cls = el.className.trim().split(/\\s+/)[0];
if (cls) selector = tag + '.' + CSS.escape(cls);
}
if (!selector) selector = tag;
if (seen.has(selector)) return;
seen.add(selector);
let label = '';
const aria = el.getAttribute('aria-label');
if (aria) {
label = aria;
} else if (tag === 'input' || tag === 'textarea' || tag === 'select') {
label = el.placeholder || el.name || el.getAttribute('aria-label') || '';
if (el.type && !label) label = el.type;
} else {
label = (el.textContent || '').trim().substring(0, 80);
}
info.push(i + ': ' + selector + ' [' + tag + '] "' + label + '"');
});
return JSON.stringify(info);
})()
"#;
#[must_use]
pub fn truncate(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_owned()
} else {
format!("{}...<truncated>", &s[..max_len])
}
}