Skip to main content

victauri_test/
client.rs

1use serde_json::{Value, json};
2
3use crate::error::TestError;
4
5/// Typed HTTP client for the Victauri MCP server.
6///
7/// Manages session lifecycle (initialize → tool calls → cleanup) and provides
8/// convenient methods for common test operations.
9pub struct VictauriClient {
10    http: reqwest::Client,
11    base_url: String,
12    session_id: String,
13    next_id: u64,
14}
15
16impl VictauriClient {
17    /// Connect to a Victauri MCP server on the given port.
18    /// Sends `initialize` and `notifications/initialized` automatically.
19    pub async fn connect(port: u16) -> Result<Self, TestError> {
20        Self::connect_with_token(port, None).await
21    }
22
23    /// Connect with an optional Bearer auth token.
24    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    /// Call an MCP tool by name and return the result content as JSON.
90    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    /// Evaluate JavaScript in the webview and return the result.
136    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    /// Get a DOM snapshot of the current page.
141    pub async fn dom_snapshot(&mut self) -> Result<Value, TestError> {
142        self.call_tool("dom_snapshot", json!({})).await
143    }
144
145    /// Click an element by ref handle ID.
146    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    /// Fill an input element with a value.
151    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    /// Type text into an element character by character.
157    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    /// List all window labels.
163    pub async fn list_windows(&mut self) -> Result<Value, TestError> {
164        self.call_tool("list_windows", json!({})).await
165    }
166
167    /// Get the state of a specific window (or all windows).
168    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    /// Take a screenshot and return base64-encoded PNG.
177    pub async fn screenshot(&mut self) -> Result<Value, TestError> {
178        self.call_tool("screenshot", json!({})).await
179    }
180
181    /// Invoke a Tauri command by name with optional arguments.
182    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    /// Get the IPC call log.
195    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    /// Verify frontend state against backend state.
204    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    /// Detect ghost commands (registered but never called, or called but not registered).
220    pub async fn detect_ghost_commands(&mut self) -> Result<Value, TestError> {
221        self.call_tool("detect_ghost_commands", json!({})).await
222    }
223
224    /// Check IPC call health (pending, stale, errored).
225    pub async fn check_ipc_integrity(&mut self) -> Result<Value, TestError> {
226        self.call_tool("check_ipc_integrity", json!({})).await
227    }
228
229    /// Run a semantic assertion against a JS expression.
230    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    /// Run an accessibility audit.
250    pub async fn audit_accessibility(&mut self) -> Result<Value, TestError> {
251        self.call_tool("audit_accessibility", json!({})).await
252    }
253
254    /// Get performance metrics (timing, heap, resources).
255    pub async fn get_performance_metrics(&mut self) -> Result<Value, TestError> {
256        self.call_tool("get_performance_metrics", json!({})).await
257    }
258
259    /// Get the command registry.
260    pub async fn get_registry(&mut self) -> Result<Value, TestError> {
261        self.call_tool("get_registry", json!({})).await
262    }
263
264    /// Get process memory statistics.
265    pub async fn get_memory_stats(&mut self) -> Result<Value, TestError> {
266        self.call_tool("get_memory_stats", json!({})).await
267    }
268
269    /// Read plugin info (version, uptime, tool count).
270    pub async fn get_plugin_info(&mut self) -> Result<Value, TestError> {
271        self.call_tool("get_plugin_info", json!({})).await
272    }
273
274    /// Wait for a JS condition to become truthy, polling at an interval.
275    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    /// Start a time-travel recording session.
292    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    /// Stop the recording and return the session.
301    pub async fn stop_recording(&mut self) -> Result<Value, TestError> {
302        self.call_tool("stop_recording", json!({})).await
303    }
304
305    /// Export the current recording session as JSON.
306    pub async fn export_session(&mut self) -> Result<Value, TestError> {
307        self.call_tool("export_session", json!({})).await
308    }
309
310    /// Get the server base URL.
311    pub fn base_url(&self) -> &str {
312        &self.base_url
313    }
314
315    /// Get the MCP session ID.
316    pub fn session_id(&self) -> &str {
317        &self.session_id
318    }
319}
320
321// ── Assertion Helpers ────────────────────────────────────────────────────────
322
323/// Assert that a JSON value at the given pointer equals the expected value.
324///
325/// ```rust,ignore
326/// let state = client.get_window_state(Some("main")).await?;
327/// victauri_test::assert_json_eq(&state, "/visible", &json!(true));
328/// ```
329pub 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
338/// Assert that a JSON value at the given pointer is truthy (not null/false/0/"").
339pub 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
356/// Assert that an accessibility audit has zero violations.
357pub 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
368/// Assert that all performance metrics are within budget.
369pub 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
388/// Assert that IPC integrity is healthy (no stale or errored calls).
389pub 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
401/// Assert that state verification passed with no divergences.
402pub 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}