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