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