use serde_json::{Value, json};
use crate::error::TestError;
pub struct VictauriClient {
http: reqwest::Client,
base_url: String,
session_id: String,
next_id: u64,
}
impl VictauriClient {
pub async fn connect(port: u16) -> Result<Self, TestError> {
Self::connect_with_token(port, None).await
}
pub async fn connect_with_token(port: u16, token: Option<&str>) -> Result<Self, TestError> {
let base_url = format!("http://127.0.0.1:{port}");
let http = reqwest::Client::new();
let mut init_req = http
.post(format!("{base_url}/mcp"))
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.json(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "victauri-test", "version": env!("CARGO_PKG_VERSION")}
}
}));
if let Some(t) = token {
init_req = init_req.header("Authorization", format!("Bearer {t}"));
}
let init_resp = init_req
.send()
.await
.map_err(|e| TestError::Connection(e.to_string()))?;
if !init_resp.status().is_success() {
return Err(TestError::Connection(format!(
"initialize returned {}",
init_resp.status()
)));
}
let session_id = init_resp
.headers()
.get("mcp-session-id")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| TestError::Connection("no mcp-session-id header".into()))?
.to_string();
let mut notify_req = http
.post(format!("{base_url}/mcp"))
.header("Content-Type", "application/json")
.header("mcp-session-id", &session_id)
.json(&json!({
"jsonrpc": "2.0",
"method": "notifications/initialized"
}));
if let Some(t) = token {
notify_req = notify_req.header("Authorization", format!("Bearer {t}"));
}
notify_req.send().await?;
Ok(Self {
http,
base_url,
session_id,
next_id: 10,
})
}
pub async fn call_tool(&mut self, name: &str, arguments: Value) -> Result<Value, TestError> {
let id = self.next_id;
self.next_id += 1;
let resp = self
.http
.post(format!("{}/mcp", self.base_url))
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header("mcp-session-id", &self.session_id)
.json(&json!({
"jsonrpc": "2.0",
"id": id,
"method": "tools/call",
"params": {
"name": name,
"arguments": arguments
}
}))
.send()
.await?;
let body: Value = resp.json().await?;
if let Some(error) = body.get("error") {
return Err(TestError::Mcp {
code: error["code"].as_i64().unwrap_or(-1),
message: error["message"].as_str().unwrap_or("unknown").to_string(),
});
}
let content = &body["result"]["content"];
if let Some(arr) = content.as_array()
&& let Some(first) = arr.first()
&& let Some(text) = first["text"].as_str()
{
if let Ok(parsed) = serde_json::from_str::<Value>(text) {
return Ok(parsed);
}
return Ok(Value::String(text.to_string()));
}
Ok(body)
}
pub async fn eval_js(&mut self, code: &str) -> Result<Value, TestError> {
self.call_tool("eval_js", json!({"code": code})).await
}
pub async fn dom_snapshot(&mut self) -> Result<Value, TestError> {
self.call_tool("dom_snapshot", json!({})).await
}
pub async fn click(&mut self, ref_id: &str) -> Result<Value, TestError> {
self.call_tool("click", json!({"ref_id": ref_id})).await
}
pub async fn fill(&mut self, ref_id: &str, value: &str) -> Result<Value, TestError> {
self.call_tool("fill", json!({"ref_id": ref_id, "value": value}))
.await
}
pub async fn type_text(&mut self, ref_id: &str, text: &str) -> Result<Value, TestError> {
self.call_tool("type_text", json!({"ref_id": ref_id, "text": text}))
.await
}
pub async fn list_windows(&mut self) -> Result<Value, TestError> {
self.call_tool("list_windows", json!({})).await
}
pub async fn get_window_state(&mut self, label: Option<&str>) -> Result<Value, TestError> {
let args = match label {
Some(l) => json!({"label": l}),
None => json!({}),
};
self.call_tool("get_window_state", args).await
}
pub async fn screenshot(&mut self) -> Result<Value, TestError> {
self.call_tool("screenshot", json!({})).await
}
pub async fn invoke_command(
&mut self,
command: &str,
args: Option<Value>,
) -> Result<Value, TestError> {
let mut params = json!({"command": command});
if let Some(a) = args {
params["args"] = a;
}
self.call_tool("invoke_command", params).await
}
pub async fn get_ipc_log(&mut self, limit: Option<usize>) -> Result<Value, TestError> {
let args = match limit {
Some(n) => json!({"limit": n}),
None => json!({}),
};
self.call_tool("get_ipc_log", args).await
}
pub async fn verify_state(
&mut self,
frontend_expr: &str,
backend_state: Value,
) -> Result<Value, TestError> {
self.call_tool(
"verify_state",
json!({
"frontend_expr": frontend_expr,
"backend_state": backend_state,
}),
)
.await
}
pub async fn detect_ghost_commands(&mut self) -> Result<Value, TestError> {
self.call_tool("detect_ghost_commands", json!({})).await
}
pub async fn check_ipc_integrity(&mut self) -> Result<Value, TestError> {
self.call_tool("check_ipc_integrity", json!({})).await
}
pub async fn assert_semantic(
&mut self,
expression: &str,
label: &str,
condition: &str,
expected: Value,
) -> Result<Value, TestError> {
self.call_tool(
"assert_semantic",
json!({
"expression": expression,
"label": label,
"condition": condition,
"expected": expected,
}),
)
.await
}
pub async fn audit_accessibility(&mut self) -> Result<Value, TestError> {
self.call_tool("audit_accessibility", json!({})).await
}
pub async fn get_performance_metrics(&mut self) -> Result<Value, TestError> {
self.call_tool("get_performance_metrics", json!({})).await
}
pub async fn get_registry(&mut self) -> Result<Value, TestError> {
self.call_tool("get_registry", json!({})).await
}
pub async fn get_memory_stats(&mut self) -> Result<Value, TestError> {
self.call_tool("get_memory_stats", json!({})).await
}
pub async fn get_plugin_info(&mut self) -> Result<Value, TestError> {
self.call_tool("get_plugin_info", json!({})).await
}
pub async fn wait_for(
&mut self,
condition: &str,
timeout_ms: Option<u64>,
interval_ms: Option<u64>,
) -> Result<Value, TestError> {
let mut args = json!({"condition": condition});
if let Some(t) = timeout_ms {
args["timeout_ms"] = json!(t);
}
if let Some(i) = interval_ms {
args["interval_ms"] = json!(i);
}
self.call_tool("wait_for", args).await
}
pub async fn start_recording(&mut self, session_id: Option<&str>) -> Result<Value, TestError> {
let args = match session_id {
Some(id) => json!({"session_id": id}),
None => json!({}),
};
self.call_tool("start_recording", args).await
}
pub async fn stop_recording(&mut self) -> Result<Value, TestError> {
self.call_tool("stop_recording", json!({})).await
}
pub async fn export_session(&mut self) -> Result<Value, TestError> {
self.call_tool("export_session", json!({})).await
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn session_id(&self) -> &str {
&self.session_id
}
}
pub fn assert_json_eq(value: &Value, pointer: &str, expected: &Value) {
let actual = value.pointer(pointer);
assert!(
actual == Some(expected),
"JSON pointer {pointer}: expected {expected}, got {}",
actual.map_or("missing".to_string(), |v| v.to_string())
);
}
pub fn assert_json_truthy(value: &Value, pointer: &str) {
let actual = value.pointer(pointer);
let is_truthy = match actual {
None | Some(Value::Null) => false,
Some(Value::Bool(b)) => *b,
Some(Value::Number(n)) => n.as_f64().unwrap_or(0.0) != 0.0,
Some(Value::String(s)) => !s.is_empty(),
Some(Value::Array(a)) => !a.is_empty(),
Some(Value::Object(_)) => true,
};
assert!(
is_truthy,
"JSON pointer {pointer}: expected truthy, got {}",
actual.map_or("missing".to_string(), |v| v.to_string())
);
}
pub fn assert_no_a11y_violations(audit: &Value) {
let violations = audit
.pointer("/summary/violations")
.and_then(|v| v.as_u64())
.unwrap_or(u64::MAX);
assert_eq!(
violations, 0,
"expected 0 accessibility violations, got {violations}"
);
}
pub fn assert_performance_budget(metrics: &Value, max_load_ms: f64, max_heap_mb: f64) {
if let Some(load) = metrics
.pointer("/navigation/load_event_end")
.and_then(|v| v.as_f64())
{
assert!(
load <= max_load_ms,
"load event took {load}ms, budget is {max_load_ms}ms"
);
}
if let Some(heap) = metrics.pointer("/js_heap/used_mb").and_then(|v| v.as_f64()) {
assert!(
heap <= max_heap_mb,
"JS heap is {heap}MB, budget is {max_heap_mb}MB"
);
}
}
pub fn assert_ipc_healthy(integrity: &Value) {
let healthy = integrity
.get("healthy")
.and_then(|v| v.as_bool())
.unwrap_or(false);
assert!(
healthy,
"IPC integrity check failed: {}",
serde_json::to_string_pretty(integrity).unwrap_or_default()
);
}
pub fn assert_state_matches(verification: &Value) {
let passed = verification
.get("passed")
.and_then(|v| v.as_bool())
.unwrap_or(false);
assert!(
passed,
"state verification failed: {}",
serde_json::to_string_pretty(verification).unwrap_or_default()
);
}