Skip to main content

victauri_test/
client.rs

1use serde::Deserialize;
2use serde_json::{Value, json};
3
4use crate::assertions::VerifyBuilder;
5use crate::discovery::{scan_discovery_dirs_for_port, scan_discovery_dirs_for_token};
6use crate::error::TestError;
7use crate::visual::{VisualDiff, VisualOptions};
8
9// ── Typed Response Structs (Phase 4E) ───────────────────────────────────────
10
11/// Structured plugin information returned by [`VictauriClient::plugin_info`].
12///
13/// # Example
14///
15/// ```rust,ignore
16/// let info = client.plugin_info().await.unwrap();
17/// println!("v{} — {} tools, up {:.0}s", info.version, info.tools.total, info.uptime_secs);
18/// ```
19#[derive(Debug, Clone, Deserialize)]
20pub struct PluginInfo {
21    /// Plugin version string (e.g. `"0.2.0"`).
22    pub version: String,
23    /// Seconds since the plugin was initialized.
24    pub uptime_secs: f64,
25    /// Total number of tool invocations served.
26    pub tool_invocations: u64,
27    /// Tool details (nested object with `total`, `enabled`, etc.).
28    #[serde(default)]
29    pub tools: PluginToolInfo,
30    /// Number of tools — derived from `tools.total` if present, else top-level
31    /// `tool_count` for backwards compatibility.
32    #[serde(default)]
33    pub tool_count: usize,
34}
35
36/// Tool information nested inside [`PluginInfo`].
37#[derive(Debug, Clone, Default, Deserialize)]
38pub struct PluginToolInfo {
39    /// Total number of tools registered.
40    #[serde(default)]
41    pub total: usize,
42    /// Number of enabled tools.
43    #[serde(default)]
44    pub enabled: usize,
45}
46
47/// Structured process memory statistics returned by [`VictauriClient::memory_stats`].
48///
49/// # Example
50///
51/// ```rust,ignore
52/// let mem = client.memory_stats().await.unwrap();
53/// let mb = mem.working_set_bytes as f64 / 1_048_576.0;
54/// assert!(mb < 512.0, "memory usage too high: {mb:.1} MB");
55/// ```
56#[derive(Debug, Clone, Deserialize)]
57pub struct MemoryStats {
58    /// Current working set size in bytes.
59    pub working_set_bytes: u64,
60    /// Peak working set size in bytes, if available.
61    pub peak_working_set_bytes: Option<u64>,
62}
63
64// ── WaitForBuilder (Phase 4B) ───────────────────────────────────────────────
65
66/// Builder for configuring and executing a `wait_for` condition.
67///
68/// Created via [`VictauriClient::wait`]. Provides a fluent API as an
69/// alternative to the positional-argument [`VictauriClient::wait_for`] method.
70///
71/// # Examples
72///
73/// ```rust,ignore
74/// // Wait for text to appear with custom timeout
75/// client.wait("text")
76///     .value("Hello, World!")
77///     .timeout_ms(15_000)
78///     .run()
79///     .await
80///     .unwrap();
81///
82/// // Wait for network idle with fast polling
83/// client.wait("network_idle")
84///     .poll_ms(50)
85///     .run()
86///     .await
87///     .unwrap();
88/// ```
89pub struct WaitForBuilder<'a> {
90    client: &'a mut VictauriClient,
91    condition: String,
92    value: Option<String>,
93    timeout_ms: u64,
94    poll_ms: u64,
95}
96
97impl<'a> WaitForBuilder<'a> {
98    /// Set the value to match against (e.g. the text string for `"text"` condition).
99    #[must_use]
100    pub fn value(mut self, v: &str) -> Self {
101        self.value = Some(v.to_string());
102        self
103    }
104
105    /// Set the maximum time to wait in milliseconds (default: 10 000).
106    #[must_use]
107    pub fn timeout_ms(mut self, ms: u64) -> Self {
108        self.timeout_ms = ms;
109        self
110    }
111
112    /// Set the polling interval in milliseconds (default: 200).
113    #[must_use]
114    pub fn poll_ms(mut self, ms: u64) -> Self {
115        self.poll_ms = ms;
116        self
117    }
118
119    /// Execute the wait, polling until the condition is met or the timeout expires.
120    ///
121    /// # Errors
122    ///
123    /// Returns errors from [`VictauriClient::call_tool`].
124    pub async fn run(self) -> Result<Value, TestError> {
125        self.client
126            .wait_for(
127                &self.condition,
128                self.value.as_deref(),
129                Some(self.timeout_ms),
130                Some(self.poll_ms),
131            )
132            .await
133    }
134}
135
136/// Typed HTTP client for the Victauri MCP server.
137///
138/// Manages session lifecycle (initialize → tool calls → cleanup) and provides
139/// convenient methods for common test operations.
140///
141/// # Example
142///
143/// ```rust,ignore
144/// use victauri_test::VictauriClient;
145///
146/// let mut client = VictauriClient::connect(7373).await.unwrap();
147/// let title = client.eval_js("document.title").await.unwrap();
148/// client.click("e3").await.unwrap();
149/// let snapshot = client.dom_snapshot().await.unwrap();
150/// ```
151pub struct VictauriClient {
152    http: reqwest::Client,
153    base_url: String,
154    host: String,
155    port: u16,
156    session_id: String,
157    next_id: u64,
158    auth_token: Option<String>,
159}
160
161impl VictauriClient {
162    /// Connect to a Victauri MCP server on the given port.
163    /// Sends `initialize` and `notifications/initialized` automatically.
164    ///
165    /// # Errors
166    ///
167    /// Returns [`TestError::Connection`] if the server is unreachable or
168    /// returns a non-success status. Returns [`TestError::Request`] on
169    /// HTTP transport failures.
170    pub async fn connect(port: u16) -> Result<Self, TestError> {
171        Self::connect_with_token(port, None).await
172    }
173
174    /// Connect with an optional Bearer auth token.
175    ///
176    /// Retries up to 3 times with exponential backoff on 429 (rate limited).
177    ///
178    /// # Errors
179    ///
180    /// Returns [`TestError::Connection`] if the server is unreachable or
181    /// returns a non-success status. Returns [`TestError::Request`] on
182    /// HTTP transport failures.
183    pub async fn connect_with_token(port: u16, token: Option<&str>) -> Result<Self, TestError> {
184        let host = "127.0.0.1";
185        let base_url = format!("http://{host}:{port}");
186        let http = reqwest::Client::builder()
187            .timeout(std::time::Duration::from_secs(60))
188            .connect_timeout(std::time::Duration::from_secs(10))
189            .build()
190            .map_err(|e| TestError::Connection {
191                host: host.to_string(),
192                port,
193                reason: e.to_string(),
194            })?;
195
196        let init_body = json!({
197            "jsonrpc": "2.0",
198            "id": 1,
199            "method": "initialize",
200            "params": {
201                "protocolVersion": "2025-03-26",
202                "capabilities": {},
203                "clientInfo": {"name": "victauri-test", "version": env!("CARGO_PKG_VERSION")}
204            }
205        });
206
207        let mut init_resp = None;
208        for attempt in 0..4 {
209            let mut req = http
210                .post(format!("{base_url}/mcp"))
211                .header("Content-Type", "application/json")
212                .header("Accept", "application/json, text/event-stream")
213                .json(&init_body);
214            if let Some(t) = token {
215                req = req.header("Authorization", format!("Bearer {t}"));
216            }
217
218            let resp = req.send().await.map_err(|e| TestError::Connection {
219                host: host.to_string(),
220                port,
221                reason: e.to_string(),
222            })?;
223
224            if resp.status() == 429 && attempt < 3 {
225                let delay = std::time::Duration::from_millis(100 * (1 << attempt));
226                tokio::time::sleep(delay).await;
227                continue;
228            }
229
230            init_resp = Some(resp);
231            break;
232        }
233
234        let init_resp = init_resp.ok_or_else(|| TestError::Connection {
235            host: host.to_string(),
236            port,
237            reason: "initialize failed after retries".into(),
238        })?;
239
240        if !init_resp.status().is_success() {
241            return Err(TestError::Connection {
242                host: host.to_string(),
243                port,
244                reason: format!("initialize returned {}", init_resp.status()),
245            });
246        }
247
248        let session_id = init_resp
249            .headers()
250            .get("mcp-session-id")
251            .and_then(|v| v.to_str().ok())
252            .ok_or_else(|| TestError::Connection {
253                host: host.to_string(),
254                port,
255                reason: "no mcp-session-id header".into(),
256            })?
257            .to_string();
258
259        let mut notify_req = http
260            .post(format!("{base_url}/mcp"))
261            .header("Content-Type", "application/json")
262            .header("mcp-session-id", &session_id)
263            .json(&json!({
264                "jsonrpc": "2.0",
265                "method": "notifications/initialized"
266            }));
267
268        if let Some(t) = token {
269            notify_req = notify_req.header("Authorization", format!("Bearer {t}"));
270        }
271
272        notify_req.send().await?;
273
274        Ok(Self {
275            http,
276            base_url,
277            host: host.to_string(),
278            port,
279            session_id,
280            next_id: 10,
281            auth_token: token.map(String::from),
282        })
283    }
284
285    /// Auto-discover a running Victauri server via temp files.
286    ///
287    /// Discovery priority:
288    /// 1. `VICTAURI_PORT` / `VICTAURI_AUTH_TOKEN` env vars (explicit override)
289    /// 2. Per-process discovery directory: `<temp>/victauri/<pid>/port`
290    /// 3. Default: port 7373, no auth
291    ///
292    /// # Errors
293    ///
294    /// Returns [`TestError::Connection`] if the server is unreachable or
295    /// returns a non-success status. Returns [`TestError::Request`] on
296    /// HTTP transport failures.
297    pub async fn discover() -> Result<Self, TestError> {
298        let port = Self::discover_port();
299        let token = Self::discover_token();
300        Self::connect_with_token(port, token.as_deref()).await
301    }
302
303    fn discover_port() -> u16 {
304        if let Ok(p) = std::env::var("VICTAURI_PORT")
305            && let Ok(port) = p.parse::<u16>()
306        {
307            return port;
308        }
309        // Scan per-process discovery directories for live servers
310        if let Some(port) = scan_discovery_dirs_for_port() {
311            return port;
312        }
313        7373
314    }
315
316    fn discover_token() -> Option<String> {
317        if let Ok(token) = std::env::var("VICTAURI_AUTH_TOKEN") {
318            return Some(token);
319        }
320        // Scan per-process discovery directories
321        if let Some(token) = scan_discovery_dirs_for_token() {
322            return Some(token);
323        }
324        None
325    }
326
327    /// Check whether the server is still reachable.
328    ///
329    /// Sends a GET to `/health` and returns `true` if the response is 200 OK.
330    #[must_use]
331    pub async fn is_alive(&self) -> bool {
332        self.http
333            .get(format!("{}/health", self.base_url))
334            .send()
335            .await
336            .is_ok_and(|r| r.status().is_success())
337    }
338
339    /// Re-establish an MCP session after the app restarts.
340    ///
341    /// Polls `/health` up to `max_wait` and then re-runs the
342    /// initialize/initialized handshake. The returned client has a fresh
343    /// session ID; the old client should be dropped.
344    ///
345    /// # Errors
346    ///
347    /// Returns [`TestError::Connection`] if the server doesn't come back
348    /// within `max_wait`.
349    pub async fn reconnect(&self, max_wait: std::time::Duration) -> Result<Self, TestError> {
350        let start = std::time::Instant::now();
351        loop {
352            if self.is_alive().await {
353                return Self::connect_with_token(self.port, self.auth_token.as_deref()).await;
354            }
355            if start.elapsed() > max_wait {
356                return Err(TestError::Connection {
357                    host: self.host.clone(),
358                    port: self.port,
359                    reason: format!("server did not recover within {}s", max_wait.as_secs()),
360                });
361            }
362            tokio::time::sleep(std::time::Duration::from_millis(250)).await;
363        }
364    }
365
366    /// Call an MCP tool by name and return the result content as JSON.
367    ///
368    /// Retries up to 3 times with exponential backoff on 429 (rate limited).
369    ///
370    /// # Errors
371    ///
372    /// Returns [`TestError::Connection`] if the request fails after retries.
373    /// Returns [`TestError::Request`] on HTTP transport errors.
374    /// Returns [`TestError::Mcp`] if the server returns a JSON-RPC error.
375    pub async fn call_tool(&mut self, name: &str, arguments: Value) -> Result<Value, TestError> {
376        let id = self.next_id;
377        self.next_id += 1;
378
379        let call_body = json!({
380            "jsonrpc": "2.0",
381            "id": id,
382            "method": "tools/call",
383            "params": {
384                "name": name,
385                "arguments": arguments
386            }
387        });
388
389        let mut resp = None;
390        for attempt in 0..4 {
391            let mut req = self
392                .http
393                .post(format!("{}/mcp", self.base_url))
394                .header("Content-Type", "application/json")
395                .header("Accept", "application/json, text/event-stream")
396                .header("mcp-session-id", &self.session_id)
397                .json(&call_body);
398            if let Some(ref t) = self.auth_token {
399                req = req.header("Authorization", format!("Bearer {t}"));
400            }
401            let r = req.send().await?;
402
403            if r.status() == 429 && attempt < 3 {
404                let delay = std::time::Duration::from_millis(100 * (1 << attempt));
405                tokio::time::sleep(delay).await;
406                continue;
407            }
408            resp = Some(r);
409            break;
410        }
411
412        let resp = resp.ok_or_else(|| TestError::Connection {
413            host: self.host.clone(),
414            port: self.port,
415            reason: "tool call failed after retries".into(),
416        })?;
417        let body = Self::parse_response(resp, &self.host, self.port).await?;
418
419        if let Some(error) = body.get("error") {
420            return Err(TestError::Mcp {
421                code: error["code"].as_i64().unwrap_or(-1),
422                message: error["message"].as_str().map_or_else(
423                    || {
424                        format!(
425                            "unknown error (raw: {})",
426                            serde_json::to_string(error).unwrap_or_else(|_| "<unparseable>".into())
427                        )
428                    },
429                    String::from,
430                ),
431            });
432        }
433
434        let content = &body["result"]["content"];
435        if let Some(arr) = content.as_array()
436            && let Some(first) = arr.first()
437        {
438            if let Some(text) = first["text"].as_str() {
439                if let Ok(parsed) = serde_json::from_str::<Value>(text) {
440                    return Ok(parsed);
441                }
442                return Ok(Value::String(text.to_string()));
443            }
444            if first.get("type").and_then(Value::as_str) == Some("image") {
445                return Ok(first.clone());
446            }
447        }
448
449        Ok(body)
450    }
451
452    /// Parse a response that may be JSON or SSE (text/event-stream).
453    ///
454    /// rmcp's Streamable HTTP transport always returns SSE format with the
455    /// JSON-RPC payload in a `data:` line. This method handles both formats.
456    async fn parse_response(
457        resp: reqwest::Response,
458        host: &str,
459        port: u16,
460    ) -> Result<Value, TestError> {
461        let content_type = resp
462            .headers()
463            .get("content-type")
464            .and_then(|v| v.to_str().ok())
465            .unwrap_or("")
466            .to_string();
467
468        let text = resp.text().await?;
469
470        if content_type.contains("text/event-stream") {
471            for line in text.lines() {
472                let data = line
473                    .strip_prefix("data: ")
474                    .or_else(|| line.strip_prefix("data:"));
475                let Some(data) = data else { continue };
476                let trimmed = data.trim();
477                if trimmed.is_empty() {
478                    continue;
479                }
480                if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
481                    return Ok(parsed);
482                }
483            }
484            Err(TestError::Connection {
485                host: host.to_string(),
486                port,
487                reason: "SSE stream contained no JSON-RPC data".into(),
488            })
489        } else {
490            serde_json::from_str(&text).map_err(|e| TestError::Connection {
491                host: host.to_string(),
492                port,
493                reason: format!(
494                    "JSON parse error: {e}, body: {}",
495                    &text[..200.min(text.len())]
496                ),
497            })
498        }
499    }
500
501    /// Evaluate JavaScript in the webview and return the result.
502    ///
503    /// # Errors
504    ///
505    /// Returns errors from [`VictauriClient::call_tool`].
506    pub async fn eval_js(&mut self, code: &str) -> Result<Value, TestError> {
507        self.call_tool("eval_js", json!({"code": code})).await
508    }
509
510    /// Get a DOM snapshot of the current page.
511    ///
512    /// # Errors
513    ///
514    /// Returns errors from [`VictauriClient::call_tool`].
515    pub async fn dom_snapshot(&mut self) -> Result<Value, TestError> {
516        self.call_tool("dom_snapshot", json!({})).await
517    }
518
519    /// Get a DOM snapshot of a specific webview by label.
520    ///
521    /// # Errors
522    ///
523    /// Returns errors from [`VictauriClient::call_tool`].
524    pub async fn dom_snapshot_for(&mut self, label: &str) -> Result<Value, TestError> {
525        self.call_tool("dom_snapshot", json!({"webview_label": label}))
526            .await
527    }
528
529    /// Capture a screenshot of a specific webview by label.
530    ///
531    /// # Errors
532    ///
533    /// Returns errors from [`VictauriClient::call_tool`].
534    pub async fn screenshot_for(&mut self, label: &str) -> Result<Value, TestError> {
535        self.call_tool("screenshot", json!({"window_label": label}))
536            .await
537    }
538
539    /// Click an element by ref handle ID.
540    ///
541    /// # Errors
542    ///
543    /// Returns errors from [`VictauriClient::call_tool`].
544    pub async fn click(&mut self, ref_id: &str) -> Result<Value, TestError> {
545        self.call_tool("interact", json!({"action": "click", "ref_id": ref_id}))
546            .await
547    }
548
549    /// Fill an input element with a value.
550    ///
551    /// # Errors
552    ///
553    /// Returns errors from [`VictauriClient::call_tool`].
554    pub async fn fill(&mut self, ref_id: &str, value: &str) -> Result<Value, TestError> {
555        self.call_tool(
556            "input",
557            json!({"action": "fill", "ref_id": ref_id, "value": value}),
558        )
559        .await
560    }
561
562    /// Type text into an element character by character.
563    ///
564    /// # Errors
565    ///
566    /// Returns errors from [`VictauriClient::call_tool`].
567    pub async fn type_text(&mut self, ref_id: &str, text: &str) -> Result<Value, TestError> {
568        self.call_tool(
569            "input",
570            json!({"action": "type_text", "ref_id": ref_id, "text": text}),
571        )
572        .await
573    }
574
575    /// List all window labels.
576    ///
577    /// # Errors
578    ///
579    /// Returns errors from [`VictauriClient::call_tool`].
580    pub async fn list_windows(&mut self) -> Result<Value, TestError> {
581        self.call_tool("window", json!({"action": "list"})).await
582    }
583
584    /// Get the state of a specific window (or all windows).
585    ///
586    /// # Errors
587    ///
588    /// Returns errors from [`VictauriClient::call_tool`].
589    pub async fn get_window_state(&mut self, label: Option<&str>) -> Result<Value, TestError> {
590        let mut args = json!({"action": "get_state"});
591        if let Some(l) = label {
592            args["label"] = json!(l);
593        }
594        self.call_tool("window", args).await
595    }
596
597    /// Take a screenshot and return base64-encoded PNG.
598    ///
599    /// # Errors
600    ///
601    /// Returns errors from [`VictauriClient::call_tool`].
602    pub async fn screenshot(&mut self) -> Result<Value, TestError> {
603        self.call_tool("screenshot", json!({})).await
604    }
605
606    /// Take a screenshot and compare it against a stored baseline.
607    ///
608    /// Captures the current window, extracts the base64 PNG data, and passes
609    /// it to [`visual::compare_screenshot`](crate::visual::compare_screenshot).
610    /// On first run the screenshot is saved as the new baseline.
611    ///
612    /// # Errors
613    ///
614    /// Returns [`TestError::VisualRegression`] if the diff exceeds the
615    /// threshold, or [`TestError::Other`] if the screenshot result does not
616    /// contain recognizable image data.
617    pub async fn screenshot_visual(
618        &mut self,
619        name: &str,
620        options: &VisualOptions,
621    ) -> Result<VisualDiff, TestError> {
622        let result = self.screenshot().await?;
623        let base64_data = extract_screenshot_base64(&result)?;
624        crate::visual::compare_screenshot(name, &base64_data, options)
625    }
626
627    /// Invoke a Tauri command by name with optional arguments.
628    ///
629    /// # Errors
630    ///
631    /// Returns errors from [`VictauriClient::call_tool`].
632    pub async fn invoke_command(
633        &mut self,
634        command: &str,
635        args: Option<Value>,
636    ) -> Result<Value, TestError> {
637        let mut params = json!({"command": command});
638        if let Some(a) = args {
639            params["args"] = a;
640        }
641        self.call_tool("invoke_command", params).await
642    }
643
644    /// Get the IPC call log.
645    ///
646    /// # Errors
647    ///
648    /// Returns errors from [`VictauriClient::call_tool`].
649    pub async fn get_ipc_log(&mut self, limit: Option<usize>) -> Result<Value, TestError> {
650        let mut args = json!({"action": "ipc"});
651        if let Some(n) = limit {
652            args["limit"] = json!(n);
653        }
654        self.call_tool("logs", args).await
655    }
656
657    /// Verify frontend state against backend state.
658    ///
659    /// # Errors
660    ///
661    /// Returns errors from [`VictauriClient::call_tool`].
662    pub async fn verify_state(
663        &mut self,
664        frontend_expr: &str,
665        backend_state: Value,
666    ) -> Result<Value, TestError> {
667        self.call_tool(
668            "verify_state",
669            json!({
670                "frontend_expr": frontend_expr,
671                "backend_state": backend_state,
672            }),
673        )
674        .await
675    }
676
677    /// Detect ghost commands (registered but never called, or called but not registered).
678    ///
679    /// # Errors
680    ///
681    /// Returns errors from [`VictauriClient::call_tool`].
682    pub async fn detect_ghost_commands(&mut self) -> Result<Value, TestError> {
683        self.call_tool("detect_ghost_commands", json!({})).await
684    }
685
686    /// Check IPC call health (pending, stale, errored).
687    ///
688    /// # Errors
689    ///
690    /// Returns errors from [`VictauriClient::call_tool`].
691    pub async fn check_ipc_integrity(&mut self) -> Result<Value, TestError> {
692        self.call_tool("check_ipc_integrity", json!({})).await
693    }
694
695    /// Run a semantic assertion against a JS expression.
696    ///
697    /// # Errors
698    ///
699    /// Returns errors from [`VictauriClient::call_tool`].
700    pub async fn assert_semantic(
701        &mut self,
702        expression: &str,
703        label: &str,
704        condition: &str,
705        expected: Value,
706    ) -> Result<Value, TestError> {
707        self.call_tool(
708            "assert_semantic",
709            json!({
710                "expression": expression,
711                "label": label,
712                "condition": condition,
713                "expected": expected,
714            }),
715        )
716        .await
717    }
718
719    /// Run an accessibility audit.
720    ///
721    /// # Errors
722    ///
723    /// Returns errors from [`VictauriClient::call_tool`].
724    pub async fn audit_accessibility(&mut self) -> Result<Value, TestError> {
725        self.call_tool("inspect", json!({"action": "audit_accessibility"}))
726            .await
727    }
728
729    /// Get performance metrics (timing, heap, resources).
730    ///
731    /// # Errors
732    ///
733    /// Returns errors from [`VictauriClient::call_tool`].
734    pub async fn get_performance_metrics(&mut self) -> Result<Value, TestError> {
735        self.call_tool("inspect", json!({"action": "get_performance"}))
736            .await
737    }
738
739    /// Get the command registry.
740    ///
741    /// # Errors
742    ///
743    /// Returns errors from [`VictauriClient::call_tool`].
744    pub async fn get_registry(&mut self) -> Result<Value, TestError> {
745        self.call_tool("get_registry", json!({})).await
746    }
747
748    /// Get process memory statistics.
749    ///
750    /// # Errors
751    ///
752    /// Returns errors from [`VictauriClient::call_tool`].
753    pub async fn get_memory_stats(&mut self) -> Result<Value, TestError> {
754        self.call_tool("get_memory_stats", json!({})).await
755    }
756
757    /// Read plugin info (version, uptime, tool count).
758    ///
759    /// # Errors
760    ///
761    /// Returns errors from [`VictauriClient::call_tool`].
762    pub async fn get_plugin_info(&mut self) -> Result<Value, TestError> {
763        self.call_tool("get_plugin_info", json!({})).await
764    }
765
766    /// Run environment diagnostics to detect potential compatibility issues.
767    ///
768    /// Checks for service workers, closed shadow DOM, iframes, large DOM,
769    /// and CSP status. Returns warnings and environment info.
770    ///
771    /// # Errors
772    ///
773    /// Returns errors from [`VictauriClient::call_tool`].
774    pub async fn get_diagnostics(&mut self) -> Result<Value, TestError> {
775        self.call_tool("get_diagnostics", json!({})).await
776    }
777
778    /// Wait for a condition to be met, polling at an interval.
779    ///
780    /// Conditions: `text`, `text_gone`, `selector`, `selector_gone`, `url`,
781    /// `ipc_idle`, `network_idle`.
782    ///
783    /// # Errors
784    ///
785    /// Returns errors from [`VictauriClient::call_tool`].
786    pub async fn wait_for(
787        &mut self,
788        condition: &str,
789        value: Option<&str>,
790        timeout_ms: Option<u64>,
791        poll_ms: Option<u64>,
792    ) -> Result<Value, TestError> {
793        let mut args = json!({"condition": condition});
794        if let Some(v) = value {
795            args["value"] = json!(v);
796        }
797        if let Some(t) = timeout_ms {
798            args["timeout_ms"] = json!(t);
799        }
800        if let Some(p) = poll_ms {
801            args["poll_ms"] = json!(p);
802        }
803        self.call_tool("wait_for", args).await
804    }
805
806    /// Start a time-travel recording session.
807    ///
808    /// # Errors
809    ///
810    /// Returns errors from [`VictauriClient::call_tool`].
811    pub async fn start_recording(&mut self, session_id: Option<&str>) -> Result<Value, TestError> {
812        let mut args = json!({"action": "start"});
813        if let Some(id) = session_id {
814            args["session_id"] = json!(id);
815        }
816        self.call_tool("recording", args).await
817    }
818
819    /// Stop the recording and return the session.
820    ///
821    /// # Errors
822    ///
823    /// Returns errors from [`VictauriClient::call_tool`].
824    pub async fn stop_recording(&mut self) -> Result<Value, TestError> {
825        self.call_tool("recording", json!({"action": "stop"})).await
826    }
827
828    /// Export the current recording session as JSON.
829    ///
830    /// # Errors
831    ///
832    /// Returns errors from [`VictauriClient::call_tool`].
833    pub async fn export_session(&mut self) -> Result<Value, TestError> {
834        self.call_tool("recording", json!({"action": "export"}))
835            .await
836    }
837
838    /// Search for elements by various criteria without a full snapshot.
839    ///
840    /// # Errors
841    ///
842    /// Returns errors from [`VictauriClient::call_tool`].
843    pub async fn find_elements(&mut self, query: Value) -> Result<Value, TestError> {
844        self.call_tool("find_elements", query).await
845    }
846
847    /// Double-click an element by ref handle ID.
848    ///
849    /// # Errors
850    ///
851    /// Returns errors from [`VictauriClient::call_tool`].
852    pub async fn double_click(&mut self, ref_id: &str) -> Result<Value, TestError> {
853        self.call_tool(
854            "interact",
855            json!({"action": "double_click", "ref_id": ref_id}),
856        )
857        .await
858    }
859
860    /// Hover over an element by ref handle.
861    ///
862    /// # Errors
863    ///
864    /// Returns errors from [`VictauriClient::call_tool`].
865    pub async fn hover(&mut self, ref_id: &str) -> Result<Value, TestError> {
866        self.call_tool("interact", json!({"action": "hover", "ref_id": ref_id}))
867            .await
868    }
869
870    /// Focus an element by ref handle.
871    ///
872    /// # Errors
873    ///
874    /// Returns errors from [`VictauriClient::call_tool`].
875    pub async fn focus(&mut self, ref_id: &str) -> Result<Value, TestError> {
876        self.call_tool("interact", json!({"action": "focus", "ref_id": ref_id}))
877            .await
878    }
879
880    /// Press a keyboard key.
881    ///
882    /// # Errors
883    ///
884    /// Returns errors from [`VictauriClient::call_tool`].
885    pub async fn press_key(&mut self, key: &str) -> Result<Value, TestError> {
886        self.call_tool("input", json!({"action": "press_key", "key": key}))
887            .await
888    }
889
890    /// Navigate to a URL.
891    ///
892    /// # Errors
893    ///
894    /// Returns errors from [`VictauriClient::call_tool`].
895    pub async fn navigate(&mut self, url: &str) -> Result<Value, TestError> {
896        self.call_tool("navigate", json!({"action": "go_to", "url": url}))
897            .await
898    }
899
900    /// Get logs by type (console, network, ipc, navigation, dialogs).
901    ///
902    /// # Errors
903    ///
904    /// Returns errors from [`VictauriClient::call_tool`].
905    pub async fn logs(&mut self, action: &str, limit: Option<usize>) -> Result<Value, TestError> {
906        self.call_tool("logs", json!({"action": action, "limit": limit}))
907            .await
908    }
909
910    /// Scroll an element into view by ref handle.
911    ///
912    /// # Errors
913    ///
914    /// Returns errors from [`VictauriClient::call_tool`].
915    pub async fn scroll_to(&mut self, ref_id: &str) -> Result<Value, TestError> {
916        self.call_tool(
917            "interact",
918            json!({"action": "scroll_into_view", "ref_id": ref_id}),
919        )
920        .await
921    }
922
923    /// Select option(s) in a `<select>` element.
924    ///
925    /// # Errors
926    ///
927    /// Returns errors from [`VictauriClient::call_tool`].
928    pub async fn select_option(
929        &mut self,
930        ref_id: &str,
931        values: &[&str],
932    ) -> Result<Value, TestError> {
933        self.call_tool(
934            "interact",
935            json!({"action": "select_option", "ref_id": ref_id, "values": values}),
936        )
937        .await
938    }
939
940    /// Get the server base URL.
941    #[must_use]
942    pub fn base_url(&self) -> &str {
943        &self.base_url
944    }
945
946    /// Get the host the client is connected to.
947    #[must_use]
948    pub fn host(&self) -> &str {
949        &self.host
950    }
951
952    /// Get the port the client is connected to.
953    #[must_use]
954    pub fn port(&self) -> u16 {
955        self.port
956    }
957
958    /// Get the MCP session ID.
959    #[must_use]
960    pub fn session_id(&self) -> &str {
961        &self.session_id
962    }
963
964    pub(crate) fn http_client(&self) -> &reqwest::Client {
965        &self.http
966    }
967
968    // ── IPC Log Helpers ───────────────────────────────────────────────────────
969
970    /// Get IPC calls filtered to a specific command.
971    ///
972    /// Returns a Vec of all IPC log entries matching the given command name.
973    ///
974    /// # Errors
975    ///
976    /// Returns errors from [`VictauriClient::call_tool`].
977    #[deprecated(since = "0.2.0", note = "renamed to get_ipc_calls_for")]
978    pub async fn get_ipc_calls(&mut self, command: &str) -> Result<Vec<Value>, TestError> {
979        let log = self.get_ipc_log(None).await?;
980        let entries = if let Some(arr) = log.as_array() {
981            arr.clone()
982        } else if let Some(entries) = log.get("entries").and_then(Value::as_array) {
983            entries.clone()
984        } else {
985            return Ok(Vec::new());
986        };
987        Ok(entries
988            .into_iter()
989            .filter(|e| {
990                e.get("command")
991                    .and_then(Value::as_str)
992                    .is_some_and(|c| c == command)
993            })
994            .collect())
995    }
996
997    /// Get IPC calls made since a previous checkpoint.
998    ///
999    /// # Errors
1000    ///
1001    /// Returns errors from [`VictauriClient::call_tool`].
1002    #[deprecated(since = "0.2.0", note = "renamed to get_ipc_calls_since")]
1003    pub async fn ipc_calls_since(&mut self, checkpoint: usize) -> Result<Vec<Value>, TestError> {
1004        let log = self.get_ipc_log(None).await?;
1005        let entries = if let Some(arr) = log.as_array() {
1006            arr.clone()
1007        } else if let Some(entries) = log.get("entries").and_then(Value::as_array) {
1008            entries.clone()
1009        } else {
1010            return Ok(Vec::new());
1011        };
1012        Ok(entries.into_iter().skip(checkpoint).collect())
1013    }
1014
1015    /// Filter the IPC log for calls to a specific command.
1016    ///
1017    /// # Errors
1018    ///
1019    /// Returns errors from [`VictauriClient::call_tool`].
1020    pub async fn get_ipc_calls_for(&mut self, command: &str) -> Result<Vec<Value>, TestError> {
1021        #[allow(deprecated)]
1022        self.get_ipc_calls(command).await
1023    }
1024
1025    /// Get IPC calls made since a previous checkpoint.
1026    ///
1027    /// # Errors
1028    ///
1029    /// Returns errors from [`VictauriClient::call_tool`].
1030    pub async fn get_ipc_calls_since(
1031        &mut self,
1032        checkpoint: usize,
1033    ) -> Result<Vec<Value>, TestError> {
1034        #[allow(deprecated)]
1035        self.ipc_calls_since(checkpoint).await
1036    }
1037
1038    // ── Builder-Style Wait (Phase 4B) ──────────────────────────────────────────
1039
1040    /// Start a builder-style wait for a condition.
1041    ///
1042    /// This is a fluent alternative to [`VictauriClient::wait_for`] that avoids
1043    /// positional `Option` arguments.
1044    ///
1045    /// # Examples
1046    ///
1047    /// ```rust,ignore
1048    /// client.wait("text")
1049    ///     .value("Welcome")
1050    ///     .timeout_ms(5000)
1051    ///     .run()
1052    ///     .await
1053    ///     .unwrap();
1054    /// ```
1055    pub fn wait(&mut self, condition: &str) -> WaitForBuilder<'_> {
1056        WaitForBuilder {
1057            client: self,
1058            condition: condition.to_string(),
1059            value: None,
1060            timeout_ms: 10_000,
1061            poll_ms: 200,
1062        }
1063    }
1064
1065    // ── Deprecated Aliases (Phase 4C) ────────────────────────────────────────
1066
1067    /// Snapshot the current IPC log length, for use with `ipc_calls_since`.
1068    ///
1069    /// Prefer [`VictauriClient::create_ipc_checkpoint`] — this alias exists
1070    /// for backwards compatibility.
1071    ///
1072    /// # Errors
1073    ///
1074    /// Returns errors from [`VictauriClient::call_tool`].
1075    #[deprecated(since = "0.2.0", note = "renamed to create_ipc_checkpoint")]
1076    pub async fn ipc_checkpoint(&mut self) -> Result<usize, TestError> {
1077        self.create_ipc_checkpoint().await
1078    }
1079
1080    /// Snapshot the current IPC log length, for use with `ipc_calls_since`.
1081    ///
1082    /// Returns the number of IPC calls recorded so far. Pass this value to
1083    /// [`VictauriClient::ipc_calls_since`] to get only the calls that occurred
1084    /// after the checkpoint.
1085    ///
1086    /// # Errors
1087    ///
1088    /// Returns errors from [`VictauriClient::call_tool`].
1089    pub async fn create_ipc_checkpoint(&mut self) -> Result<usize, TestError> {
1090        let log = self.get_ipc_log(None).await?;
1091        let len = if let Some(arr) = log.as_array() {
1092            arr.len()
1093        } else if let Some(entries) = log.get("entries").and_then(Value::as_array) {
1094            entries.len()
1095        } else {
1096            0
1097        };
1098        Ok(len)
1099    }
1100
1101    // ── Typed Response Methods (Phase 4E) ────────────────────────────────────
1102
1103    /// Read plugin info as a typed [`PluginInfo`] struct.
1104    ///
1105    /// This is a typed alternative to [`VictauriClient::get_plugin_info`] which
1106    /// returns raw JSON.
1107    ///
1108    /// # Errors
1109    ///
1110    /// Returns [`TestError::Other`] if the response cannot be deserialized.
1111    /// Returns other errors from [`VictauriClient::call_tool`].
1112    pub async fn plugin_info(&mut self) -> Result<PluginInfo, TestError> {
1113        let value = self.get_plugin_info().await?;
1114        serde_json::from_value(value)
1115            .map_err(|e| TestError::Other(format!("failed to deserialize PluginInfo: {e}")))
1116    }
1117
1118    /// Read process memory statistics as a typed [`MemoryStats`] struct.
1119    ///
1120    /// This is a typed alternative to [`VictauriClient::get_memory_stats`] which
1121    /// returns raw JSON.
1122    ///
1123    /// # Errors
1124    ///
1125    /// Returns [`TestError::Other`] if the response cannot be deserialized.
1126    /// Returns other errors from [`VictauriClient::call_tool`].
1127    pub async fn memory_stats(&mut self) -> Result<MemoryStats, TestError> {
1128        let value = self.get_memory_stats().await?;
1129        serde_json::from_value(value)
1130            .map_err(|e| TestError::Other(format!("failed to deserialize MemoryStats: {e}")))
1131    }
1132
1133    // ── Fluent Verification Builder ───────────────────────────────────────────
1134
1135    /// Start a fluent verification chain that checks multiple conditions at once.
1136    ///
1137    /// Unlike individual assertions that panic on failure, `verify()` collects
1138    /// all results and reports them together — making test failures more
1139    /// informative and reducing test reruns.
1140    ///
1141    /// # Examples
1142    ///
1143    /// ```rust,ignore
1144    /// let report = client.verify()
1145    ///     .has_text("Welcome")
1146    ///     .ipc_was_called("greet")
1147    ///     .no_console_errors()
1148    ///     .run()
1149    ///     .await
1150    ///     .unwrap();
1151    /// report.assert_all_passed();
1152    /// ```
1153    pub fn verify(&mut self) -> VerifyBuilder<'_> {
1154        VerifyBuilder::new(self)
1155    }
1156
1157    // ── High-Level Playwright-Style API ─────────────────────────────────────
1158
1159    /// Click the first element whose accessible text contains the given string.
1160    ///
1161    /// Takes a DOM snapshot, finds the element, and clicks it.
1162    ///
1163    /// # Errors
1164    ///
1165    /// Returns [`TestError::ElementNotFound`] if no matching element is found.
1166    /// Returns other errors from [`VictauriClient::call_tool`].
1167    pub async fn click_by_text(&mut self, text: &str) -> Result<Value, TestError> {
1168        let ref_id = self.find_ref_by_text(text).await?;
1169        self.click(&ref_id).await
1170    }
1171
1172    /// Click the element with the given HTML `id` attribute.
1173    ///
1174    /// # Errors
1175    ///
1176    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
1177    /// Returns other errors from [`VictauriClient::call_tool`].
1178    pub async fn click_by_id(&mut self, id: &str) -> Result<Value, TestError> {
1179        let ref_id = self.find_ref_by_id(id).await?;
1180        self.click(&ref_id).await
1181    }
1182
1183    /// Double-click the first element whose accessible text contains the given string.
1184    ///
1185    /// # Errors
1186    ///
1187    /// Returns [`TestError::ElementNotFound`] if no matching element is found.
1188    /// Returns other errors from [`VictauriClient::call_tool`].
1189    pub async fn double_click_by_text(&mut self, text: &str) -> Result<Value, TestError> {
1190        let ref_id = self.find_ref_by_text(text).await?;
1191        self.double_click(&ref_id).await
1192    }
1193
1194    /// Double-click the element with the given HTML `id` attribute.
1195    ///
1196    /// # Errors
1197    ///
1198    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
1199    /// Returns other errors from [`VictauriClient::call_tool`].
1200    pub async fn double_click_by_id(&mut self, id: &str) -> Result<Value, TestError> {
1201        let ref_id = self.find_ref_by_id(id).await?;
1202        self.double_click(&ref_id).await
1203    }
1204
1205    /// Double-click the first element matching a CSS selector.
1206    ///
1207    /// Resolves the selector via `find_elements`, then double-clicks the first match.
1208    ///
1209    /// # Errors
1210    ///
1211    /// Returns [`TestError::ElementNotFound`] if no element matches the selector.
1212    /// Returns other errors from [`VictauriClient::call_tool`].
1213    pub async fn double_click_by_selector(&mut self, selector: &str) -> Result<Value, TestError> {
1214        let ref_id = self.find_ref_by_selector(selector).await?;
1215        self.double_click(&ref_id).await
1216    }
1217
1218    /// Click the first element matching a CSS selector.
1219    ///
1220    /// Resolves the selector via `find_elements`, then clicks the first match.
1221    ///
1222    /// # Errors
1223    ///
1224    /// Returns [`TestError::ElementNotFound`] if no element matches the selector.
1225    /// Returns other errors from [`VictauriClient::call_tool`].
1226    pub async fn click_by_selector(&mut self, selector: &str) -> Result<Value, TestError> {
1227        let ref_id = self.find_ref_by_selector(selector).await?;
1228        self.click(&ref_id).await
1229    }
1230
1231    /// Fill an input identified by HTML `id` with the given value.
1232    ///
1233    /// # Errors
1234    ///
1235    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
1236    /// Returns other errors from [`VictauriClient::call_tool`].
1237    pub async fn fill_by_id(&mut self, id: &str, value: &str) -> Result<Value, TestError> {
1238        let ref_id = self.find_ref_by_id(id).await?;
1239        self.fill(&ref_id, value).await
1240    }
1241
1242    /// Fill an input whose accessible text contains the given string.
1243    ///
1244    /// # Errors
1245    ///
1246    /// Returns [`TestError::ElementNotFound`] if no matching element is found.
1247    /// Returns other errors from [`VictauriClient::call_tool`].
1248    pub async fn fill_by_text(&mut self, text: &str, value: &str) -> Result<Value, TestError> {
1249        let ref_id = self.find_ref_by_text(text).await?;
1250        self.fill(&ref_id, value).await
1251    }
1252
1253    /// Fill an input matching a CSS selector with the given value.
1254    ///
1255    /// Resolves the selector via `find_elements`, then fills the first match.
1256    ///
1257    /// # Errors
1258    ///
1259    /// Returns [`TestError::ElementNotFound`] if no element matches the selector.
1260    /// Returns other errors from [`VictauriClient::call_tool`].
1261    pub async fn fill_by_selector(
1262        &mut self,
1263        selector: &str,
1264        value: &str,
1265    ) -> Result<Value, TestError> {
1266        let ref_id = self.find_ref_by_selector(selector).await?;
1267        self.fill(&ref_id, value).await
1268    }
1269
1270    /// Type text into an input identified by HTML `id`, character by character.
1271    ///
1272    /// # Errors
1273    ///
1274    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
1275    /// Returns other errors from [`VictauriClient::call_tool`].
1276    pub async fn type_by_id(&mut self, id: &str, text: &str) -> Result<Value, TestError> {
1277        let ref_id = self.find_ref_by_id(id).await?;
1278        self.type_text(&ref_id, text).await
1279    }
1280
1281    /// Wait until the page contains the given text (polls DOM snapshots).
1282    ///
1283    /// Default timeout: 5000ms, poll interval: 200ms.
1284    ///
1285    /// # Errors
1286    ///
1287    /// Returns [`TestError::Timeout`] if the text doesn't appear within the timeout.
1288    /// Returns other errors from [`VictauriClient::call_tool`].
1289    pub async fn expect_text(&mut self, text: &str) -> Result<(), TestError> {
1290        self.expect_text_with_timeout(text, 5000).await
1291    }
1292
1293    /// Wait until the page contains the given text, with a custom timeout in ms.
1294    ///
1295    /// # Errors
1296    ///
1297    /// Returns [`TestError::Timeout`] if the text doesn't appear within the timeout.
1298    /// Returns other errors from [`VictauriClient::call_tool`].
1299    pub async fn expect_text_with_timeout(
1300        &mut self,
1301        text: &str,
1302        timeout_ms: u64,
1303    ) -> Result<(), TestError> {
1304        let result = self
1305            .wait_for("text", Some(text), Some(timeout_ms), Some(200))
1306            .await?;
1307        if result.get("ok").and_then(Value::as_bool) == Some(true) {
1308            Ok(())
1309        } else {
1310            Err(TestError::Timeout(format!(
1311                "text \"{text}\" did not appear within {timeout_ms}ms"
1312            )))
1313        }
1314    }
1315
1316    /// Wait until the page no longer contains the given text.
1317    ///
1318    /// Default timeout: 3000ms, poll interval: 200ms.
1319    ///
1320    /// # Errors
1321    ///
1322    /// Returns [`TestError::Timeout`] if the text is still present after the timeout.
1323    /// Returns other errors from [`VictauriClient::call_tool`].
1324    pub async fn expect_no_text(&mut self, text: &str) -> Result<(), TestError> {
1325        let result = self
1326            .wait_for("text_gone", Some(text), Some(3000), Some(200))
1327            .await?;
1328        if result.get("ok").and_then(Value::as_bool) == Some(true) {
1329            Ok(())
1330        } else {
1331            Err(TestError::Timeout(format!(
1332                "text \"{text}\" still present after 3000ms"
1333            )))
1334        }
1335    }
1336
1337    /// Select an option in a `<select>` element identified by HTML `id`.
1338    ///
1339    /// # Errors
1340    ///
1341    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
1342    /// Returns other errors from [`VictauriClient::call_tool`].
1343    pub async fn select_by_id(&mut self, id: &str, value: &str) -> Result<Value, TestError> {
1344        let ref_id = self.find_ref_by_id(id).await?;
1345        self.select_option(&ref_id, &[value]).await
1346    }
1347
1348    /// Select option(s) in a `<select>` element identified by HTML `id`.
1349    ///
1350    /// Accepts multiple values for multi-select elements.
1351    ///
1352    /// # Errors
1353    ///
1354    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
1355    /// Returns other errors from [`VictauriClient::call_tool`].
1356    pub async fn select_option_by_id(
1357        &mut self,
1358        id: &str,
1359        values: &[&str],
1360    ) -> Result<Value, TestError> {
1361        let ref_id = self.find_ref_by_id(id).await?;
1362        self.select_option(&ref_id, values).await
1363    }
1364
1365    /// Select option(s) in a `<select>` element whose accessible text contains
1366    /// the given string.
1367    ///
1368    /// # Errors
1369    ///
1370    /// Returns [`TestError::ElementNotFound`] if no matching element is found.
1371    /// Returns other errors from [`VictauriClient::call_tool`].
1372    pub async fn select_option_by_text(
1373        &mut self,
1374        text: &str,
1375        values: &[&str],
1376    ) -> Result<Value, TestError> {
1377        let ref_id = self.find_ref_by_text(text).await?;
1378        self.select_option(&ref_id, values).await
1379    }
1380
1381    /// Select option(s) in a `<select>` element matching a CSS selector.
1382    ///
1383    /// Resolves the selector via `find_elements`, then selects in the first match.
1384    ///
1385    /// # Errors
1386    ///
1387    /// Returns [`TestError::ElementNotFound`] if no element matches the selector.
1388    /// Returns other errors from [`VictauriClient::call_tool`].
1389    pub async fn select_option_by_selector(
1390        &mut self,
1391        selector: &str,
1392        values: &[&str],
1393    ) -> Result<Value, TestError> {
1394        let ref_id = self.find_ref_by_selector(selector).await?;
1395        self.select_option(&ref_id, values).await
1396    }
1397
1398    /// Scroll an element matching a CSS selector into view.
1399    ///
1400    /// Resolves the selector via `find_elements`, then scrolls the first match.
1401    ///
1402    /// # Errors
1403    ///
1404    /// Returns [`TestError::ElementNotFound`] if no element matches the selector.
1405    /// Returns other errors from [`VictauriClient::call_tool`].
1406    pub async fn scroll_to_by_selector(&mut self, selector: &str) -> Result<Value, TestError> {
1407        let ref_id = self.find_ref_by_selector(selector).await?;
1408        self.scroll_to(&ref_id).await
1409    }
1410
1411    /// Scroll an element with the given HTML `id` into view.
1412    ///
1413    /// # Errors
1414    ///
1415    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
1416    /// Returns other errors from [`VictauriClient::call_tool`].
1417    pub async fn scroll_to_by_id(&mut self, id: &str) -> Result<Value, TestError> {
1418        let ref_id = self.find_ref_by_id(id).await?;
1419        self.scroll_to(&ref_id).await
1420    }
1421
1422    /// Get the text content of an element identified by HTML `id`.
1423    ///
1424    /// # Errors
1425    ///
1426    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
1427    /// Returns other errors from [`VictauriClient::call_tool`].
1428    pub async fn text_by_id(&mut self, id: &str) -> Result<String, TestError> {
1429        let snap = self.snapshot_json().await?;
1430        let tree = &snap["tree"];
1431        find_text_by_attr_id(tree, id)
1432            .ok_or_else(|| TestError::ElementNotFound(format!("id=\"{id}\"")))
1433    }
1434
1435    // ── Internal helpers for high-level API ─────────────────────────────────
1436
1437    async fn snapshot_json(&mut self) -> Result<Value, TestError> {
1438        self.call_tool("dom_snapshot", json!({"format": "json"}))
1439            .await
1440    }
1441
1442    async fn find_ref_by_text(&mut self, text: &str) -> Result<String, TestError> {
1443        let snap = self.snapshot_json().await?;
1444        let tree = &snap["tree"];
1445        find_in_tree_by_text(tree, text)
1446            .ok_or_else(|| TestError::ElementNotFound(format!("text=\"{text}\"")))
1447    }
1448
1449    async fn find_ref_by_id(&mut self, id: &str) -> Result<String, TestError> {
1450        let snap = self.snapshot_json().await?;
1451        let tree = &snap["tree"];
1452        find_in_tree_by_attr_id(tree, id)
1453            .ok_or_else(|| TestError::ElementNotFound(format!("id=\"{id}\"")))
1454    }
1455
1456    async fn find_ref_by_selector(&mut self, selector: &str) -> Result<String, TestError> {
1457        let result = self.find_elements(json!({"selector": selector})).await?;
1458        // find_elements returns an array of matched elements with ref_id fields
1459        let elements = result
1460            .as_array()
1461            .or_else(|| result.get("elements").and_then(Value::as_array));
1462        if let Some(elems) = elements
1463            && let Some(first) = elems.first()
1464            && let Some(ref_id) = first.get("ref_id").and_then(Value::as_str)
1465        {
1466            return Ok(ref_id.to_string());
1467        }
1468        Err(TestError::ElementNotFound(format!(
1469            "selector=\"{selector}\""
1470        )))
1471    }
1472
1473    // ── Locator Factories ──────────────────────────────────────────────────
1474
1475    /// Create a [`Locator`](crate::Locator) matching elements by ARIA role.
1476    ///
1477    /// Equivalent to Playwright's `page.getByRole()`.
1478    #[must_use]
1479    pub fn get_by_role(&self, role: &str) -> crate::locator::Locator {
1480        crate::locator::Locator::role(role)
1481    }
1482
1483    /// Create a [`Locator`](crate::Locator) matching elements by visible text content.
1484    ///
1485    /// Equivalent to Playwright's `page.getByText()`.
1486    #[must_use]
1487    pub fn get_by_text(&self, text: &str) -> crate::locator::Locator {
1488        crate::locator::Locator::text(text)
1489    }
1490
1491    /// Create a [`Locator`](crate::Locator) matching elements by `data-testid` attribute.
1492    ///
1493    /// Equivalent to Playwright's `page.getByTestId()`.
1494    #[must_use]
1495    pub fn get_by_test_id(&self, id: &str) -> crate::locator::Locator {
1496        crate::locator::Locator::test_id(id)
1497    }
1498
1499    /// Create a [`Locator`](crate::Locator) matching form controls by associated label text.
1500    ///
1501    /// Equivalent to Playwright's `page.getByLabel()`.
1502    #[must_use]
1503    pub fn get_by_label(&self, text: &str) -> crate::locator::Locator {
1504        crate::locator::Locator::label(text)
1505    }
1506
1507    /// Create a [`Locator`](crate::Locator) matching elements by placeholder text.
1508    ///
1509    /// Equivalent to Playwright's `page.getByPlaceholder()`.
1510    #[must_use]
1511    pub fn get_by_placeholder(&self, text: &str) -> crate::locator::Locator {
1512        crate::locator::Locator::placeholder(text)
1513    }
1514
1515    /// Create a [`Locator`](crate::Locator) matching elements by CSS selector.
1516    ///
1517    /// Equivalent to Playwright's `page.locator()`.
1518    #[must_use]
1519    pub fn locator(&self, css: &str) -> crate::locator::Locator {
1520        crate::locator::Locator::css(css)
1521    }
1522
1523    /// Create a [`Locator`](crate::Locator) matching elements by alt text (images).
1524    ///
1525    /// Equivalent to Playwright's `page.getByAltText()`.
1526    #[must_use]
1527    pub fn get_by_alt_text(&self, alt: &str) -> crate::locator::Locator {
1528        crate::locator::Locator::alt_text(alt)
1529    }
1530
1531    /// Create a [`Locator`](crate::Locator) matching elements by title attribute.
1532    ///
1533    /// Equivalent to Playwright's `page.getByTitle()`.
1534    #[must_use]
1535    pub fn get_by_title(&self, title: &str) -> crate::locator::Locator {
1536        crate::locator::Locator::title(title)
1537    }
1538
1539    // ── Screenshot to File ─────────────────────────────────────────────────
1540
1541    /// Take a screenshot and save it to a file on disk.
1542    ///
1543    /// Captures the default window, decodes the base64 PNG, and writes it
1544    /// to the given path. Returns the canonical path of the saved file.
1545    ///
1546    /// # Errors
1547    ///
1548    /// Returns [`TestError::Other`] if the screenshot cannot be captured,
1549    /// decoded, or written to disk.
1550    pub async fn screenshot_to_file(
1551        &mut self,
1552        path: impl AsRef<std::path::Path>,
1553    ) -> Result<std::path::PathBuf, TestError> {
1554        let result = self.screenshot().await?;
1555        let base64_data = extract_screenshot_base64(&result)?;
1556        save_screenshot_to_file(&base64_data, path.as_ref())
1557    }
1558
1559    /// Take a screenshot of a specific window and save it to a file.
1560    ///
1561    /// # Errors
1562    ///
1563    /// Returns [`TestError::Other`] if the screenshot cannot be captured,
1564    /// decoded, or written to disk.
1565    pub async fn screenshot_to_file_for(
1566        &mut self,
1567        label: &str,
1568        path: impl AsRef<std::path::Path>,
1569    ) -> Result<std::path::PathBuf, TestError> {
1570        let result = self.screenshot_for(label).await?;
1571        let base64_data = extract_screenshot_base64(&result)?;
1572        save_screenshot_to_file(&base64_data, path.as_ref())
1573    }
1574}
1575
1576fn save_screenshot_to_file(
1577    base64_data: &str,
1578    path: &std::path::Path,
1579) -> Result<std::path::PathBuf, TestError> {
1580    use base64::Engine;
1581    let bytes = base64::engine::general_purpose::STANDARD
1582        .decode(base64_data)
1583        .map_err(|e| TestError::Other(format!("failed to decode screenshot base64: {e}")))?;
1584    if let Some(parent) = path.parent() {
1585        std::fs::create_dir_all(parent)
1586            .map_err(|e| TestError::Other(format!("failed to create directory: {e}")))?;
1587    }
1588    std::fs::write(path, &bytes)
1589        .map_err(|e| TestError::Other(format!("failed to write screenshot: {e}")))?;
1590    path.canonicalize()
1591        .or_else(|_| Ok(path.to_path_buf()))
1592        .map_err(|e: std::io::Error| TestError::Other(format!("path error: {e}")))
1593}
1594
1595fn extract_screenshot_base64(result: &Value) -> Result<String, TestError> {
1596    // Try various response shapes the plugin may return
1597    if let Some(data) = result.get("base64").and_then(Value::as_str) {
1598        return Ok(data.to_string());
1599    }
1600    if let Some(data) = result.get("data").and_then(Value::as_str) {
1601        return Ok(data.to_string());
1602    }
1603    if let Some(data) = result.get("image").and_then(Value::as_str) {
1604        return Ok(data.to_string());
1605    }
1606    if let Some(data) = result
1607        .pointer("/result/content/0/data")
1608        .and_then(Value::as_str)
1609    {
1610        return Ok(data.to_string());
1611    }
1612    Err(TestError::Other(
1613        "screenshot result does not contain recognizable base64 image data".to_string(),
1614    ))
1615}
1616
1617fn find_in_tree_by_text(node: &Value, text: &str) -> Option<String> {
1618    let node_text = node.get("text").and_then(Value::as_str).unwrap_or("");
1619    let node_name = node.get("name").and_then(Value::as_str).unwrap_or("");
1620    if (node_text.contains(text) || node_name.contains(text))
1621        && let Some(ref_id) = node.get("ref_id").and_then(Value::as_str)
1622    {
1623        return Some(ref_id.to_string());
1624    }
1625    if let Some(children) = node.get("children").and_then(Value::as_array) {
1626        for child in children {
1627            if let Some(found) = find_in_tree_by_text(child, text) {
1628                return Some(found);
1629            }
1630        }
1631    }
1632    None
1633}
1634
1635fn find_in_tree_by_attr_id(node: &Value, id: &str) -> Option<String> {
1636    if node
1637        .get("attributes")
1638        .and_then(|a| a.get("id"))
1639        .and_then(Value::as_str)
1640        == Some(id)
1641        && let Some(ref_id) = node.get("ref_id").and_then(Value::as_str)
1642    {
1643        return Some(ref_id.to_string());
1644    }
1645    if let Some(children) = node.get("children").and_then(Value::as_array) {
1646        for child in children {
1647            if let Some(found) = find_in_tree_by_attr_id(child, id) {
1648                return Some(found);
1649            }
1650        }
1651    }
1652    None
1653}
1654
1655fn find_text_by_attr_id(node: &Value, id: &str) -> Option<String> {
1656    if node
1657        .get("attributes")
1658        .and_then(|a| a.get("id"))
1659        .and_then(Value::as_str)
1660        == Some(id)
1661    {
1662        let text = node.get("text").and_then(Value::as_str).unwrap_or("");
1663        return Some(text.to_string());
1664    }
1665    if let Some(children) = node.get("children").and_then(Value::as_array) {
1666        for child in children {
1667            if let Some(found) = find_text_by_attr_id(child, id) {
1668                return Some(found);
1669            }
1670        }
1671    }
1672    None
1673}
1674
1675// ── Assertion Helpers ────────────────────────────────────────────────────────
1676
1677/// Assert that a JSON value at the given pointer equals the expected value.
1678///
1679/// # Panics
1680///
1681/// Panics if the value at `pointer` is missing or does not equal `expected`.
1682///
1683/// # Examples
1684///
1685/// ```
1686/// use serde_json::json;
1687///
1688/// let state = json!({"visible": true, "title": "My App"});
1689/// victauri_test::assert_json_eq(&state, "/visible", &json!(true));
1690/// victauri_test::assert_json_eq(&state, "/title", &json!("My App"));
1691/// ```
1692pub fn assert_json_eq(value: &Value, pointer: &str, expected: &Value) {
1693    let actual = value.pointer(pointer);
1694    assert!(
1695        actual == Some(expected),
1696        "JSON pointer {pointer}: expected {expected}, got {}",
1697        actual.map_or("missing".to_string(), std::string::ToString::to_string)
1698    );
1699}
1700
1701/// Assert that a JSON value at the given pointer is truthy (not null/false/0/"").
1702///
1703/// # Panics
1704///
1705/// Panics if the value at `pointer` is missing, null, false, zero, or empty.
1706///
1707/// # Examples
1708///
1709/// ```
1710/// use serde_json::json;
1711///
1712/// let value = json!({"active": true, "name": "test", "count": 42});
1713/// victauri_test::assert_json_truthy(&value, "/active");
1714/// victauri_test::assert_json_truthy(&value, "/name");
1715/// victauri_test::assert_json_truthy(&value, "/count");
1716/// ```
1717pub fn assert_json_truthy(value: &Value, pointer: &str) {
1718    let actual = value.pointer(pointer);
1719    let is_truthy = match actual {
1720        None | Some(Value::Null) => false,
1721        Some(Value::Bool(b)) => *b,
1722        Some(Value::Number(n)) => n.as_f64().unwrap_or(0.0) != 0.0,
1723        Some(Value::String(s)) => !s.is_empty(),
1724        Some(Value::Array(a)) => !a.is_empty(),
1725        Some(Value::Object(_)) => true,
1726    };
1727    assert!(
1728        is_truthy,
1729        "JSON pointer {pointer}: expected truthy, got {}",
1730        actual.map_or("missing".to_string(), std::string::ToString::to_string)
1731    );
1732}
1733
1734/// Assert that an accessibility audit has zero violations.
1735///
1736/// # Panics
1737///
1738/// Panics if the audit contains any violations.
1739///
1740/// # Examples
1741///
1742/// ```
1743/// use serde_json::json;
1744///
1745/// let audit = json!({"summary": {"violations": 0, "passes": 12}});
1746/// victauri_test::assert_no_a11y_violations(&audit);
1747/// ```
1748pub fn assert_no_a11y_violations(audit: &Value) {
1749    let violations = audit
1750        .pointer("/summary/violations")
1751        .and_then(serde_json::Value::as_u64)
1752        .unwrap_or(u64::MAX);
1753    assert_eq!(
1754        violations, 0,
1755        "expected 0 accessibility violations, got {violations}"
1756    );
1757}
1758
1759/// Assert that all performance metrics are within budget.
1760///
1761/// # Panics
1762///
1763/// Panics if load time exceeds `max_load_ms` or heap usage exceeds `max_heap_mb`.
1764///
1765/// # Examples
1766///
1767/// ```
1768/// use serde_json::json;
1769///
1770/// let metrics = json!({
1771///     "navigation": {"load_event_ms": 450.0},
1772///     "js_heap": {"used_mb": 12.5}
1773/// });
1774/// victauri_test::assert_performance_budget(&metrics, 1000.0, 50.0);
1775/// ```
1776pub fn assert_performance_budget(metrics: &Value, max_load_ms: f64, max_heap_mb: f64) {
1777    if let Some(load) = metrics
1778        .pointer("/navigation/load_event_ms")
1779        .and_then(serde_json::Value::as_f64)
1780    {
1781        assert!(
1782            load <= max_load_ms,
1783            "load event took {load}ms, budget is {max_load_ms}ms"
1784        );
1785    }
1786
1787    if let Some(heap) = metrics
1788        .pointer("/js_heap/used_mb")
1789        .and_then(serde_json::Value::as_f64)
1790    {
1791        assert!(
1792            heap <= max_heap_mb,
1793            "JS heap is {heap}MB, budget is {max_heap_mb}MB"
1794        );
1795    }
1796}
1797
1798/// Assert that IPC integrity is healthy (no stale or errored calls).
1799///
1800/// # Panics
1801///
1802/// Panics if the integrity check reports an unhealthy state.
1803///
1804/// # Examples
1805///
1806/// ```
1807/// use serde_json::json;
1808///
1809/// let integrity = json!({"healthy": true, "stale_calls": 0, "error_calls": 0});
1810/// victauri_test::assert_ipc_healthy(&integrity);
1811/// ```
1812pub fn assert_ipc_healthy(integrity: &Value) {
1813    let healthy = integrity
1814        .get("healthy")
1815        .and_then(serde_json::Value::as_bool)
1816        .unwrap_or(false);
1817    assert!(
1818        healthy,
1819        "IPC integrity check failed: {}",
1820        serde_json::to_string_pretty(integrity).unwrap_or_default()
1821    );
1822}
1823
1824/// Assert that state verification passed with no divergences.
1825///
1826/// # Panics
1827///
1828/// Panics if the verification reports any divergences.
1829///
1830/// # Examples
1831///
1832/// ```
1833/// use serde_json::json;
1834///
1835/// let verification = json!({"passed": true, "divergences": []});
1836/// victauri_test::assert_state_matches(&verification);
1837/// ```
1838pub fn assert_state_matches(verification: &Value) {
1839    let passed = verification
1840        .get("passed")
1841        .and_then(serde_json::Value::as_bool)
1842        .unwrap_or(false);
1843    assert!(
1844        passed,
1845        "state verification failed: {}",
1846        serde_json::to_string_pretty(verification).unwrap_or_default()
1847    );
1848}