1use serde_json::{Value, json};
2
3use crate::error::TestError;
4
5pub struct VictauriClient {
10 http: reqwest::Client,
11 base_url: String,
12 session_id: String,
13 next_id: u64,
14}
15
16impl VictauriClient {
17 pub async fn connect(port: u16) -> Result<Self, TestError> {
20 Self::connect_with_token(port, None).await
21 }
22
23 pub async fn connect_with_token(port: u16, token: Option<&str>) -> Result<Self, TestError> {
25 let base_url = format!("http://127.0.0.1:{port}");
26 let http = reqwest::Client::new();
27
28 let mut init_req = http
29 .post(format!("{base_url}/mcp"))
30 .header("Content-Type", "application/json")
31 .header("Accept", "application/json, text/event-stream")
32 .json(&json!({
33 "jsonrpc": "2.0",
34 "id": 1,
35 "method": "initialize",
36 "params": {
37 "protocolVersion": "2025-03-26",
38 "capabilities": {},
39 "clientInfo": {"name": "victauri-test", "version": env!("CARGO_PKG_VERSION")}
40 }
41 }));
42
43 if let Some(t) = token {
44 init_req = init_req.header("Authorization", format!("Bearer {t}"));
45 }
46
47 let init_resp = init_req
48 .send()
49 .await
50 .map_err(|e| TestError::Connection(e.to_string()))?;
51
52 if !init_resp.status().is_success() {
53 return Err(TestError::Connection(format!(
54 "initialize returned {}",
55 init_resp.status()
56 )));
57 }
58
59 let session_id = init_resp
60 .headers()
61 .get("mcp-session-id")
62 .and_then(|v| v.to_str().ok())
63 .ok_or_else(|| TestError::Connection("no mcp-session-id header".into()))?
64 .to_string();
65
66 let mut notify_req = http
67 .post(format!("{base_url}/mcp"))
68 .header("Content-Type", "application/json")
69 .header("mcp-session-id", &session_id)
70 .json(&json!({
71 "jsonrpc": "2.0",
72 "method": "notifications/initialized"
73 }));
74
75 if let Some(t) = token {
76 notify_req = notify_req.header("Authorization", format!("Bearer {t}"));
77 }
78
79 notify_req.send().await?;
80
81 Ok(Self {
82 http,
83 base_url,
84 session_id,
85 next_id: 10,
86 })
87 }
88
89 pub async fn call_tool(&mut self, name: &str, arguments: Value) -> Result<Value, TestError> {
91 let id = self.next_id;
92 self.next_id += 1;
93
94 let resp = self
95 .http
96 .post(format!("{}/mcp", self.base_url))
97 .header("Content-Type", "application/json")
98 .header("Accept", "application/json, text/event-stream")
99 .header("mcp-session-id", &self.session_id)
100 .json(&json!({
101 "jsonrpc": "2.0",
102 "id": id,
103 "method": "tools/call",
104 "params": {
105 "name": name,
106 "arguments": arguments
107 }
108 }))
109 .send()
110 .await?;
111
112 let body: Value = resp.json().await?;
113
114 if let Some(error) = body.get("error") {
115 return Err(TestError::Mcp {
116 code: error["code"].as_i64().unwrap_or(-1),
117 message: error["message"].as_str().unwrap_or("unknown").to_string(),
118 });
119 }
120
121 let content = &body["result"]["content"];
122 if let Some(arr) = content.as_array()
123 && let Some(first) = arr.first()
124 && let Some(text) = first["text"].as_str()
125 {
126 if let Ok(parsed) = serde_json::from_str::<Value>(text) {
127 return Ok(parsed);
128 }
129 return Ok(Value::String(text.to_string()));
130 }
131
132 Ok(body)
133 }
134
135 pub async fn eval_js(&mut self, code: &str) -> Result<Value, TestError> {
137 self.call_tool("eval_js", json!({"code": code})).await
138 }
139
140 pub async fn dom_snapshot(&mut self) -> Result<Value, TestError> {
142 self.call_tool("dom_snapshot", json!({})).await
143 }
144
145 pub async fn click(&mut self, ref_id: &str) -> Result<Value, TestError> {
147 self.call_tool("click", json!({"ref_id": ref_id})).await
148 }
149
150 pub async fn fill(&mut self, ref_id: &str, value: &str) -> Result<Value, TestError> {
152 self.call_tool("fill", json!({"ref_id": ref_id, "value": value}))
153 .await
154 }
155
156 pub async fn type_text(&mut self, ref_id: &str, text: &str) -> Result<Value, TestError> {
158 self.call_tool("type_text", json!({"ref_id": ref_id, "text": text}))
159 .await
160 }
161
162 pub async fn list_windows(&mut self) -> Result<Value, TestError> {
164 self.call_tool("list_windows", json!({})).await
165 }
166
167 pub async fn get_window_state(&mut self, label: Option<&str>) -> Result<Value, TestError> {
169 let args = match label {
170 Some(l) => json!({"label": l}),
171 None => json!({}),
172 };
173 self.call_tool("get_window_state", args).await
174 }
175
176 pub async fn screenshot(&mut self) -> Result<Value, TestError> {
178 self.call_tool("screenshot", json!({})).await
179 }
180
181 pub async fn invoke_command(
183 &mut self,
184 command: &str,
185 args: Option<Value>,
186 ) -> Result<Value, TestError> {
187 let mut params = json!({"command": command});
188 if let Some(a) = args {
189 params["args"] = a;
190 }
191 self.call_tool("invoke_command", params).await
192 }
193
194 pub async fn get_ipc_log(&mut self, limit: Option<usize>) -> Result<Value, TestError> {
196 let args = match limit {
197 Some(n) => json!({"limit": n}),
198 None => json!({}),
199 };
200 self.call_tool("get_ipc_log", args).await
201 }
202
203 pub async fn verify_state(
205 &mut self,
206 frontend_expr: &str,
207 backend_state: Value,
208 ) -> Result<Value, TestError> {
209 self.call_tool(
210 "verify_state",
211 json!({
212 "frontend_expr": frontend_expr,
213 "backend_state": backend_state,
214 }),
215 )
216 .await
217 }
218
219 pub async fn detect_ghost_commands(&mut self) -> Result<Value, TestError> {
221 self.call_tool("detect_ghost_commands", json!({})).await
222 }
223
224 pub async fn check_ipc_integrity(&mut self) -> Result<Value, TestError> {
226 self.call_tool("check_ipc_integrity", json!({})).await
227 }
228
229 pub async fn assert_semantic(
231 &mut self,
232 expression: &str,
233 label: &str,
234 condition: &str,
235 expected: Value,
236 ) -> Result<Value, TestError> {
237 self.call_tool(
238 "assert_semantic",
239 json!({
240 "expression": expression,
241 "label": label,
242 "condition": condition,
243 "expected": expected,
244 }),
245 )
246 .await
247 }
248
249 pub async fn audit_accessibility(&mut self) -> Result<Value, TestError> {
251 self.call_tool("audit_accessibility", json!({})).await
252 }
253
254 pub async fn get_performance_metrics(&mut self) -> Result<Value, TestError> {
256 self.call_tool("get_performance_metrics", json!({})).await
257 }
258
259 pub async fn get_registry(&mut self) -> Result<Value, TestError> {
261 self.call_tool("get_registry", json!({})).await
262 }
263
264 pub async fn get_memory_stats(&mut self) -> Result<Value, TestError> {
266 self.call_tool("get_memory_stats", json!({})).await
267 }
268
269 pub async fn get_plugin_info(&mut self) -> Result<Value, TestError> {
271 self.call_tool("get_plugin_info", json!({})).await
272 }
273
274 pub async fn wait_for(
276 &mut self,
277 condition: &str,
278 timeout_ms: Option<u64>,
279 interval_ms: Option<u64>,
280 ) -> Result<Value, TestError> {
281 let mut args = json!({"condition": condition});
282 if let Some(t) = timeout_ms {
283 args["timeout_ms"] = json!(t);
284 }
285 if let Some(i) = interval_ms {
286 args["interval_ms"] = json!(i);
287 }
288 self.call_tool("wait_for", args).await
289 }
290
291 pub async fn start_recording(&mut self, session_id: Option<&str>) -> Result<Value, TestError> {
293 let args = match session_id {
294 Some(id) => json!({"session_id": id}),
295 None => json!({}),
296 };
297 self.call_tool("start_recording", args).await
298 }
299
300 pub async fn stop_recording(&mut self) -> Result<Value, TestError> {
302 self.call_tool("stop_recording", json!({})).await
303 }
304
305 pub async fn export_session(&mut self) -> Result<Value, TestError> {
307 self.call_tool("export_session", json!({})).await
308 }
309
310 pub fn base_url(&self) -> &str {
312 &self.base_url
313 }
314
315 pub fn session_id(&self) -> &str {
317 &self.session_id
318 }
319}
320
321pub fn assert_json_eq(value: &Value, pointer: &str, expected: &Value) {
330 let actual = value.pointer(pointer);
331 assert!(
332 actual == Some(expected),
333 "JSON pointer {pointer}: expected {expected}, got {}",
334 actual.map_or("missing".to_string(), |v| v.to_string())
335 );
336}
337
338pub fn assert_json_truthy(value: &Value, pointer: &str) {
340 let actual = value.pointer(pointer);
341 let is_truthy = match actual {
342 None | Some(Value::Null) => false,
343 Some(Value::Bool(b)) => *b,
344 Some(Value::Number(n)) => n.as_f64().unwrap_or(0.0) != 0.0,
345 Some(Value::String(s)) => !s.is_empty(),
346 Some(Value::Array(a)) => !a.is_empty(),
347 Some(Value::Object(_)) => true,
348 };
349 assert!(
350 is_truthy,
351 "JSON pointer {pointer}: expected truthy, got {}",
352 actual.map_or("missing".to_string(), |v| v.to_string())
353 );
354}
355
356pub fn assert_no_a11y_violations(audit: &Value) {
358 let violations = audit
359 .pointer("/summary/violations")
360 .and_then(|v| v.as_u64())
361 .unwrap_or(u64::MAX);
362 assert_eq!(
363 violations, 0,
364 "expected 0 accessibility violations, got {violations}"
365 );
366}
367
368pub fn assert_performance_budget(metrics: &Value, max_load_ms: f64, max_heap_mb: f64) {
370 if let Some(load) = metrics
371 .pointer("/navigation/load_event_end")
372 .and_then(|v| v.as_f64())
373 {
374 assert!(
375 load <= max_load_ms,
376 "load event took {load}ms, budget is {max_load_ms}ms"
377 );
378 }
379
380 if let Some(heap) = metrics.pointer("/js_heap/used_mb").and_then(|v| v.as_f64()) {
381 assert!(
382 heap <= max_heap_mb,
383 "JS heap is {heap}MB, budget is {max_heap_mb}MB"
384 );
385 }
386}
387
388pub fn assert_ipc_healthy(integrity: &Value) {
390 let healthy = integrity
391 .get("healthy")
392 .and_then(|v| v.as_bool())
393 .unwrap_or(false);
394 assert!(
395 healthy,
396 "IPC integrity check failed: {}",
397 serde_json::to_string_pretty(integrity).unwrap_or_default()
398 );
399}
400
401pub fn assert_state_matches(verification: &Value) {
403 let passed = verification
404 .get("passed")
405 .and_then(|v| v.as_bool())
406 .unwrap_or(false);
407 assert!(
408 passed,
409 "state verification failed: {}",
410 serde_json::to_string_pretty(verification).unwrap_or_default()
411 );
412}