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    // ── Backend Access ─────────────────────────────────────────────────────
779
780    /// Get app info: Tauri config, directory paths, env vars, discovered databases.
781    ///
782    /// # Errors
783    ///
784    /// Returns errors from [`VictauriClient::call_tool`].
785    pub async fn app_info(&mut self) -> Result<Value, TestError> {
786        self.call_tool("app_info", json!({})).await
787    }
788
789    /// List files in an app directory (data, config, log, or `local_data`).
790    ///
791    /// # Errors
792    ///
793    /// Returns errors from [`VictauriClient::call_tool`].
794    pub async fn list_app_dir(
795        &mut self,
796        directory: Option<&str>,
797        path: Option<&str>,
798    ) -> Result<Value, TestError> {
799        let mut args = json!({});
800        if let Some(d) = directory {
801            args["directory"] = json!(d);
802        }
803        if let Some(p) = path {
804            args["path"] = json!(p);
805        }
806        self.call_tool("list_app_dir", args).await
807    }
808
809    /// Read a file from an app directory.
810    ///
811    /// # Errors
812    ///
813    /// Returns errors from [`VictauriClient::call_tool`].
814    pub async fn read_app_file(
815        &mut self,
816        path: &str,
817        directory: Option<&str>,
818    ) -> Result<Value, TestError> {
819        let mut args = json!({"path": path});
820        if let Some(d) = directory {
821            args["directory"] = json!(d);
822        }
823        self.call_tool("read_app_file", args).await
824    }
825
826    /// Execute a read-only SQL query against a `SQLite` database in the app data directory.
827    ///
828    /// # Errors
829    ///
830    /// Returns errors from [`VictauriClient::call_tool`].
831    pub async fn query_db(
832        &mut self,
833        query: &str,
834        db_path: Option<&str>,
835        params: Option<Vec<Value>>,
836    ) -> Result<Value, TestError> {
837        let mut args = json!({"query": query});
838        if let Some(p) = db_path {
839            args["path"] = json!(p);
840        }
841        if let Some(params) = params {
842            args["params"] = json!(params);
843        }
844        self.call_tool("query_db", args).await
845    }
846
847    /// Wait for a condition to be met, polling at an interval.
848    ///
849    /// Conditions: `text`, `text_gone`, `selector`, `selector_gone`, `url`,
850    /// `ipc_idle`, `network_idle`.
851    ///
852    /// # Errors
853    ///
854    /// Returns errors from [`VictauriClient::call_tool`].
855    pub async fn wait_for(
856        &mut self,
857        condition: &str,
858        value: Option<&str>,
859        timeout_ms: Option<u64>,
860        poll_ms: Option<u64>,
861    ) -> Result<Value, TestError> {
862        let mut args = json!({"condition": condition});
863        if let Some(v) = value {
864            args["value"] = json!(v);
865        }
866        if let Some(t) = timeout_ms {
867            args["timeout_ms"] = json!(t);
868        }
869        if let Some(p) = poll_ms {
870            args["poll_ms"] = json!(p);
871        }
872        self.call_tool("wait_for", args).await
873    }
874
875    /// Start a time-travel recording session.
876    ///
877    /// # Errors
878    ///
879    /// Returns errors from [`VictauriClient::call_tool`].
880    pub async fn start_recording(&mut self, session_id: Option<&str>) -> Result<Value, TestError> {
881        let mut args = json!({"action": "start"});
882        if let Some(id) = session_id {
883            args["session_id"] = json!(id);
884        }
885        self.call_tool("recording", args).await
886    }
887
888    /// Stop the recording and return the session.
889    ///
890    /// # Errors
891    ///
892    /// Returns errors from [`VictauriClient::call_tool`].
893    pub async fn stop_recording(&mut self) -> Result<Value, TestError> {
894        self.call_tool("recording", json!({"action": "stop"})).await
895    }
896
897    /// Export the current recording session as JSON.
898    ///
899    /// # Errors
900    ///
901    /// Returns errors from [`VictauriClient::call_tool`].
902    pub async fn export_session(&mut self) -> Result<Value, TestError> {
903        self.call_tool("recording", json!({"action": "export"}))
904            .await
905    }
906
907    /// Search for elements by various criteria without a full snapshot.
908    ///
909    /// # Errors
910    ///
911    /// Returns errors from [`VictauriClient::call_tool`].
912    pub async fn find_elements(&mut self, query: Value) -> Result<Value, TestError> {
913        self.call_tool("find_elements", query).await
914    }
915
916    /// Double-click an element by ref handle ID.
917    ///
918    /// # Errors
919    ///
920    /// Returns errors from [`VictauriClient::call_tool`].
921    pub async fn double_click(&mut self, ref_id: &str) -> Result<Value, TestError> {
922        self.call_tool(
923            "interact",
924            json!({"action": "double_click", "ref_id": ref_id}),
925        )
926        .await
927    }
928
929    /// Hover over an element by ref handle.
930    ///
931    /// # Errors
932    ///
933    /// Returns errors from [`VictauriClient::call_tool`].
934    pub async fn hover(&mut self, ref_id: &str) -> Result<Value, TestError> {
935        self.call_tool("interact", json!({"action": "hover", "ref_id": ref_id}))
936            .await
937    }
938
939    /// Focus an element by ref handle.
940    ///
941    /// # Errors
942    ///
943    /// Returns errors from [`VictauriClient::call_tool`].
944    pub async fn focus(&mut self, ref_id: &str) -> Result<Value, TestError> {
945        self.call_tool("interact", json!({"action": "focus", "ref_id": ref_id}))
946            .await
947    }
948
949    /// Press a keyboard key.
950    ///
951    /// # Errors
952    ///
953    /// Returns errors from [`VictauriClient::call_tool`].
954    pub async fn press_key(&mut self, key: &str) -> Result<Value, TestError> {
955        self.call_tool("input", json!({"action": "press_key", "key": key}))
956            .await
957    }
958
959    /// Navigate to a URL.
960    ///
961    /// # Errors
962    ///
963    /// Returns errors from [`VictauriClient::call_tool`].
964    pub async fn navigate(&mut self, url: &str) -> Result<Value, TestError> {
965        self.call_tool("navigate", json!({"action": "go_to", "url": url}))
966            .await
967    }
968
969    /// Get logs by type (console, network, ipc, navigation, dialogs).
970    ///
971    /// # Errors
972    ///
973    /// Returns errors from [`VictauriClient::call_tool`].
974    pub async fn logs(&mut self, action: &str, limit: Option<usize>) -> Result<Value, TestError> {
975        self.call_tool("logs", json!({"action": action, "limit": limit}))
976            .await
977    }
978
979    /// Scroll an element into view by ref handle.
980    ///
981    /// # Errors
982    ///
983    /// Returns errors from [`VictauriClient::call_tool`].
984    pub async fn scroll_to(&mut self, ref_id: &str) -> Result<Value, TestError> {
985        self.call_tool(
986            "interact",
987            json!({"action": "scroll_into_view", "ref_id": ref_id}),
988        )
989        .await
990    }
991
992    /// Select option(s) in a `<select>` element.
993    ///
994    /// # Errors
995    ///
996    /// Returns errors from [`VictauriClient::call_tool`].
997    pub async fn select_option(
998        &mut self,
999        ref_id: &str,
1000        values: &[&str],
1001    ) -> Result<Value, TestError> {
1002        self.call_tool(
1003            "interact",
1004            json!({"action": "select_option", "ref_id": ref_id, "values": values}),
1005        )
1006        .await
1007    }
1008
1009    /// Get the server base URL.
1010    #[must_use]
1011    pub fn base_url(&self) -> &str {
1012        &self.base_url
1013    }
1014
1015    /// Get the host the client is connected to.
1016    #[must_use]
1017    pub fn host(&self) -> &str {
1018        &self.host
1019    }
1020
1021    /// Get the port the client is connected to.
1022    #[must_use]
1023    pub fn port(&self) -> u16 {
1024        self.port
1025    }
1026
1027    /// Get the MCP session ID.
1028    #[must_use]
1029    pub fn session_id(&self) -> &str {
1030        &self.session_id
1031    }
1032
1033    pub(crate) fn http_client(&self) -> &reqwest::Client {
1034        &self.http
1035    }
1036
1037    // ── IPC Log Helpers ───────────────────────────────────────────────────────
1038
1039    /// Get IPC calls filtered to a specific command.
1040    ///
1041    /// Returns a Vec of all IPC log entries matching the given command name.
1042    ///
1043    /// # Errors
1044    ///
1045    /// Returns errors from [`VictauriClient::call_tool`].
1046    #[deprecated(since = "0.2.0", note = "renamed to get_ipc_calls_for")]
1047    pub async fn get_ipc_calls(&mut self, command: &str) -> Result<Vec<Value>, TestError> {
1048        let log = self.get_ipc_log(None).await?;
1049        let entries = if let Some(arr) = log.as_array() {
1050            arr.clone()
1051        } else if let Some(entries) = log.get("entries").and_then(Value::as_array) {
1052            entries.clone()
1053        } else {
1054            return Ok(Vec::new());
1055        };
1056        Ok(entries
1057            .into_iter()
1058            .filter(|e| {
1059                e.get("command")
1060                    .and_then(Value::as_str)
1061                    .is_some_and(|c| c == command)
1062            })
1063            .collect())
1064    }
1065
1066    /// Get IPC calls made since a previous checkpoint.
1067    ///
1068    /// # Errors
1069    ///
1070    /// Returns errors from [`VictauriClient::call_tool`].
1071    #[deprecated(since = "0.2.0", note = "renamed to get_ipc_calls_since")]
1072    pub async fn ipc_calls_since(&mut self, checkpoint: usize) -> Result<Vec<Value>, TestError> {
1073        let log = self.get_ipc_log(None).await?;
1074        let entries = if let Some(arr) = log.as_array() {
1075            arr.clone()
1076        } else if let Some(entries) = log.get("entries").and_then(Value::as_array) {
1077            entries.clone()
1078        } else {
1079            return Ok(Vec::new());
1080        };
1081        Ok(entries.into_iter().skip(checkpoint).collect())
1082    }
1083
1084    /// Filter the IPC log for calls to a specific command.
1085    ///
1086    /// # Errors
1087    ///
1088    /// Returns errors from [`VictauriClient::call_tool`].
1089    pub async fn get_ipc_calls_for(&mut self, command: &str) -> Result<Vec<Value>, TestError> {
1090        #[allow(deprecated)]
1091        self.get_ipc_calls(command).await
1092    }
1093
1094    /// Get IPC calls made since a previous checkpoint.
1095    ///
1096    /// # Errors
1097    ///
1098    /// Returns errors from [`VictauriClient::call_tool`].
1099    pub async fn get_ipc_calls_since(
1100        &mut self,
1101        checkpoint: usize,
1102    ) -> Result<Vec<Value>, TestError> {
1103        #[allow(deprecated)]
1104        self.ipc_calls_since(checkpoint).await
1105    }
1106
1107    // ── Builder-Style Wait (Phase 4B) ──────────────────────────────────────────
1108
1109    /// Start a builder-style wait for a condition.
1110    ///
1111    /// This is a fluent alternative to [`VictauriClient::wait_for`] that avoids
1112    /// positional `Option` arguments.
1113    ///
1114    /// # Examples
1115    ///
1116    /// ```rust,ignore
1117    /// client.wait("text")
1118    ///     .value("Welcome")
1119    ///     .timeout_ms(5000)
1120    ///     .run()
1121    ///     .await
1122    ///     .unwrap();
1123    /// ```
1124    pub fn wait(&mut self, condition: &str) -> WaitForBuilder<'_> {
1125        WaitForBuilder {
1126            client: self,
1127            condition: condition.to_string(),
1128            value: None,
1129            timeout_ms: 10_000,
1130            poll_ms: 200,
1131        }
1132    }
1133
1134    // ── Deprecated Aliases (Phase 4C) ────────────────────────────────────────
1135
1136    /// Snapshot the current IPC log length, for use with `ipc_calls_since`.
1137    ///
1138    /// Prefer [`VictauriClient::create_ipc_checkpoint`] — this alias exists
1139    /// for backwards compatibility.
1140    ///
1141    /// # Errors
1142    ///
1143    /// Returns errors from [`VictauriClient::call_tool`].
1144    #[deprecated(since = "0.2.0", note = "renamed to create_ipc_checkpoint")]
1145    pub async fn ipc_checkpoint(&mut self) -> Result<usize, TestError> {
1146        self.create_ipc_checkpoint().await
1147    }
1148
1149    /// Snapshot the current IPC log length, for use with `ipc_calls_since`.
1150    ///
1151    /// Returns the number of IPC calls recorded so far. Pass this value to
1152    /// [`VictauriClient::ipc_calls_since`] to get only the calls that occurred
1153    /// after the checkpoint.
1154    ///
1155    /// # Errors
1156    ///
1157    /// Returns errors from [`VictauriClient::call_tool`].
1158    pub async fn create_ipc_checkpoint(&mut self) -> Result<usize, TestError> {
1159        let log = self.get_ipc_log(None).await?;
1160        let len = if let Some(arr) = log.as_array() {
1161            arr.len()
1162        } else if let Some(entries) = log.get("entries").and_then(Value::as_array) {
1163            entries.len()
1164        } else {
1165            0
1166        };
1167        Ok(len)
1168    }
1169
1170    // ── Typed Response Methods (Phase 4E) ────────────────────────────────────
1171
1172    /// Read plugin info as a typed [`PluginInfo`] struct.
1173    ///
1174    /// This is a typed alternative to [`VictauriClient::get_plugin_info`] which
1175    /// returns raw JSON.
1176    ///
1177    /// # Errors
1178    ///
1179    /// Returns [`TestError::Other`] if the response cannot be deserialized.
1180    /// Returns other errors from [`VictauriClient::call_tool`].
1181    pub async fn plugin_info(&mut self) -> Result<PluginInfo, TestError> {
1182        let value = self.get_plugin_info().await?;
1183        serde_json::from_value(value)
1184            .map_err(|e| TestError::Other(format!("failed to deserialize PluginInfo: {e}")))
1185    }
1186
1187    /// Read process memory statistics as a typed [`MemoryStats`] struct.
1188    ///
1189    /// This is a typed alternative to [`VictauriClient::get_memory_stats`] which
1190    /// returns raw JSON.
1191    ///
1192    /// # Errors
1193    ///
1194    /// Returns [`TestError::Other`] if the response cannot be deserialized.
1195    /// Returns other errors from [`VictauriClient::call_tool`].
1196    pub async fn memory_stats(&mut self) -> Result<MemoryStats, TestError> {
1197        let value = self.get_memory_stats().await?;
1198        serde_json::from_value(value)
1199            .map_err(|e| TestError::Other(format!("failed to deserialize MemoryStats: {e}")))
1200    }
1201
1202    // ── Fluent Verification Builder ───────────────────────────────────────────
1203
1204    /// Start a fluent verification chain that checks multiple conditions at once.
1205    ///
1206    /// Unlike individual assertions that panic on failure, `verify()` collects
1207    /// all results and reports them together — making test failures more
1208    /// informative and reducing test reruns.
1209    ///
1210    /// # Examples
1211    ///
1212    /// ```rust,ignore
1213    /// let report = client.verify()
1214    ///     .has_text("Welcome")
1215    ///     .ipc_was_called("greet")
1216    ///     .no_console_errors()
1217    ///     .run()
1218    ///     .await
1219    ///     .unwrap();
1220    /// report.assert_all_passed();
1221    /// ```
1222    pub fn verify(&mut self) -> VerifyBuilder<'_> {
1223        VerifyBuilder::new(self)
1224    }
1225
1226    // ── High-Level Playwright-Style API ─────────────────────────────────────
1227
1228    /// Click the first element whose accessible text contains the given string.
1229    ///
1230    /// Takes a DOM snapshot, finds the element, and clicks it.
1231    ///
1232    /// # Errors
1233    ///
1234    /// Returns [`TestError::ElementNotFound`] if no matching element is found.
1235    /// Returns other errors from [`VictauriClient::call_tool`].
1236    pub async fn click_by_text(&mut self, text: &str) -> Result<Value, TestError> {
1237        let ref_id = self.find_ref_by_text(text).await?;
1238        self.click(&ref_id).await
1239    }
1240
1241    /// Click the element with the given HTML `id` attribute.
1242    ///
1243    /// # Errors
1244    ///
1245    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
1246    /// Returns other errors from [`VictauriClient::call_tool`].
1247    pub async fn click_by_id(&mut self, id: &str) -> Result<Value, TestError> {
1248        let ref_id = self.find_ref_by_id(id).await?;
1249        self.click(&ref_id).await
1250    }
1251
1252    /// Double-click the first element whose accessible text contains the given string.
1253    ///
1254    /// # Errors
1255    ///
1256    /// Returns [`TestError::ElementNotFound`] if no matching element is found.
1257    /// Returns other errors from [`VictauriClient::call_tool`].
1258    pub async fn double_click_by_text(&mut self, text: &str) -> Result<Value, TestError> {
1259        let ref_id = self.find_ref_by_text(text).await?;
1260        self.double_click(&ref_id).await
1261    }
1262
1263    /// Double-click the element with the given HTML `id` attribute.
1264    ///
1265    /// # Errors
1266    ///
1267    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
1268    /// Returns other errors from [`VictauriClient::call_tool`].
1269    pub async fn double_click_by_id(&mut self, id: &str) -> Result<Value, TestError> {
1270        let ref_id = self.find_ref_by_id(id).await?;
1271        self.double_click(&ref_id).await
1272    }
1273
1274    /// Double-click the first element matching a CSS selector.
1275    ///
1276    /// Resolves the selector via `find_elements`, then double-clicks the first match.
1277    ///
1278    /// # Errors
1279    ///
1280    /// Returns [`TestError::ElementNotFound`] if no element matches the selector.
1281    /// Returns other errors from [`VictauriClient::call_tool`].
1282    pub async fn double_click_by_selector(&mut self, selector: &str) -> Result<Value, TestError> {
1283        let ref_id = self.find_ref_by_selector(selector).await?;
1284        self.double_click(&ref_id).await
1285    }
1286
1287    /// Click the first element matching a CSS selector.
1288    ///
1289    /// Resolves the selector via `find_elements`, then clicks the first match.
1290    ///
1291    /// # Errors
1292    ///
1293    /// Returns [`TestError::ElementNotFound`] if no element matches the selector.
1294    /// Returns other errors from [`VictauriClient::call_tool`].
1295    pub async fn click_by_selector(&mut self, selector: &str) -> Result<Value, TestError> {
1296        let ref_id = self.find_ref_by_selector(selector).await?;
1297        self.click(&ref_id).await
1298    }
1299
1300    /// Fill an input identified by HTML `id` with the given value.
1301    ///
1302    /// # Errors
1303    ///
1304    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
1305    /// Returns other errors from [`VictauriClient::call_tool`].
1306    pub async fn fill_by_id(&mut self, id: &str, value: &str) -> Result<Value, TestError> {
1307        let ref_id = self.find_ref_by_id(id).await?;
1308        self.fill(&ref_id, value).await
1309    }
1310
1311    /// Fill an input whose accessible text contains the given string.
1312    ///
1313    /// # Errors
1314    ///
1315    /// Returns [`TestError::ElementNotFound`] if no matching element is found.
1316    /// Returns other errors from [`VictauriClient::call_tool`].
1317    pub async fn fill_by_text(&mut self, text: &str, value: &str) -> Result<Value, TestError> {
1318        let ref_id = self.find_ref_by_text(text).await?;
1319        self.fill(&ref_id, value).await
1320    }
1321
1322    /// Fill an input matching a CSS selector with the given value.
1323    ///
1324    /// Resolves the selector via `find_elements`, then fills the first match.
1325    ///
1326    /// # Errors
1327    ///
1328    /// Returns [`TestError::ElementNotFound`] if no element matches the selector.
1329    /// Returns other errors from [`VictauriClient::call_tool`].
1330    pub async fn fill_by_selector(
1331        &mut self,
1332        selector: &str,
1333        value: &str,
1334    ) -> Result<Value, TestError> {
1335        let ref_id = self.find_ref_by_selector(selector).await?;
1336        self.fill(&ref_id, value).await
1337    }
1338
1339    /// Type text into an input identified by HTML `id`, character by character.
1340    ///
1341    /// # Errors
1342    ///
1343    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
1344    /// Returns other errors from [`VictauriClient::call_tool`].
1345    pub async fn type_by_id(&mut self, id: &str, text: &str) -> Result<Value, TestError> {
1346        let ref_id = self.find_ref_by_id(id).await?;
1347        self.type_text(&ref_id, text).await
1348    }
1349
1350    /// Wait until the page contains the given text (polls DOM snapshots).
1351    ///
1352    /// Default timeout: 5000ms, poll interval: 200ms.
1353    ///
1354    /// # Errors
1355    ///
1356    /// Returns [`TestError::Timeout`] if the text doesn't appear within the timeout.
1357    /// Returns other errors from [`VictauriClient::call_tool`].
1358    pub async fn expect_text(&mut self, text: &str) -> Result<(), TestError> {
1359        self.expect_text_with_timeout(text, 5000).await
1360    }
1361
1362    /// Wait until the page contains the given text, with a custom timeout in ms.
1363    ///
1364    /// # Errors
1365    ///
1366    /// Returns [`TestError::Timeout`] if the text doesn't appear within the timeout.
1367    /// Returns other errors from [`VictauriClient::call_tool`].
1368    pub async fn expect_text_with_timeout(
1369        &mut self,
1370        text: &str,
1371        timeout_ms: u64,
1372    ) -> Result<(), TestError> {
1373        let result = self
1374            .wait_for("text", Some(text), Some(timeout_ms), Some(200))
1375            .await?;
1376        if result.get("ok").and_then(Value::as_bool) == Some(true) {
1377            Ok(())
1378        } else {
1379            Err(TestError::Timeout(format!(
1380                "text \"{text}\" did not appear within {timeout_ms}ms"
1381            )))
1382        }
1383    }
1384
1385    /// Wait until the page no longer contains the given text.
1386    ///
1387    /// Default timeout: 3000ms, poll interval: 200ms.
1388    ///
1389    /// # Errors
1390    ///
1391    /// Returns [`TestError::Timeout`] if the text is still present after the timeout.
1392    /// Returns other errors from [`VictauriClient::call_tool`].
1393    pub async fn expect_no_text(&mut self, text: &str) -> Result<(), TestError> {
1394        let result = self
1395            .wait_for("text_gone", Some(text), Some(3000), Some(200))
1396            .await?;
1397        if result.get("ok").and_then(Value::as_bool) == Some(true) {
1398            Ok(())
1399        } else {
1400            Err(TestError::Timeout(format!(
1401                "text \"{text}\" still present after 3000ms"
1402            )))
1403        }
1404    }
1405
1406    /// Select an option in a `<select>` element identified by HTML `id`.
1407    ///
1408    /// # Errors
1409    ///
1410    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
1411    /// Returns other errors from [`VictauriClient::call_tool`].
1412    pub async fn select_by_id(&mut self, id: &str, value: &str) -> Result<Value, TestError> {
1413        let ref_id = self.find_ref_by_id(id).await?;
1414        self.select_option(&ref_id, &[value]).await
1415    }
1416
1417    /// Select option(s) in a `<select>` element identified by HTML `id`.
1418    ///
1419    /// Accepts multiple values for multi-select elements.
1420    ///
1421    /// # Errors
1422    ///
1423    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
1424    /// Returns other errors from [`VictauriClient::call_tool`].
1425    pub async fn select_option_by_id(
1426        &mut self,
1427        id: &str,
1428        values: &[&str],
1429    ) -> Result<Value, TestError> {
1430        let ref_id = self.find_ref_by_id(id).await?;
1431        self.select_option(&ref_id, values).await
1432    }
1433
1434    /// Select option(s) in a `<select>` element whose accessible text contains
1435    /// the given string.
1436    ///
1437    /// # Errors
1438    ///
1439    /// Returns [`TestError::ElementNotFound`] if no matching element is found.
1440    /// Returns other errors from [`VictauriClient::call_tool`].
1441    pub async fn select_option_by_text(
1442        &mut self,
1443        text: &str,
1444        values: &[&str],
1445    ) -> Result<Value, TestError> {
1446        let ref_id = self.find_ref_by_text(text).await?;
1447        self.select_option(&ref_id, values).await
1448    }
1449
1450    /// Select option(s) in a `<select>` element matching a CSS selector.
1451    ///
1452    /// Resolves the selector via `find_elements`, then selects in the first match.
1453    ///
1454    /// # Errors
1455    ///
1456    /// Returns [`TestError::ElementNotFound`] if no element matches the selector.
1457    /// Returns other errors from [`VictauriClient::call_tool`].
1458    pub async fn select_option_by_selector(
1459        &mut self,
1460        selector: &str,
1461        values: &[&str],
1462    ) -> Result<Value, TestError> {
1463        let ref_id = self.find_ref_by_selector(selector).await?;
1464        self.select_option(&ref_id, values).await
1465    }
1466
1467    /// Scroll an element matching a CSS selector into view.
1468    ///
1469    /// Resolves the selector via `find_elements`, then scrolls the first match.
1470    ///
1471    /// # Errors
1472    ///
1473    /// Returns [`TestError::ElementNotFound`] if no element matches the selector.
1474    /// Returns other errors from [`VictauriClient::call_tool`].
1475    pub async fn scroll_to_by_selector(&mut self, selector: &str) -> Result<Value, TestError> {
1476        let ref_id = self.find_ref_by_selector(selector).await?;
1477        self.scroll_to(&ref_id).await
1478    }
1479
1480    /// Scroll an element with the given HTML `id` into view.
1481    ///
1482    /// # Errors
1483    ///
1484    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
1485    /// Returns other errors from [`VictauriClient::call_tool`].
1486    pub async fn scroll_to_by_id(&mut self, id: &str) -> Result<Value, TestError> {
1487        let ref_id = self.find_ref_by_id(id).await?;
1488        self.scroll_to(&ref_id).await
1489    }
1490
1491    /// Get the text content of an element identified by HTML `id`.
1492    ///
1493    /// # Errors
1494    ///
1495    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
1496    /// Returns other errors from [`VictauriClient::call_tool`].
1497    pub async fn text_by_id(&mut self, id: &str) -> Result<String, TestError> {
1498        let snap = self.snapshot_json().await?;
1499        let tree = &snap["tree"];
1500        find_text_by_attr_id(tree, id)
1501            .ok_or_else(|| TestError::ElementNotFound(format!("id=\"{id}\"")))
1502    }
1503
1504    // ── Internal helpers for high-level API ─────────────────────────────────
1505
1506    async fn snapshot_json(&mut self) -> Result<Value, TestError> {
1507        self.call_tool("dom_snapshot", json!({"format": "json"}))
1508            .await
1509    }
1510
1511    async fn find_ref_by_text(&mut self, text: &str) -> Result<String, TestError> {
1512        let snap = self.snapshot_json().await?;
1513        let tree = &snap["tree"];
1514        find_in_tree_by_text(tree, text)
1515            .ok_or_else(|| TestError::ElementNotFound(format!("text=\"{text}\"")))
1516    }
1517
1518    async fn find_ref_by_id(&mut self, id: &str) -> Result<String, TestError> {
1519        let snap = self.snapshot_json().await?;
1520        let tree = &snap["tree"];
1521        find_in_tree_by_attr_id(tree, id)
1522            .ok_or_else(|| TestError::ElementNotFound(format!("id=\"{id}\"")))
1523    }
1524
1525    async fn find_ref_by_selector(&mut self, selector: &str) -> Result<String, TestError> {
1526        let result = self.find_elements(json!({"selector": selector})).await?;
1527        // find_elements returns an array of matched elements with ref_id fields
1528        let elements = result
1529            .as_array()
1530            .or_else(|| result.get("elements").and_then(Value::as_array));
1531        if let Some(elems) = elements
1532            && let Some(first) = elems.first()
1533            && let Some(ref_id) = first.get("ref_id").and_then(Value::as_str)
1534        {
1535            return Ok(ref_id.to_string());
1536        }
1537        Err(TestError::ElementNotFound(format!(
1538            "selector=\"{selector}\""
1539        )))
1540    }
1541
1542    // ── Locator Factories ──────────────────────────────────────────────────
1543
1544    /// Create a [`Locator`](crate::Locator) matching elements by ARIA role.
1545    ///
1546    /// Equivalent to Playwright's `page.getByRole()`.
1547    #[must_use]
1548    pub fn get_by_role(&self, role: &str) -> crate::locator::Locator {
1549        crate::locator::Locator::role(role)
1550    }
1551
1552    /// Create a [`Locator`](crate::Locator) matching elements by visible text content.
1553    ///
1554    /// Equivalent to Playwright's `page.getByText()`.
1555    #[must_use]
1556    pub fn get_by_text(&self, text: &str) -> crate::locator::Locator {
1557        crate::locator::Locator::text(text)
1558    }
1559
1560    /// Create a [`Locator`](crate::Locator) matching elements by `data-testid` attribute.
1561    ///
1562    /// Equivalent to Playwright's `page.getByTestId()`.
1563    #[must_use]
1564    pub fn get_by_test_id(&self, id: &str) -> crate::locator::Locator {
1565        crate::locator::Locator::test_id(id)
1566    }
1567
1568    /// Create a [`Locator`](crate::Locator) matching form controls by associated label text.
1569    ///
1570    /// Equivalent to Playwright's `page.getByLabel()`.
1571    #[must_use]
1572    pub fn get_by_label(&self, text: &str) -> crate::locator::Locator {
1573        crate::locator::Locator::label(text)
1574    }
1575
1576    /// Create a [`Locator`](crate::Locator) matching elements by placeholder text.
1577    ///
1578    /// Equivalent to Playwright's `page.getByPlaceholder()`.
1579    #[must_use]
1580    pub fn get_by_placeholder(&self, text: &str) -> crate::locator::Locator {
1581        crate::locator::Locator::placeholder(text)
1582    }
1583
1584    /// Create a [`Locator`](crate::Locator) matching elements by CSS selector.
1585    ///
1586    /// Equivalent to Playwright's `page.locator()`.
1587    #[must_use]
1588    pub fn locator(&self, css: &str) -> crate::locator::Locator {
1589        crate::locator::Locator::css(css)
1590    }
1591
1592    /// Create a [`Locator`](crate::Locator) matching elements by alt text (images).
1593    ///
1594    /// Equivalent to Playwright's `page.getByAltText()`.
1595    #[must_use]
1596    pub fn get_by_alt_text(&self, alt: &str) -> crate::locator::Locator {
1597        crate::locator::Locator::alt_text(alt)
1598    }
1599
1600    /// Create a [`Locator`](crate::Locator) matching elements by title attribute.
1601    ///
1602    /// Equivalent to Playwright's `page.getByTitle()`.
1603    #[must_use]
1604    pub fn get_by_title(&self, title: &str) -> crate::locator::Locator {
1605        crate::locator::Locator::title(title)
1606    }
1607
1608    // ── Screenshot to File ─────────────────────────────────────────────────
1609
1610    /// Take a screenshot and save it to a file on disk.
1611    ///
1612    /// Captures the default window, decodes the base64 PNG, and writes it
1613    /// to the given path. Returns the canonical path of the saved file.
1614    ///
1615    /// # Errors
1616    ///
1617    /// Returns [`TestError::Other`] if the screenshot cannot be captured,
1618    /// decoded, or written to disk.
1619    pub async fn screenshot_to_file(
1620        &mut self,
1621        path: impl AsRef<std::path::Path>,
1622    ) -> Result<std::path::PathBuf, TestError> {
1623        let result = self.screenshot().await?;
1624        let base64_data = extract_screenshot_base64(&result)?;
1625        save_screenshot_to_file(&base64_data, path.as_ref())
1626    }
1627
1628    /// Take a screenshot of a specific window and save it to a file.
1629    ///
1630    /// # Errors
1631    ///
1632    /// Returns [`TestError::Other`] if the screenshot cannot be captured,
1633    /// decoded, or written to disk.
1634    pub async fn screenshot_to_file_for(
1635        &mut self,
1636        label: &str,
1637        path: impl AsRef<std::path::Path>,
1638    ) -> Result<std::path::PathBuf, TestError> {
1639        let result = self.screenshot_for(label).await?;
1640        let base64_data = extract_screenshot_base64(&result)?;
1641        save_screenshot_to_file(&base64_data, path.as_ref())
1642    }
1643}
1644
1645fn save_screenshot_to_file(
1646    base64_data: &str,
1647    path: &std::path::Path,
1648) -> Result<std::path::PathBuf, TestError> {
1649    use base64::Engine;
1650    let bytes = base64::engine::general_purpose::STANDARD
1651        .decode(base64_data)
1652        .map_err(|e| TestError::Other(format!("failed to decode screenshot base64: {e}")))?;
1653    if let Some(parent) = path.parent() {
1654        std::fs::create_dir_all(parent)
1655            .map_err(|e| TestError::Other(format!("failed to create directory: {e}")))?;
1656    }
1657    std::fs::write(path, &bytes)
1658        .map_err(|e| TestError::Other(format!("failed to write screenshot: {e}")))?;
1659    path.canonicalize()
1660        .or_else(|_| Ok(path.to_path_buf()))
1661        .map_err(|e: std::io::Error| TestError::Other(format!("path error: {e}")))
1662}
1663
1664fn extract_screenshot_base64(result: &Value) -> Result<String, TestError> {
1665    // Try various response shapes the plugin may return
1666    if let Some(data) = result.get("base64").and_then(Value::as_str) {
1667        return Ok(data.to_string());
1668    }
1669    if let Some(data) = result.get("data").and_then(Value::as_str) {
1670        return Ok(data.to_string());
1671    }
1672    if let Some(data) = result.get("image").and_then(Value::as_str) {
1673        return Ok(data.to_string());
1674    }
1675    if let Some(data) = result
1676        .pointer("/result/content/0/data")
1677        .and_then(Value::as_str)
1678    {
1679        return Ok(data.to_string());
1680    }
1681    Err(TestError::Other(
1682        "screenshot result does not contain recognizable base64 image data".to_string(),
1683    ))
1684}
1685
1686fn find_in_tree_by_text(node: &Value, text: &str) -> Option<String> {
1687    let node_text = node.get("text").and_then(Value::as_str).unwrap_or("");
1688    let node_name = node.get("name").and_then(Value::as_str).unwrap_or("");
1689    if (node_text.contains(text) || node_name.contains(text))
1690        && let Some(ref_id) = node.get("ref_id").and_then(Value::as_str)
1691    {
1692        return Some(ref_id.to_string());
1693    }
1694    if let Some(children) = node.get("children").and_then(Value::as_array) {
1695        for child in children {
1696            if let Some(found) = find_in_tree_by_text(child, text) {
1697                return Some(found);
1698            }
1699        }
1700    }
1701    None
1702}
1703
1704fn find_in_tree_by_attr_id(node: &Value, id: &str) -> Option<String> {
1705    if node
1706        .get("attributes")
1707        .and_then(|a| a.get("id"))
1708        .and_then(Value::as_str)
1709        == Some(id)
1710        && let Some(ref_id) = node.get("ref_id").and_then(Value::as_str)
1711    {
1712        return Some(ref_id.to_string());
1713    }
1714    if let Some(children) = node.get("children").and_then(Value::as_array) {
1715        for child in children {
1716            if let Some(found) = find_in_tree_by_attr_id(child, id) {
1717                return Some(found);
1718            }
1719        }
1720    }
1721    None
1722}
1723
1724fn find_text_by_attr_id(node: &Value, id: &str) -> Option<String> {
1725    if node
1726        .get("attributes")
1727        .and_then(|a| a.get("id"))
1728        .and_then(Value::as_str)
1729        == Some(id)
1730    {
1731        let text = node.get("text").and_then(Value::as_str).unwrap_or("");
1732        return Some(text.to_string());
1733    }
1734    if let Some(children) = node.get("children").and_then(Value::as_array) {
1735        for child in children {
1736            if let Some(found) = find_text_by_attr_id(child, id) {
1737                return Some(found);
1738            }
1739        }
1740    }
1741    None
1742}
1743
1744// ── Assertion Helpers ────────────────────────────────────────────────────────
1745
1746/// Assert that a JSON value at the given pointer equals the expected value.
1747///
1748/// # Panics
1749///
1750/// Panics if the value at `pointer` is missing or does not equal `expected`.
1751///
1752/// # Examples
1753///
1754/// ```
1755/// use serde_json::json;
1756///
1757/// let state = json!({"visible": true, "title": "My App"});
1758/// victauri_test::assert_json_eq(&state, "/visible", &json!(true));
1759/// victauri_test::assert_json_eq(&state, "/title", &json!("My App"));
1760/// ```
1761pub fn assert_json_eq(value: &Value, pointer: &str, expected: &Value) {
1762    let actual = value.pointer(pointer);
1763    assert!(
1764        actual == Some(expected),
1765        "JSON pointer {pointer}: expected {expected}, got {}",
1766        actual.map_or("missing".to_string(), std::string::ToString::to_string)
1767    );
1768}
1769
1770/// Assert that a JSON value at the given pointer is truthy (not null/false/0/"").
1771///
1772/// # Panics
1773///
1774/// Panics if the value at `pointer` is missing, null, false, zero, or empty.
1775///
1776/// # Examples
1777///
1778/// ```
1779/// use serde_json::json;
1780///
1781/// let value = json!({"active": true, "name": "test", "count": 42});
1782/// victauri_test::assert_json_truthy(&value, "/active");
1783/// victauri_test::assert_json_truthy(&value, "/name");
1784/// victauri_test::assert_json_truthy(&value, "/count");
1785/// ```
1786pub fn assert_json_truthy(value: &Value, pointer: &str) {
1787    let actual = value.pointer(pointer);
1788    let is_truthy = match actual {
1789        None | Some(Value::Null) => false,
1790        Some(Value::Bool(b)) => *b,
1791        Some(Value::Number(n)) => n.as_f64().unwrap_or(0.0) != 0.0,
1792        Some(Value::String(s)) => !s.is_empty(),
1793        Some(Value::Array(a)) => !a.is_empty(),
1794        Some(Value::Object(_)) => true,
1795    };
1796    assert!(
1797        is_truthy,
1798        "JSON pointer {pointer}: expected truthy, got {}",
1799        actual.map_or("missing".to_string(), std::string::ToString::to_string)
1800    );
1801}
1802
1803/// Assert that an accessibility audit has zero violations.
1804///
1805/// # Panics
1806///
1807/// Panics if the audit contains any violations.
1808///
1809/// # Examples
1810///
1811/// ```
1812/// use serde_json::json;
1813///
1814/// let audit = json!({"summary": {"violations": 0, "passes": 12}});
1815/// victauri_test::assert_no_a11y_violations(&audit);
1816/// ```
1817pub fn assert_no_a11y_violations(audit: &Value) {
1818    let violations = audit
1819        .pointer("/summary/violations")
1820        .and_then(serde_json::Value::as_u64)
1821        .unwrap_or(u64::MAX);
1822    assert_eq!(
1823        violations, 0,
1824        "expected 0 accessibility violations, got {violations}"
1825    );
1826}
1827
1828/// Assert that all performance metrics are within budget.
1829///
1830/// # Panics
1831///
1832/// Panics if load time exceeds `max_load_ms` or heap usage exceeds `max_heap_mb`.
1833///
1834/// # Examples
1835///
1836/// ```
1837/// use serde_json::json;
1838///
1839/// let metrics = json!({
1840///     "navigation": {"load_event_ms": 450.0},
1841///     "js_heap": {"used_mb": 12.5}
1842/// });
1843/// victauri_test::assert_performance_budget(&metrics, 1000.0, 50.0);
1844/// ```
1845pub fn assert_performance_budget(metrics: &Value, max_load_ms: f64, max_heap_mb: f64) {
1846    if let Some(load) = metrics
1847        .pointer("/navigation/load_event_ms")
1848        .and_then(serde_json::Value::as_f64)
1849    {
1850        assert!(
1851            load <= max_load_ms,
1852            "load event took {load}ms, budget is {max_load_ms}ms"
1853        );
1854    }
1855
1856    if let Some(heap) = metrics
1857        .pointer("/js_heap/used_mb")
1858        .and_then(serde_json::Value::as_f64)
1859    {
1860        assert!(
1861            heap <= max_heap_mb,
1862            "JS heap is {heap}MB, budget is {max_heap_mb}MB"
1863        );
1864    }
1865}
1866
1867/// Assert that IPC integrity is healthy (no stale or errored calls).
1868///
1869/// # Panics
1870///
1871/// Panics if the integrity check reports an unhealthy state.
1872///
1873/// # Examples
1874///
1875/// ```
1876/// use serde_json::json;
1877///
1878/// let integrity = json!({"healthy": true, "stale_calls": 0, "error_calls": 0});
1879/// victauri_test::assert_ipc_healthy(&integrity);
1880/// ```
1881pub fn assert_ipc_healthy(integrity: &Value) {
1882    let healthy = integrity
1883        .get("healthy")
1884        .and_then(serde_json::Value::as_bool)
1885        .unwrap_or(false);
1886    assert!(
1887        healthy,
1888        "IPC integrity check failed: {}",
1889        serde_json::to_string_pretty(integrity).unwrap_or_default()
1890    );
1891}
1892
1893/// Assert that state verification passed with no divergences.
1894///
1895/// # Panics
1896///
1897/// Panics if the verification reports any divergences.
1898///
1899/// # Examples
1900///
1901/// ```
1902/// use serde_json::json;
1903///
1904/// let verification = json!({"passed": true, "divergences": []});
1905/// victauri_test::assert_state_matches(&verification);
1906/// ```
1907pub fn assert_state_matches(verification: &Value) {
1908    let passed = verification
1909        .get("passed")
1910        .and_then(serde_json::Value::as_bool)
1911        .unwrap_or(false);
1912    assert!(
1913        passed,
1914        "state verification failed: {}",
1915        serde_json::to_string_pretty(verification).unwrap_or_default()
1916    );
1917}