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