Skip to main content

victauri_test/
client.rs

1use serde_json::{Value, json};
2
3use crate::assertions::VerifyBuilder;
4use crate::error::TestError;
5use crate::visual::{VisualDiff, VisualOptions};
6
7/// Typed HTTP client for the Victauri MCP server.
8///
9/// Manages session lifecycle (initialize → tool calls → cleanup) and provides
10/// convenient methods for common test operations.
11pub struct VictauriClient {
12    http: reqwest::Client,
13    base_url: String,
14    session_id: String,
15    next_id: u64,
16    auth_token: Option<String>,
17}
18
19impl VictauriClient {
20    /// Connect to a Victauri MCP server on the given port.
21    /// Sends `initialize` and `notifications/initialized` automatically.
22    ///
23    /// # Errors
24    ///
25    /// Returns [`TestError::Connection`] if the server is unreachable or
26    /// returns a non-success status. Returns [`TestError::Request`] on
27    /// HTTP transport failures.
28    pub async fn connect(port: u16) -> Result<Self, TestError> {
29        Self::connect_with_token(port, None).await
30    }
31
32    /// Connect with an optional Bearer auth token.
33    ///
34    /// Retries up to 3 times with exponential backoff on 429 (rate limited).
35    ///
36    /// # Errors
37    ///
38    /// Returns [`TestError::Connection`] if the server is unreachable or
39    /// returns a non-success status. Returns [`TestError::Request`] on
40    /// HTTP transport failures.
41    pub async fn connect_with_token(port: u16, token: Option<&str>) -> Result<Self, TestError> {
42        let base_url = format!("http://127.0.0.1:{port}");
43        let http = reqwest::Client::builder()
44            .timeout(std::time::Duration::from_secs(60))
45            .connect_timeout(std::time::Duration::from_secs(10))
46            .build()
47            .map_err(|e| TestError::Connection(e.to_string()))?;
48
49        let init_body = json!({
50            "jsonrpc": "2.0",
51            "id": 1,
52            "method": "initialize",
53            "params": {
54                "protocolVersion": "2025-03-26",
55                "capabilities": {},
56                "clientInfo": {"name": "victauri-test", "version": env!("CARGO_PKG_VERSION")}
57            }
58        });
59
60        let mut init_resp = None;
61        for attempt in 0..4 {
62            let mut req = http
63                .post(format!("{base_url}/mcp"))
64                .header("Content-Type", "application/json")
65                .header("Accept", "application/json, text/event-stream")
66                .json(&init_body);
67            if let Some(t) = token {
68                req = req.header("Authorization", format!("Bearer {t}"));
69            }
70
71            let resp = req
72                .send()
73                .await
74                .map_err(|e| TestError::Connection(e.to_string()))?;
75
76            if resp.status() == 429 && attempt < 3 {
77                let delay = std::time::Duration::from_millis(100 * (1 << attempt));
78                tokio::time::sleep(delay).await;
79                continue;
80            }
81
82            init_resp = Some(resp);
83            break;
84        }
85
86        let init_resp = init_resp
87            .ok_or_else(|| TestError::Connection("initialize failed after retries".into()))?;
88
89        if !init_resp.status().is_success() {
90            return Err(TestError::Connection(format!(
91                "initialize returned {}",
92                init_resp.status()
93            )));
94        }
95
96        let session_id = init_resp
97            .headers()
98            .get("mcp-session-id")
99            .and_then(|v| v.to_str().ok())
100            .ok_or_else(|| TestError::Connection("no mcp-session-id header".into()))?
101            .to_string();
102
103        let mut notify_req = http
104            .post(format!("{base_url}/mcp"))
105            .header("Content-Type", "application/json")
106            .header("mcp-session-id", &session_id)
107            .json(&json!({
108                "jsonrpc": "2.0",
109                "method": "notifications/initialized"
110            }));
111
112        if let Some(t) = token {
113            notify_req = notify_req.header("Authorization", format!("Bearer {t}"));
114        }
115
116        notify_req.send().await?;
117
118        Ok(Self {
119            http,
120            base_url,
121            session_id,
122            next_id: 10,
123            auth_token: token.map(String::from),
124        })
125    }
126
127    /// Auto-discover a running Victauri server via temp files.
128    ///
129    /// Reads `<temp>/victauri.port` and `<temp>/victauri.token` written by the
130    /// plugin on startup. Falls back to `VICTAURI_PORT` / `VICTAURI_AUTH_TOKEN`
131    /// env vars, then defaults (port 7373, no auth).
132    ///
133    /// # Errors
134    ///
135    /// Returns [`TestError::Connection`] if the server is unreachable or
136    /// returns a non-success status. Returns [`TestError::Request`] on
137    /// HTTP transport failures.
138    pub async fn discover() -> Result<Self, TestError> {
139        let port = Self::discover_port();
140        let token = Self::discover_token();
141        Self::connect_with_token(port, token.as_deref()).await
142    }
143
144    fn discover_port() -> u16 {
145        if let Ok(p) = std::env::var("VICTAURI_PORT")
146            && let Ok(port) = p.parse::<u16>()
147        {
148            return port;
149        }
150        let path = std::env::temp_dir().join("victauri.port");
151        if let Ok(contents) = std::fs::read_to_string(&path)
152            && let Ok(port) = contents.trim().parse::<u16>()
153        {
154            return port;
155        }
156        7373
157    }
158
159    fn discover_token() -> Option<String> {
160        if let Ok(token) = std::env::var("VICTAURI_AUTH_TOKEN") {
161            return Some(token);
162        }
163        let path = std::env::temp_dir().join("victauri.token");
164        let token = std::fs::read_to_string(&path).ok()?;
165        let token = token.trim().to_string();
166        if token.is_empty() { None } else { Some(token) }
167    }
168
169    /// Call an MCP tool by name and return the result content as JSON.
170    ///
171    /// Retries up to 3 times with exponential backoff on 429 (rate limited).
172    ///
173    /// # Errors
174    ///
175    /// Returns [`TestError::Connection`] if the request fails after retries.
176    /// Returns [`TestError::Request`] on HTTP transport errors.
177    /// Returns [`TestError::Mcp`] if the server returns a JSON-RPC error.
178    pub async fn call_tool(&mut self, name: &str, arguments: Value) -> Result<Value, TestError> {
179        let id = self.next_id;
180        self.next_id += 1;
181
182        let call_body = json!({
183            "jsonrpc": "2.0",
184            "id": id,
185            "method": "tools/call",
186            "params": {
187                "name": name,
188                "arguments": arguments
189            }
190        });
191
192        let mut resp = None;
193        for attempt in 0..4 {
194            let mut req = self
195                .http
196                .post(format!("{}/mcp", self.base_url))
197                .header("Content-Type", "application/json")
198                .header("Accept", "application/json, text/event-stream")
199                .header("mcp-session-id", &self.session_id)
200                .json(&call_body);
201            if let Some(ref t) = self.auth_token {
202                req = req.header("Authorization", format!("Bearer {t}"));
203            }
204            let r = req.send().await?;
205
206            if r.status() == 429 && attempt < 3 {
207                let delay = std::time::Duration::from_millis(100 * (1 << attempt));
208                tokio::time::sleep(delay).await;
209                continue;
210            }
211            resp = Some(r);
212            break;
213        }
214
215        let resp =
216            resp.ok_or_else(|| TestError::Connection("tool call failed after retries".into()))?;
217        let body = Self::parse_response(resp).await?;
218
219        if let Some(error) = body.get("error") {
220            return Err(TestError::Mcp {
221                code: error["code"].as_i64().unwrap_or(-1),
222                message: error["message"].as_str().unwrap_or("unknown").to_string(),
223            });
224        }
225
226        let content = &body["result"]["content"];
227        if let Some(arr) = content.as_array()
228            && let Some(first) = arr.first()
229            && let Some(text) = first["text"].as_str()
230        {
231            if let Ok(parsed) = serde_json::from_str::<Value>(text) {
232                return Ok(parsed);
233            }
234            return Ok(Value::String(text.to_string()));
235        }
236
237        Ok(body)
238    }
239
240    /// Parse a response that may be JSON or SSE (text/event-stream).
241    ///
242    /// rmcp's Streamable HTTP transport always returns SSE format with the
243    /// JSON-RPC payload in a `data:` line. This method handles both formats.
244    async fn parse_response(resp: reqwest::Response) -> Result<Value, TestError> {
245        let content_type = resp
246            .headers()
247            .get("content-type")
248            .and_then(|v| v.to_str().ok())
249            .unwrap_or("")
250            .to_string();
251
252        let text = resp.text().await?;
253
254        if content_type.contains("text/event-stream") {
255            for line in text.lines() {
256                let data = line
257                    .strip_prefix("data: ")
258                    .or_else(|| line.strip_prefix("data:"));
259                let Some(data) = data else { continue };
260                let trimmed = data.trim();
261                if trimmed.is_empty() {
262                    continue;
263                }
264                if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
265                    return Ok(parsed);
266                }
267            }
268            Err(TestError::Connection(
269                "SSE stream contained no JSON-RPC data".into(),
270            ))
271        } else {
272            serde_json::from_str(&text).map_err(|e| {
273                TestError::Connection(format!(
274                    "JSON parse error: {e}, body: {}",
275                    &text[..200.min(text.len())]
276                ))
277            })
278        }
279    }
280
281    /// Evaluate JavaScript in the webview and return the result.
282    ///
283    /// # Errors
284    ///
285    /// Returns errors from [`VictauriClient::call_tool`].
286    pub async fn eval_js(&mut self, code: &str) -> Result<Value, TestError> {
287        self.call_tool("eval_js", json!({"code": code})).await
288    }
289
290    /// Get a DOM snapshot of the current page.
291    ///
292    /// # Errors
293    ///
294    /// Returns errors from [`VictauriClient::call_tool`].
295    pub async fn dom_snapshot(&mut self) -> Result<Value, TestError> {
296        self.call_tool("dom_snapshot", json!({})).await
297    }
298
299    /// Click an element by ref handle ID.
300    ///
301    /// # Errors
302    ///
303    /// Returns errors from [`VictauriClient::call_tool`].
304    pub async fn click(&mut self, ref_id: &str) -> Result<Value, TestError> {
305        self.call_tool("interact", json!({"action": "click", "ref_id": ref_id}))
306            .await
307    }
308
309    /// Fill an input element with a value.
310    ///
311    /// # Errors
312    ///
313    /// Returns errors from [`VictauriClient::call_tool`].
314    pub async fn fill(&mut self, ref_id: &str, value: &str) -> Result<Value, TestError> {
315        self.call_tool(
316            "input",
317            json!({"action": "fill", "ref_id": ref_id, "value": value}),
318        )
319        .await
320    }
321
322    /// Type text into an element character by character.
323    ///
324    /// # Errors
325    ///
326    /// Returns errors from [`VictauriClient::call_tool`].
327    pub async fn type_text(&mut self, ref_id: &str, text: &str) -> Result<Value, TestError> {
328        self.call_tool(
329            "input",
330            json!({"action": "type_text", "ref_id": ref_id, "text": text}),
331        )
332        .await
333    }
334
335    /// List all window labels.
336    ///
337    /// # Errors
338    ///
339    /// Returns errors from [`VictauriClient::call_tool`].
340    pub async fn list_windows(&mut self) -> Result<Value, TestError> {
341        self.call_tool("window", json!({"action": "list"})).await
342    }
343
344    /// Get the state of a specific window (or all windows).
345    ///
346    /// # Errors
347    ///
348    /// Returns errors from [`VictauriClient::call_tool`].
349    pub async fn get_window_state(&mut self, label: Option<&str>) -> Result<Value, TestError> {
350        let mut args = json!({"action": "get_state"});
351        if let Some(l) = label {
352            args["label"] = json!(l);
353        }
354        self.call_tool("window", args).await
355    }
356
357    /// Take a screenshot and return base64-encoded PNG.
358    ///
359    /// # Errors
360    ///
361    /// Returns errors from [`VictauriClient::call_tool`].
362    pub async fn screenshot(&mut self) -> Result<Value, TestError> {
363        self.call_tool("screenshot", json!({})).await
364    }
365
366    /// Take a screenshot and compare it against a stored baseline.
367    ///
368    /// Captures the current window, extracts the base64 PNG data, and passes
369    /// it to [`visual::compare_screenshot`](crate::visual::compare_screenshot).
370    /// On first run the screenshot is saved as the new baseline.
371    ///
372    /// # Errors
373    ///
374    /// Returns [`TestError::VisualRegression`] if the diff exceeds the
375    /// threshold, or [`TestError::Other`] if the screenshot result does not
376    /// contain recognizable image data.
377    pub async fn screenshot_visual(
378        &mut self,
379        name: &str,
380        options: &VisualOptions,
381    ) -> Result<VisualDiff, TestError> {
382        let result = self.screenshot().await?;
383        let base64_data = extract_screenshot_base64(&result)?;
384        crate::visual::compare_screenshot(name, &base64_data, options)
385    }
386
387    /// Invoke a Tauri command by name with optional arguments.
388    ///
389    /// # Errors
390    ///
391    /// Returns errors from [`VictauriClient::call_tool`].
392    pub async fn invoke_command(
393        &mut self,
394        command: &str,
395        args: Option<Value>,
396    ) -> Result<Value, TestError> {
397        let mut params = json!({"command": command});
398        if let Some(a) = args {
399            params["args"] = a;
400        }
401        self.call_tool("invoke_command", params).await
402    }
403
404    /// Get the IPC call log.
405    ///
406    /// # Errors
407    ///
408    /// Returns errors from [`VictauriClient::call_tool`].
409    pub async fn get_ipc_log(&mut self, limit: Option<usize>) -> Result<Value, TestError> {
410        let mut args = json!({"action": "ipc"});
411        if let Some(n) = limit {
412            args["limit"] = json!(n);
413        }
414        self.call_tool("logs", args).await
415    }
416
417    /// Verify frontend state against backend state.
418    ///
419    /// # Errors
420    ///
421    /// Returns errors from [`VictauriClient::call_tool`].
422    pub async fn verify_state(
423        &mut self,
424        frontend_expr: &str,
425        backend_state: Value,
426    ) -> Result<Value, TestError> {
427        self.call_tool(
428            "verify_state",
429            json!({
430                "frontend_expr": frontend_expr,
431                "backend_state": backend_state,
432            }),
433        )
434        .await
435    }
436
437    /// Detect ghost commands (registered but never called, or called but not registered).
438    ///
439    /// # Errors
440    ///
441    /// Returns errors from [`VictauriClient::call_tool`].
442    pub async fn detect_ghost_commands(&mut self) -> Result<Value, TestError> {
443        self.call_tool("detect_ghost_commands", json!({})).await
444    }
445
446    /// Check IPC call health (pending, stale, errored).
447    ///
448    /// # Errors
449    ///
450    /// Returns errors from [`VictauriClient::call_tool`].
451    pub async fn check_ipc_integrity(&mut self) -> Result<Value, TestError> {
452        self.call_tool("check_ipc_integrity", json!({})).await
453    }
454
455    /// Run a semantic assertion against a JS expression.
456    ///
457    /// # Errors
458    ///
459    /// Returns errors from [`VictauriClient::call_tool`].
460    pub async fn assert_semantic(
461        &mut self,
462        expression: &str,
463        label: &str,
464        condition: &str,
465        expected: Value,
466    ) -> Result<Value, TestError> {
467        self.call_tool(
468            "assert_semantic",
469            json!({
470                "expression": expression,
471                "label": label,
472                "condition": condition,
473                "expected": expected,
474            }),
475        )
476        .await
477    }
478
479    /// Run an accessibility audit.
480    ///
481    /// # Errors
482    ///
483    /// Returns errors from [`VictauriClient::call_tool`].
484    pub async fn audit_accessibility(&mut self) -> Result<Value, TestError> {
485        self.call_tool("inspect", json!({"action": "audit_accessibility"}))
486            .await
487    }
488
489    /// Get performance metrics (timing, heap, resources).
490    ///
491    /// # Errors
492    ///
493    /// Returns errors from [`VictauriClient::call_tool`].
494    pub async fn get_performance_metrics(&mut self) -> Result<Value, TestError> {
495        self.call_tool("inspect", json!({"action": "get_performance"}))
496            .await
497    }
498
499    /// Get the command registry.
500    ///
501    /// # Errors
502    ///
503    /// Returns errors from [`VictauriClient::call_tool`].
504    pub async fn get_registry(&mut self) -> Result<Value, TestError> {
505        self.call_tool("get_registry", json!({})).await
506    }
507
508    /// Get process memory statistics.
509    ///
510    /// # Errors
511    ///
512    /// Returns errors from [`VictauriClient::call_tool`].
513    pub async fn get_memory_stats(&mut self) -> Result<Value, TestError> {
514        self.call_tool("get_memory_stats", json!({})).await
515    }
516
517    /// Read plugin info (version, uptime, tool count).
518    ///
519    /// # Errors
520    ///
521    /// Returns errors from [`VictauriClient::call_tool`].
522    pub async fn get_plugin_info(&mut self) -> Result<Value, TestError> {
523        self.call_tool("get_plugin_info", json!({})).await
524    }
525
526    /// Wait for a condition to be met, polling at an interval.
527    ///
528    /// Conditions: `text`, `text_gone`, `selector`, `selector_gone`, `url`,
529    /// `ipc_idle`, `network_idle`.
530    ///
531    /// # Errors
532    ///
533    /// Returns errors from [`VictauriClient::call_tool`].
534    pub async fn wait_for(
535        &mut self,
536        condition: &str,
537        value: Option<&str>,
538        timeout_ms: Option<u64>,
539        poll_ms: Option<u64>,
540    ) -> Result<Value, TestError> {
541        let mut args = json!({"condition": condition});
542        if let Some(v) = value {
543            args["value"] = json!(v);
544        }
545        if let Some(t) = timeout_ms {
546            args["timeout_ms"] = json!(t);
547        }
548        if let Some(p) = poll_ms {
549            args["poll_ms"] = json!(p);
550        }
551        self.call_tool("wait_for", args).await
552    }
553
554    /// Start a time-travel recording session.
555    ///
556    /// # Errors
557    ///
558    /// Returns errors from [`VictauriClient::call_tool`].
559    pub async fn start_recording(&mut self, session_id: Option<&str>) -> Result<Value, TestError> {
560        let mut args = json!({"action": "start"});
561        if let Some(id) = session_id {
562            args["session_id"] = json!(id);
563        }
564        self.call_tool("recording", args).await
565    }
566
567    /// Stop the recording and return the session.
568    ///
569    /// # Errors
570    ///
571    /// Returns errors from [`VictauriClient::call_tool`].
572    pub async fn stop_recording(&mut self) -> Result<Value, TestError> {
573        self.call_tool("recording", json!({"action": "stop"})).await
574    }
575
576    /// Export the current recording session as JSON.
577    ///
578    /// # Errors
579    ///
580    /// Returns errors from [`VictauriClient::call_tool`].
581    pub async fn export_session(&mut self) -> Result<Value, TestError> {
582        self.call_tool("recording", json!({"action": "export"}))
583            .await
584    }
585
586    /// Search for elements by various criteria without a full snapshot.
587    ///
588    /// # Errors
589    ///
590    /// Returns errors from [`VictauriClient::call_tool`].
591    pub async fn find_elements(&mut self, query: Value) -> Result<Value, TestError> {
592        self.call_tool("find_elements", query).await
593    }
594
595    /// Hover over an element by ref handle.
596    ///
597    /// # Errors
598    ///
599    /// Returns errors from [`VictauriClient::call_tool`].
600    pub async fn hover(&mut self, ref_id: &str) -> Result<Value, TestError> {
601        self.call_tool("interact", json!({"action": "hover", "ref_id": ref_id}))
602            .await
603    }
604
605    /// Focus an element by ref handle.
606    ///
607    /// # Errors
608    ///
609    /// Returns errors from [`VictauriClient::call_tool`].
610    pub async fn focus(&mut self, ref_id: &str) -> Result<Value, TestError> {
611        self.call_tool("interact", json!({"action": "focus", "ref_id": ref_id}))
612            .await
613    }
614
615    /// Press a keyboard key.
616    ///
617    /// # Errors
618    ///
619    /// Returns errors from [`VictauriClient::call_tool`].
620    pub async fn press_key(&mut self, key: &str) -> Result<Value, TestError> {
621        self.call_tool("input", json!({"action": "press_key", "key": key}))
622            .await
623    }
624
625    /// Navigate to a URL.
626    ///
627    /// # Errors
628    ///
629    /// Returns errors from [`VictauriClient::call_tool`].
630    pub async fn navigate(&mut self, url: &str) -> Result<Value, TestError> {
631        self.call_tool("navigate", json!({"action": "go_to", "url": url}))
632            .await
633    }
634
635    /// Get logs by type (console, network, ipc, navigation, dialogs).
636    ///
637    /// # Errors
638    ///
639    /// Returns errors from [`VictauriClient::call_tool`].
640    pub async fn logs(&mut self, action: &str, limit: Option<usize>) -> Result<Value, TestError> {
641        self.call_tool("logs", json!({"action": action, "limit": limit}))
642            .await
643    }
644
645    /// Scroll an element into view by ref handle.
646    ///
647    /// # Errors
648    ///
649    /// Returns errors from [`VictauriClient::call_tool`].
650    pub async fn scroll_to(&mut self, ref_id: &str) -> Result<Value, TestError> {
651        self.call_tool(
652            "interact",
653            json!({"action": "scroll_into_view", "ref_id": ref_id}),
654        )
655        .await
656    }
657
658    /// Select option(s) in a `<select>` element.
659    ///
660    /// # Errors
661    ///
662    /// Returns errors from [`VictauriClient::call_tool`].
663    pub async fn select_option(
664        &mut self,
665        ref_id: &str,
666        values: &[&str],
667    ) -> Result<Value, TestError> {
668        self.call_tool(
669            "interact",
670            json!({"action": "select_option", "ref_id": ref_id, "values": values}),
671        )
672        .await
673    }
674
675    /// Get the server base URL.
676    #[must_use]
677    pub fn base_url(&self) -> &str {
678        &self.base_url
679    }
680
681    /// Get the MCP session ID.
682    #[must_use]
683    pub fn session_id(&self) -> &str {
684        &self.session_id
685    }
686
687    pub(crate) fn http_client(&self) -> &reqwest::Client {
688        &self.http
689    }
690
691    // ── IPC Log Helpers ───────────────────────────────────────────────────────
692
693    /// Get IPC calls filtered to a specific command.
694    ///
695    /// Returns a Vec of all IPC log entries matching the given command name.
696    ///
697    /// # Errors
698    ///
699    /// Returns errors from [`VictauriClient::call_tool`].
700    pub async fn get_ipc_calls(&mut self, command: &str) -> Result<Vec<Value>, TestError> {
701        let log = self.get_ipc_log(None).await?;
702        let entries = if let Some(arr) = log.as_array() {
703            arr.clone()
704        } else if let Some(entries) = log.get("entries").and_then(Value::as_array) {
705            entries.clone()
706        } else {
707            return Ok(Vec::new());
708        };
709        Ok(entries
710            .into_iter()
711            .filter(|e| {
712                e.get("command")
713                    .and_then(Value::as_str)
714                    .is_some_and(|c| c == command)
715            })
716            .collect())
717    }
718
719    /// Snapshot the current IPC log length, for use with `ipc_calls_since`.
720    ///
721    /// # Errors
722    ///
723    /// Returns errors from [`VictauriClient::call_tool`].
724    pub async fn ipc_checkpoint(&mut self) -> Result<usize, TestError> {
725        let log = self.get_ipc_log(None).await?;
726        let len = if let Some(arr) = log.as_array() {
727            arr.len()
728        } else if let Some(entries) = log.get("entries").and_then(Value::as_array) {
729            entries.len()
730        } else {
731            0
732        };
733        Ok(len)
734    }
735
736    /// Get IPC calls made since a previous checkpoint.
737    ///
738    /// # Errors
739    ///
740    /// Returns errors from [`VictauriClient::call_tool`].
741    pub async fn ipc_calls_since(&mut self, checkpoint: usize) -> Result<Vec<Value>, TestError> {
742        let log = self.get_ipc_log(None).await?;
743        let entries = if let Some(arr) = log.as_array() {
744            arr.clone()
745        } else if let Some(entries) = log.get("entries").and_then(Value::as_array) {
746            entries.clone()
747        } else {
748            return Ok(Vec::new());
749        };
750        Ok(entries.into_iter().skip(checkpoint).collect())
751    }
752
753    // ── Fluent Verification Builder ───────────────────────────────────────────
754
755    /// Start a fluent verification chain that checks multiple conditions at once.
756    ///
757    /// Unlike individual assertions that panic on failure, `verify()` collects
758    /// all results and reports them together — making test failures more
759    /// informative and reducing test reruns.
760    ///
761    /// # Examples
762    ///
763    /// ```rust,ignore
764    /// let report = client.verify()
765    ///     .has_text("Welcome")
766    ///     .ipc_was_called("greet")
767    ///     .no_console_errors()
768    ///     .run()
769    ///     .await
770    ///     .unwrap();
771    /// report.assert_all_passed();
772    /// ```
773    pub fn verify(&mut self) -> VerifyBuilder<'_> {
774        VerifyBuilder::new(self)
775    }
776
777    // ── High-Level Playwright-Style API ─────────────────────────────────────
778
779    /// Click the first element whose accessible text contains the given string.
780    ///
781    /// Takes a DOM snapshot, finds the element, and clicks it.
782    ///
783    /// # Errors
784    ///
785    /// Returns [`TestError::ElementNotFound`] if no matching element is found.
786    /// Returns other errors from [`VictauriClient::call_tool`].
787    pub async fn click_by_text(&mut self, text: &str) -> Result<Value, TestError> {
788        let ref_id = self.find_ref_by_text(text).await?;
789        self.click(&ref_id).await
790    }
791
792    /// Click the element with the given HTML `id` attribute.
793    ///
794    /// # Errors
795    ///
796    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
797    /// Returns other errors from [`VictauriClient::call_tool`].
798    pub async fn click_by_id(&mut self, id: &str) -> Result<Value, TestError> {
799        let ref_id = self.find_ref_by_id(id).await?;
800        self.click(&ref_id).await
801    }
802
803    /// Fill an input identified by HTML `id` with the given value.
804    ///
805    /// # Errors
806    ///
807    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
808    /// Returns other errors from [`VictauriClient::call_tool`].
809    pub async fn fill_by_id(&mut self, id: &str, value: &str) -> Result<Value, TestError> {
810        let ref_id = self.find_ref_by_id(id).await?;
811        self.fill(&ref_id, value).await
812    }
813
814    /// Type text into an input identified by HTML `id`, character by character.
815    ///
816    /// # Errors
817    ///
818    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
819    /// Returns other errors from [`VictauriClient::call_tool`].
820    pub async fn type_by_id(&mut self, id: &str, text: &str) -> Result<Value, TestError> {
821        let ref_id = self.find_ref_by_id(id).await?;
822        self.type_text(&ref_id, text).await
823    }
824
825    /// Wait until the page contains the given text (polls DOM snapshots).
826    ///
827    /// Default timeout: 5000ms, poll interval: 200ms.
828    ///
829    /// # Errors
830    ///
831    /// Returns [`TestError::Timeout`] if the text doesn't appear within the timeout.
832    /// Returns other errors from [`VictauriClient::call_tool`].
833    pub async fn expect_text(&mut self, text: &str) -> Result<(), TestError> {
834        self.expect_text_with_timeout(text, 5000).await
835    }
836
837    /// Wait until the page contains the given text, with a custom timeout in ms.
838    ///
839    /// # Errors
840    ///
841    /// Returns [`TestError::Timeout`] if the text doesn't appear within the timeout.
842    /// Returns other errors from [`VictauriClient::call_tool`].
843    pub async fn expect_text_with_timeout(
844        &mut self,
845        text: &str,
846        timeout_ms: u64,
847    ) -> Result<(), TestError> {
848        let result = self
849            .wait_for("text", Some(text), Some(timeout_ms), Some(200))
850            .await?;
851        if result.get("ok").and_then(Value::as_bool) == Some(true) {
852            Ok(())
853        } else {
854            Err(TestError::Timeout(format!(
855                "text \"{text}\" did not appear within {timeout_ms}ms"
856            )))
857        }
858    }
859
860    /// Wait until the page no longer contains the given text.
861    ///
862    /// Default timeout: 3000ms, poll interval: 200ms.
863    ///
864    /// # Errors
865    ///
866    /// Returns [`TestError::Timeout`] if the text is still present after the timeout.
867    /// Returns other errors from [`VictauriClient::call_tool`].
868    pub async fn expect_no_text(&mut self, text: &str) -> Result<(), TestError> {
869        let result = self
870            .wait_for("text_gone", Some(text), Some(3000), Some(200))
871            .await?;
872        if result.get("ok").and_then(Value::as_bool) == Some(true) {
873            Ok(())
874        } else {
875            Err(TestError::Timeout(format!(
876                "text \"{text}\" still present after 3000ms"
877            )))
878        }
879    }
880
881    /// Select an option in a `<select>` element identified by HTML `id`.
882    ///
883    /// # Errors
884    ///
885    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
886    /// Returns other errors from [`VictauriClient::call_tool`].
887    pub async fn select_by_id(&mut self, id: &str, value: &str) -> Result<Value, TestError> {
888        let ref_id = self.find_ref_by_id(id).await?;
889        self.select_option(&ref_id, &[value]).await
890    }
891
892    /// Get the text content of an element identified by HTML `id`.
893    ///
894    /// # Errors
895    ///
896    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
897    /// Returns other errors from [`VictauriClient::call_tool`].
898    pub async fn text_by_id(&mut self, id: &str) -> Result<String, TestError> {
899        let snap = self.snapshot_json().await?;
900        let tree = &snap["tree"];
901        find_text_by_attr_id(tree, id)
902            .ok_or_else(|| TestError::ElementNotFound(format!("id=\"{id}\"")))
903    }
904
905    // ── Internal helpers for high-level API ─────────────────────────────────
906
907    async fn snapshot_json(&mut self) -> Result<Value, TestError> {
908        self.call_tool("dom_snapshot", json!({"format": "json"}))
909            .await
910    }
911
912    async fn find_ref_by_text(&mut self, text: &str) -> Result<String, TestError> {
913        let snap = self.snapshot_json().await?;
914        let tree = &snap["tree"];
915        find_in_tree_by_text(tree, text)
916            .ok_or_else(|| TestError::ElementNotFound(format!("text=\"{text}\"")))
917    }
918
919    async fn find_ref_by_id(&mut self, id: &str) -> Result<String, TestError> {
920        let snap = self.snapshot_json().await?;
921        let tree = &snap["tree"];
922        find_in_tree_by_attr_id(tree, id)
923            .ok_or_else(|| TestError::ElementNotFound(format!("id=\"{id}\"")))
924    }
925}
926
927fn extract_screenshot_base64(result: &Value) -> Result<String, TestError> {
928    // Try various response shapes the plugin may return
929    if let Some(data) = result.get("base64").and_then(Value::as_str) {
930        return Ok(data.to_string());
931    }
932    if let Some(data) = result.get("data").and_then(Value::as_str) {
933        return Ok(data.to_string());
934    }
935    if let Some(data) = result.get("image").and_then(Value::as_str) {
936        return Ok(data.to_string());
937    }
938    if let Some(data) = result
939        .pointer("/result/content/0/data")
940        .and_then(Value::as_str)
941    {
942        return Ok(data.to_string());
943    }
944    Err(TestError::Other(
945        "screenshot result does not contain recognizable base64 image data".to_string(),
946    ))
947}
948
949fn find_in_tree_by_text(node: &Value, text: &str) -> Option<String> {
950    let node_text = node.get("text").and_then(Value::as_str).unwrap_or("");
951    let node_name = node.get("name").and_then(Value::as_str).unwrap_or("");
952    if (node_text.contains(text) || node_name.contains(text))
953        && let Some(ref_id) = node.get("ref_id").and_then(Value::as_str)
954    {
955        return Some(ref_id.to_string());
956    }
957    if let Some(children) = node.get("children").and_then(Value::as_array) {
958        for child in children {
959            if let Some(found) = find_in_tree_by_text(child, text) {
960                return Some(found);
961            }
962        }
963    }
964    None
965}
966
967fn find_in_tree_by_attr_id(node: &Value, id: &str) -> Option<String> {
968    if node
969        .get("attributes")
970        .and_then(|a| a.get("id"))
971        .and_then(Value::as_str)
972        == Some(id)
973        && let Some(ref_id) = node.get("ref_id").and_then(Value::as_str)
974    {
975        return Some(ref_id.to_string());
976    }
977    if let Some(children) = node.get("children").and_then(Value::as_array) {
978        for child in children {
979            if let Some(found) = find_in_tree_by_attr_id(child, id) {
980                return Some(found);
981            }
982        }
983    }
984    None
985}
986
987fn find_text_by_attr_id(node: &Value, id: &str) -> Option<String> {
988    if node
989        .get("attributes")
990        .and_then(|a| a.get("id"))
991        .and_then(Value::as_str)
992        == Some(id)
993    {
994        let text = node.get("text").and_then(Value::as_str).unwrap_or("");
995        return Some(text.to_string());
996    }
997    if let Some(children) = node.get("children").and_then(Value::as_array) {
998        for child in children {
999            if let Some(found) = find_text_by_attr_id(child, id) {
1000                return Some(found);
1001            }
1002        }
1003    }
1004    None
1005}
1006
1007// ── Assertion Helpers ────────────────────────────────────────────────────────
1008
1009/// Assert that a JSON value at the given pointer equals the expected value.
1010///
1011/// # Panics
1012///
1013/// Panics if the value at `pointer` is missing or does not equal `expected`.
1014///
1015/// # Examples
1016///
1017/// ```
1018/// use serde_json::json;
1019///
1020/// let state = json!({"visible": true, "title": "My App"});
1021/// victauri_test::assert_json_eq(&state, "/visible", &json!(true));
1022/// victauri_test::assert_json_eq(&state, "/title", &json!("My App"));
1023/// ```
1024pub fn assert_json_eq(value: &Value, pointer: &str, expected: &Value) {
1025    let actual = value.pointer(pointer);
1026    assert!(
1027        actual == Some(expected),
1028        "JSON pointer {pointer}: expected {expected}, got {}",
1029        actual.map_or("missing".to_string(), std::string::ToString::to_string)
1030    );
1031}
1032
1033/// Assert that a JSON value at the given pointer is truthy (not null/false/0/"").
1034///
1035/// # Panics
1036///
1037/// Panics if the value at `pointer` is missing, null, false, zero, or empty.
1038///
1039/// # Examples
1040///
1041/// ```
1042/// use serde_json::json;
1043///
1044/// let value = json!({"active": true, "name": "test", "count": 42});
1045/// victauri_test::assert_json_truthy(&value, "/active");
1046/// victauri_test::assert_json_truthy(&value, "/name");
1047/// victauri_test::assert_json_truthy(&value, "/count");
1048/// ```
1049pub fn assert_json_truthy(value: &Value, pointer: &str) {
1050    let actual = value.pointer(pointer);
1051    let is_truthy = match actual {
1052        None | Some(Value::Null) => false,
1053        Some(Value::Bool(b)) => *b,
1054        Some(Value::Number(n)) => n.as_f64().unwrap_or(0.0) != 0.0,
1055        Some(Value::String(s)) => !s.is_empty(),
1056        Some(Value::Array(a)) => !a.is_empty(),
1057        Some(Value::Object(_)) => true,
1058    };
1059    assert!(
1060        is_truthy,
1061        "JSON pointer {pointer}: expected truthy, got {}",
1062        actual.map_or("missing".to_string(), std::string::ToString::to_string)
1063    );
1064}
1065
1066/// Assert that an accessibility audit has zero violations.
1067///
1068/// # Panics
1069///
1070/// Panics if the audit contains any violations.
1071///
1072/// # Examples
1073///
1074/// ```
1075/// use serde_json::json;
1076///
1077/// let audit = json!({"summary": {"violations": 0, "passes": 12}});
1078/// victauri_test::assert_no_a11y_violations(&audit);
1079/// ```
1080pub fn assert_no_a11y_violations(audit: &Value) {
1081    let violations = audit
1082        .pointer("/summary/violations")
1083        .and_then(serde_json::Value::as_u64)
1084        .unwrap_or(u64::MAX);
1085    assert_eq!(
1086        violations, 0,
1087        "expected 0 accessibility violations, got {violations}"
1088    );
1089}
1090
1091/// Assert that all performance metrics are within budget.
1092///
1093/// # Panics
1094///
1095/// Panics if load time exceeds `max_load_ms` or heap usage exceeds `max_heap_mb`.
1096///
1097/// # Examples
1098///
1099/// ```
1100/// use serde_json::json;
1101///
1102/// let metrics = json!({
1103///     "navigation": {"load_event_ms": 450.0},
1104///     "js_heap": {"used_mb": 12.5}
1105/// });
1106/// victauri_test::assert_performance_budget(&metrics, 1000.0, 50.0);
1107/// ```
1108pub fn assert_performance_budget(metrics: &Value, max_load_ms: f64, max_heap_mb: f64) {
1109    if let Some(load) = metrics
1110        .pointer("/navigation/load_event_ms")
1111        .and_then(serde_json::Value::as_f64)
1112    {
1113        assert!(
1114            load <= max_load_ms,
1115            "load event took {load}ms, budget is {max_load_ms}ms"
1116        );
1117    }
1118
1119    if let Some(heap) = metrics
1120        .pointer("/js_heap/used_mb")
1121        .and_then(serde_json::Value::as_f64)
1122    {
1123        assert!(
1124            heap <= max_heap_mb,
1125            "JS heap is {heap}MB, budget is {max_heap_mb}MB"
1126        );
1127    }
1128}
1129
1130/// Assert that IPC integrity is healthy (no stale or errored calls).
1131///
1132/// # Panics
1133///
1134/// Panics if the integrity check reports an unhealthy state.
1135///
1136/// # Examples
1137///
1138/// ```
1139/// use serde_json::json;
1140///
1141/// let integrity = json!({"healthy": true, "stale_calls": 0, "error_calls": 0});
1142/// victauri_test::assert_ipc_healthy(&integrity);
1143/// ```
1144pub fn assert_ipc_healthy(integrity: &Value) {
1145    let healthy = integrity
1146        .get("healthy")
1147        .and_then(serde_json::Value::as_bool)
1148        .unwrap_or(false);
1149    assert!(
1150        healthy,
1151        "IPC integrity check failed: {}",
1152        serde_json::to_string_pretty(integrity).unwrap_or_default()
1153    );
1154}
1155
1156/// Assert that state verification passed with no divergences.
1157///
1158/// # Panics
1159///
1160/// Panics if the verification reports any divergences.
1161///
1162/// # Examples
1163///
1164/// ```
1165/// use serde_json::json;
1166///
1167/// let verification = json!({"passed": true, "divergences": []});
1168/// victauri_test::assert_state_matches(&verification);
1169/// ```
1170pub fn assert_state_matches(verification: &Value) {
1171    let passed = verification
1172        .get("passed")
1173        .and_then(serde_json::Value::as_bool)
1174        .unwrap_or(false);
1175    assert!(
1176        passed,
1177        "state verification failed: {}",
1178        serde_json::to_string_pretty(verification).unwrap_or_default()
1179    );
1180}