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