Skip to main content

victauri_test/
client.rs

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