Skip to main content

victauri_test/
client.rs

1use serde::Deserialize;
2use serde_json::{Value, json};
3
4use crate::assertions::VerifyBuilder;
5use crate::discovery::{scan_discovery_dirs_for_port, scan_discovery_dirs_for_token};
6use crate::error::TestError;
7use crate::visual::{VisualDiff, VisualOptions};
8
9// ── Typed Response Structs (Phase 4E) ───────────────────────────────────────
10
11/// Structured plugin information returned by [`VictauriClient::plugin_info`].
12#[derive(Debug, Clone, Deserialize)]
13pub struct PluginInfo {
14    /// Plugin version string (e.g. `"0.2.0"`).
15    pub version: String,
16    /// Seconds since the plugin was initialized.
17    pub uptime_secs: f64,
18    /// Number of tools registered by the plugin.
19    pub tool_count: usize,
20    /// Total number of tool invocations served.
21    pub tool_invocations: u64,
22}
23
24/// Structured process memory statistics returned by [`VictauriClient::memory_stats`].
25#[derive(Debug, Clone, Deserialize)]
26pub struct MemoryStats {
27    /// Current working set size in bytes.
28    pub working_set_bytes: u64,
29    /// Peak working set size in bytes, if available.
30    pub peak_working_set_bytes: Option<u64>,
31}
32
33// ── WaitForBuilder (Phase 4B) ───────────────────────────────────────────────
34
35/// Builder for configuring and executing a `wait_for` condition.
36///
37/// Created via [`VictauriClient::wait`]. Provides a fluent API as an
38/// alternative to the positional-argument [`VictauriClient::wait_for`] method.
39///
40/// # Examples
41///
42/// ```rust,ignore
43/// // Wait for text to appear with custom timeout
44/// client.wait("text")
45///     .value("Hello, World!")
46///     .timeout_ms(15_000)
47///     .run()
48///     .await
49///     .unwrap();
50///
51/// // Wait for network idle with fast polling
52/// client.wait("network_idle")
53///     .poll_ms(50)
54///     .run()
55///     .await
56///     .unwrap();
57/// ```
58pub struct WaitForBuilder<'a> {
59    client: &'a mut VictauriClient,
60    condition: String,
61    value: Option<String>,
62    timeout_ms: u64,
63    poll_ms: u64,
64}
65
66impl<'a> WaitForBuilder<'a> {
67    /// Set the value to match against (e.g. the text string for `"text"` condition).
68    #[must_use]
69    pub fn value(mut self, v: &str) -> Self {
70        self.value = Some(v.to_string());
71        self
72    }
73
74    /// Set the maximum time to wait in milliseconds (default: 10 000).
75    #[must_use]
76    pub fn timeout_ms(mut self, ms: u64) -> Self {
77        self.timeout_ms = ms;
78        self
79    }
80
81    /// Set the polling interval in milliseconds (default: 200).
82    #[must_use]
83    pub fn poll_ms(mut self, ms: u64) -> Self {
84        self.poll_ms = ms;
85        self
86    }
87
88    /// Execute the wait, polling until the condition is met or the timeout expires.
89    ///
90    /// # Errors
91    ///
92    /// Returns errors from [`VictauriClient::call_tool`].
93    pub async fn run(self) -> Result<Value, TestError> {
94        self.client
95            .wait_for(
96                &self.condition,
97                self.value.as_deref(),
98                Some(self.timeout_ms),
99                Some(self.poll_ms),
100            )
101            .await
102    }
103}
104
105/// Typed HTTP client for the Victauri MCP server.
106///
107/// Manages session lifecycle (initialize → tool calls → cleanup) and provides
108/// convenient methods for common test operations.
109pub struct VictauriClient {
110    http: reqwest::Client,
111    base_url: String,
112    host: String,
113    port: u16,
114    session_id: String,
115    next_id: u64,
116    auth_token: Option<String>,
117}
118
119impl VictauriClient {
120    /// Connect to a Victauri MCP server on the given port.
121    /// Sends `initialize` and `notifications/initialized` automatically.
122    ///
123    /// # Errors
124    ///
125    /// Returns [`TestError::Connection`] if the server is unreachable or
126    /// returns a non-success status. Returns [`TestError::Request`] on
127    /// HTTP transport failures.
128    pub async fn connect(port: u16) -> Result<Self, TestError> {
129        Self::connect_with_token(port, None).await
130    }
131
132    /// Connect with an optional Bearer auth token.
133    ///
134    /// Retries up to 3 times with exponential backoff on 429 (rate limited).
135    ///
136    /// # Errors
137    ///
138    /// Returns [`TestError::Connection`] if the server is unreachable or
139    /// returns a non-success status. Returns [`TestError::Request`] on
140    /// HTTP transport failures.
141    pub async fn connect_with_token(port: u16, token: Option<&str>) -> Result<Self, TestError> {
142        let host = "127.0.0.1";
143        let base_url = format!("http://{host}:{port}");
144        let http = reqwest::Client::builder()
145            .timeout(std::time::Duration::from_secs(60))
146            .connect_timeout(std::time::Duration::from_secs(10))
147            .build()
148            .map_err(|e| TestError::Connection {
149                host: host.to_string(),
150                port,
151                reason: e.to_string(),
152            })?;
153
154        let init_body = json!({
155            "jsonrpc": "2.0",
156            "id": 1,
157            "method": "initialize",
158            "params": {
159                "protocolVersion": "2025-03-26",
160                "capabilities": {},
161                "clientInfo": {"name": "victauri-test", "version": env!("CARGO_PKG_VERSION")}
162            }
163        });
164
165        let mut init_resp = None;
166        for attempt in 0..4 {
167            let mut req = http
168                .post(format!("{base_url}/mcp"))
169                .header("Content-Type", "application/json")
170                .header("Accept", "application/json, text/event-stream")
171                .json(&init_body);
172            if let Some(t) = token {
173                req = req.header("Authorization", format!("Bearer {t}"));
174            }
175
176            let resp = req.send().await.map_err(|e| TestError::Connection {
177                host: host.to_string(),
178                port,
179                reason: e.to_string(),
180            })?;
181
182            if resp.status() == 429 && attempt < 3 {
183                let delay = std::time::Duration::from_millis(100 * (1 << attempt));
184                tokio::time::sleep(delay).await;
185                continue;
186            }
187
188            init_resp = Some(resp);
189            break;
190        }
191
192        let init_resp = init_resp.ok_or_else(|| TestError::Connection {
193            host: host.to_string(),
194            port,
195            reason: "initialize failed after retries".into(),
196        })?;
197
198        if !init_resp.status().is_success() {
199            return Err(TestError::Connection {
200                host: host.to_string(),
201                port,
202                reason: format!("initialize returned {}", init_resp.status()),
203            });
204        }
205
206        let session_id = init_resp
207            .headers()
208            .get("mcp-session-id")
209            .and_then(|v| v.to_str().ok())
210            .ok_or_else(|| TestError::Connection {
211                host: host.to_string(),
212                port,
213                reason: "no mcp-session-id header".into(),
214            })?
215            .to_string();
216
217        let mut notify_req = http
218            .post(format!("{base_url}/mcp"))
219            .header("Content-Type", "application/json")
220            .header("mcp-session-id", &session_id)
221            .json(&json!({
222                "jsonrpc": "2.0",
223                "method": "notifications/initialized"
224            }));
225
226        if let Some(t) = token {
227            notify_req = notify_req.header("Authorization", format!("Bearer {t}"));
228        }
229
230        notify_req.send().await?;
231
232        Ok(Self {
233            http,
234            base_url,
235            host: host.to_string(),
236            port,
237            session_id,
238            next_id: 10,
239            auth_token: token.map(String::from),
240        })
241    }
242
243    /// Auto-discover a running Victauri server via temp files.
244    ///
245    /// Discovery priority:
246    /// 1. `VICTAURI_PORT` / `VICTAURI_AUTH_TOKEN` env vars (explicit override)
247    /// 2. Per-process discovery directory: `<temp>/victauri/<pid>/port`
248    /// 3. Legacy single-file: `<temp>/victauri.port` (v0.1.x compat)
249    /// 4. Default: port 7373, no auth
250    ///
251    /// # Errors
252    ///
253    /// Returns [`TestError::Connection`] if the server is unreachable or
254    /// returns a non-success status. Returns [`TestError::Request`] on
255    /// HTTP transport failures.
256    pub async fn discover() -> Result<Self, TestError> {
257        let port = Self::discover_port();
258        let token = Self::discover_token();
259        Self::connect_with_token(port, token.as_deref()).await
260    }
261
262    fn discover_port() -> u16 {
263        if let Ok(p) = std::env::var("VICTAURI_PORT")
264            && let Ok(port) = p.parse::<u16>()
265        {
266            return port;
267        }
268        // Scan per-process discovery directories for live servers
269        if let Some(port) = scan_discovery_dirs_for_port() {
270            return port;
271        }
272        // Fall back to legacy single-file discovery
273        let path = std::env::temp_dir().join("victauri.port");
274        if let Ok(contents) = std::fs::read_to_string(&path)
275            && let Ok(port) = contents.trim().parse::<u16>()
276        {
277            return port;
278        }
279        7373
280    }
281
282    fn discover_token() -> Option<String> {
283        if let Ok(token) = std::env::var("VICTAURI_AUTH_TOKEN") {
284            return Some(token);
285        }
286        // Scan per-process discovery directories
287        if let Some(token) = scan_discovery_dirs_for_token() {
288            return Some(token);
289        }
290        // Legacy fallback
291        let path = std::env::temp_dir().join("victauri.token");
292        let token = std::fs::read_to_string(&path).ok()?;
293        let token = token.trim().to_string();
294        if token.is_empty() { None } else { Some(token) }
295    }
296
297    /// Check whether the server is still reachable.
298    ///
299    /// Sends a GET to `/health` and returns `true` if the response is 200 OK.
300    #[must_use]
301    pub async fn is_alive(&self) -> bool {
302        self.http
303            .get(format!("{}/health", self.base_url))
304            .send()
305            .await
306            .is_ok_and(|r| r.status().is_success())
307    }
308
309    /// Re-establish an MCP session after the app restarts.
310    ///
311    /// Polls `/health` up to `max_wait` and then re-runs the
312    /// initialize/initialized handshake. The returned client has a fresh
313    /// session ID; the old client should be dropped.
314    ///
315    /// # Errors
316    ///
317    /// Returns [`TestError::Connection`] if the server doesn't come back
318    /// within `max_wait`.
319    pub async fn reconnect(&self, max_wait: std::time::Duration) -> Result<Self, TestError> {
320        let start = std::time::Instant::now();
321        loop {
322            if self.is_alive().await {
323                return Self::connect_with_token(self.port, self.auth_token.as_deref()).await;
324            }
325            if start.elapsed() > max_wait {
326                return Err(TestError::Connection {
327                    host: self.host.clone(),
328                    port: self.port,
329                    reason: format!("server did not recover within {}s", max_wait.as_secs()),
330                });
331            }
332            tokio::time::sleep(std::time::Duration::from_millis(250)).await;
333        }
334    }
335
336    /// Call an MCP tool by name and return the result content as JSON.
337    ///
338    /// Retries up to 3 times with exponential backoff on 429 (rate limited).
339    ///
340    /// # Errors
341    ///
342    /// Returns [`TestError::Connection`] if the request fails after retries.
343    /// Returns [`TestError::Request`] on HTTP transport errors.
344    /// Returns [`TestError::Mcp`] if the server returns a JSON-RPC error.
345    pub async fn call_tool(&mut self, name: &str, arguments: Value) -> Result<Value, TestError> {
346        let id = self.next_id;
347        self.next_id += 1;
348
349        let call_body = json!({
350            "jsonrpc": "2.0",
351            "id": id,
352            "method": "tools/call",
353            "params": {
354                "name": name,
355                "arguments": arguments
356            }
357        });
358
359        let mut resp = None;
360        for attempt in 0..4 {
361            let mut req = self
362                .http
363                .post(format!("{}/mcp", self.base_url))
364                .header("Content-Type", "application/json")
365                .header("Accept", "application/json, text/event-stream")
366                .header("mcp-session-id", &self.session_id)
367                .json(&call_body);
368            if let Some(ref t) = self.auth_token {
369                req = req.header("Authorization", format!("Bearer {t}"));
370            }
371            let r = req.send().await?;
372
373            if r.status() == 429 && attempt < 3 {
374                let delay = std::time::Duration::from_millis(100 * (1 << attempt));
375                tokio::time::sleep(delay).await;
376                continue;
377            }
378            resp = Some(r);
379            break;
380        }
381
382        let resp = resp.ok_or_else(|| TestError::Connection {
383            host: self.host.clone(),
384            port: self.port,
385            reason: "tool call failed after retries".into(),
386        })?;
387        let body = Self::parse_response(resp, &self.host, self.port).await?;
388
389        if let Some(error) = body.get("error") {
390            return Err(TestError::Mcp {
391                code: error["code"].as_i64().unwrap_or(-1),
392                message: error["message"].as_str().map_or_else(
393                    || {
394                        format!(
395                            "unknown error (raw: {})",
396                            serde_json::to_string(error).unwrap_or_else(|_| "<unparseable>".into())
397                        )
398                    },
399                    String::from,
400                ),
401            });
402        }
403
404        let content = &body["result"]["content"];
405        if let Some(arr) = content.as_array()
406            && let Some(first) = arr.first()
407            && let Some(text) = first["text"].as_str()
408        {
409            if let Ok(parsed) = serde_json::from_str::<Value>(text) {
410                return Ok(parsed);
411            }
412            return Ok(Value::String(text.to_string()));
413        }
414
415        Ok(body)
416    }
417
418    /// Parse a response that may be JSON or SSE (text/event-stream).
419    ///
420    /// rmcp's Streamable HTTP transport always returns SSE format with the
421    /// JSON-RPC payload in a `data:` line. This method handles both formats.
422    async fn parse_response(
423        resp: reqwest::Response,
424        host: &str,
425        port: u16,
426    ) -> Result<Value, TestError> {
427        let content_type = resp
428            .headers()
429            .get("content-type")
430            .and_then(|v| v.to_str().ok())
431            .unwrap_or("")
432            .to_string();
433
434        let text = resp.text().await?;
435
436        if content_type.contains("text/event-stream") {
437            for line in text.lines() {
438                let data = line
439                    .strip_prefix("data: ")
440                    .or_else(|| line.strip_prefix("data:"));
441                let Some(data) = data else { continue };
442                let trimmed = data.trim();
443                if trimmed.is_empty() {
444                    continue;
445                }
446                if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
447                    return Ok(parsed);
448                }
449            }
450            Err(TestError::Connection {
451                host: host.to_string(),
452                port,
453                reason: "SSE stream contained no JSON-RPC data".into(),
454            })
455        } else {
456            serde_json::from_str(&text).map_err(|e| TestError::Connection {
457                host: host.to_string(),
458                port,
459                reason: format!(
460                    "JSON parse error: {e}, body: {}",
461                    &text[..200.min(text.len())]
462                ),
463            })
464        }
465    }
466
467    /// Evaluate JavaScript in the webview and return the result.
468    ///
469    /// # Errors
470    ///
471    /// Returns errors from [`VictauriClient::call_tool`].
472    pub async fn eval_js(&mut self, code: &str) -> Result<Value, TestError> {
473        self.call_tool("eval_js", json!({"code": code})).await
474    }
475
476    /// Get a DOM snapshot of the current page.
477    ///
478    /// # Errors
479    ///
480    /// Returns errors from [`VictauriClient::call_tool`].
481    pub async fn dom_snapshot(&mut self) -> Result<Value, TestError> {
482        self.call_tool("dom_snapshot", json!({})).await
483    }
484
485    /// Get a DOM snapshot of a specific webview by label.
486    ///
487    /// # Errors
488    ///
489    /// Returns errors from [`VictauriClient::call_tool`].
490    pub async fn dom_snapshot_for(&mut self, label: &str) -> Result<Value, TestError> {
491        self.call_tool("dom_snapshot", json!({"webview_label": label}))
492            .await
493    }
494
495    /// Capture a screenshot of a specific webview by label.
496    ///
497    /// # Errors
498    ///
499    /// Returns errors from [`VictauriClient::call_tool`].
500    pub async fn screenshot_for(&mut self, label: &str) -> Result<Value, TestError> {
501        self.call_tool("screenshot", json!({"window_label": label}))
502            .await
503    }
504
505    /// Click an element by ref handle ID.
506    ///
507    /// # Errors
508    ///
509    /// Returns errors from [`VictauriClient::call_tool`].
510    pub async fn click(&mut self, ref_id: &str) -> Result<Value, TestError> {
511        self.call_tool("interact", json!({"action": "click", "ref_id": ref_id}))
512            .await
513    }
514
515    /// Fill an input element with a value.
516    ///
517    /// # Errors
518    ///
519    /// Returns errors from [`VictauriClient::call_tool`].
520    pub async fn fill(&mut self, ref_id: &str, value: &str) -> Result<Value, TestError> {
521        self.call_tool(
522            "input",
523            json!({"action": "fill", "ref_id": ref_id, "value": value}),
524        )
525        .await
526    }
527
528    /// Type text into an element character by character.
529    ///
530    /// # Errors
531    ///
532    /// Returns errors from [`VictauriClient::call_tool`].
533    pub async fn type_text(&mut self, ref_id: &str, text: &str) -> Result<Value, TestError> {
534        self.call_tool(
535            "input",
536            json!({"action": "type_text", "ref_id": ref_id, "text": text}),
537        )
538        .await
539    }
540
541    /// List all window labels.
542    ///
543    /// # Errors
544    ///
545    /// Returns errors from [`VictauriClient::call_tool`].
546    pub async fn list_windows(&mut self) -> Result<Value, TestError> {
547        self.call_tool("window", json!({"action": "list"})).await
548    }
549
550    /// Get the state of a specific window (or all windows).
551    ///
552    /// # Errors
553    ///
554    /// Returns errors from [`VictauriClient::call_tool`].
555    pub async fn get_window_state(&mut self, label: Option<&str>) -> Result<Value, TestError> {
556        let mut args = json!({"action": "get_state"});
557        if let Some(l) = label {
558            args["label"] = json!(l);
559        }
560        self.call_tool("window", args).await
561    }
562
563    /// Take a screenshot and return base64-encoded PNG.
564    ///
565    /// # Errors
566    ///
567    /// Returns errors from [`VictauriClient::call_tool`].
568    pub async fn screenshot(&mut self) -> Result<Value, TestError> {
569        self.call_tool("screenshot", json!({})).await
570    }
571
572    /// Take a screenshot and compare it against a stored baseline.
573    ///
574    /// Captures the current window, extracts the base64 PNG data, and passes
575    /// it to [`visual::compare_screenshot`](crate::visual::compare_screenshot).
576    /// On first run the screenshot is saved as the new baseline.
577    ///
578    /// # Errors
579    ///
580    /// Returns [`TestError::VisualRegression`] if the diff exceeds the
581    /// threshold, or [`TestError::Other`] if the screenshot result does not
582    /// contain recognizable image data.
583    pub async fn screenshot_visual(
584        &mut self,
585        name: &str,
586        options: &VisualOptions,
587    ) -> Result<VisualDiff, TestError> {
588        let result = self.screenshot().await?;
589        let base64_data = extract_screenshot_base64(&result)?;
590        crate::visual::compare_screenshot(name, &base64_data, options)
591    }
592
593    /// Invoke a Tauri command by name with optional arguments.
594    ///
595    /// # Errors
596    ///
597    /// Returns errors from [`VictauriClient::call_tool`].
598    pub async fn invoke_command(
599        &mut self,
600        command: &str,
601        args: Option<Value>,
602    ) -> Result<Value, TestError> {
603        let mut params = json!({"command": command});
604        if let Some(a) = args {
605            params["args"] = a;
606        }
607        self.call_tool("invoke_command", params).await
608    }
609
610    /// Get the IPC call log.
611    ///
612    /// # Errors
613    ///
614    /// Returns errors from [`VictauriClient::call_tool`].
615    pub async fn get_ipc_log(&mut self, limit: Option<usize>) -> Result<Value, TestError> {
616        let mut args = json!({"action": "ipc"});
617        if let Some(n) = limit {
618            args["limit"] = json!(n);
619        }
620        self.call_tool("logs", args).await
621    }
622
623    /// Verify frontend state against backend state.
624    ///
625    /// # Errors
626    ///
627    /// Returns errors from [`VictauriClient::call_tool`].
628    pub async fn verify_state(
629        &mut self,
630        frontend_expr: &str,
631        backend_state: Value,
632    ) -> Result<Value, TestError> {
633        self.call_tool(
634            "verify_state",
635            json!({
636                "frontend_expr": frontend_expr,
637                "backend_state": backend_state,
638            }),
639        )
640        .await
641    }
642
643    /// Detect ghost commands (registered but never called, or called but not registered).
644    ///
645    /// # Errors
646    ///
647    /// Returns errors from [`VictauriClient::call_tool`].
648    pub async fn detect_ghost_commands(&mut self) -> Result<Value, TestError> {
649        self.call_tool("detect_ghost_commands", json!({})).await
650    }
651
652    /// Check IPC call health (pending, stale, errored).
653    ///
654    /// # Errors
655    ///
656    /// Returns errors from [`VictauriClient::call_tool`].
657    pub async fn check_ipc_integrity(&mut self) -> Result<Value, TestError> {
658        self.call_tool("check_ipc_integrity", json!({})).await
659    }
660
661    /// Run a semantic assertion against a JS expression.
662    ///
663    /// # Errors
664    ///
665    /// Returns errors from [`VictauriClient::call_tool`].
666    pub async fn assert_semantic(
667        &mut self,
668        expression: &str,
669        label: &str,
670        condition: &str,
671        expected: Value,
672    ) -> Result<Value, TestError> {
673        self.call_tool(
674            "assert_semantic",
675            json!({
676                "expression": expression,
677                "label": label,
678                "condition": condition,
679                "expected": expected,
680            }),
681        )
682        .await
683    }
684
685    /// Run an accessibility audit.
686    ///
687    /// # Errors
688    ///
689    /// Returns errors from [`VictauriClient::call_tool`].
690    pub async fn audit_accessibility(&mut self) -> Result<Value, TestError> {
691        self.call_tool("inspect", json!({"action": "audit_accessibility"}))
692            .await
693    }
694
695    /// Get performance metrics (timing, heap, resources).
696    ///
697    /// # Errors
698    ///
699    /// Returns errors from [`VictauriClient::call_tool`].
700    pub async fn get_performance_metrics(&mut self) -> Result<Value, TestError> {
701        self.call_tool("inspect", json!({"action": "get_performance"}))
702            .await
703    }
704
705    /// Get the command registry.
706    ///
707    /// # Errors
708    ///
709    /// Returns errors from [`VictauriClient::call_tool`].
710    pub async fn get_registry(&mut self) -> Result<Value, TestError> {
711        self.call_tool("get_registry", json!({})).await
712    }
713
714    /// Get process memory statistics.
715    ///
716    /// # Errors
717    ///
718    /// Returns errors from [`VictauriClient::call_tool`].
719    pub async fn get_memory_stats(&mut self) -> Result<Value, TestError> {
720        self.call_tool("get_memory_stats", json!({})).await
721    }
722
723    /// Read plugin info (version, uptime, tool count).
724    ///
725    /// # Errors
726    ///
727    /// Returns errors from [`VictauriClient::call_tool`].
728    pub async fn get_plugin_info(&mut self) -> Result<Value, TestError> {
729        self.call_tool("get_plugin_info", json!({})).await
730    }
731
732    /// Wait for a condition to be met, polling at an interval.
733    ///
734    /// Conditions: `text`, `text_gone`, `selector`, `selector_gone`, `url`,
735    /// `ipc_idle`, `network_idle`.
736    ///
737    /// # Errors
738    ///
739    /// Returns errors from [`VictauriClient::call_tool`].
740    pub async fn wait_for(
741        &mut self,
742        condition: &str,
743        value: Option<&str>,
744        timeout_ms: Option<u64>,
745        poll_ms: Option<u64>,
746    ) -> Result<Value, TestError> {
747        let mut args = json!({"condition": condition});
748        if let Some(v) = value {
749            args["value"] = json!(v);
750        }
751        if let Some(t) = timeout_ms {
752            args["timeout_ms"] = json!(t);
753        }
754        if let Some(p) = poll_ms {
755            args["poll_ms"] = json!(p);
756        }
757        self.call_tool("wait_for", args).await
758    }
759
760    /// Start a time-travel recording session.
761    ///
762    /// # Errors
763    ///
764    /// Returns errors from [`VictauriClient::call_tool`].
765    pub async fn start_recording(&mut self, session_id: Option<&str>) -> Result<Value, TestError> {
766        let mut args = json!({"action": "start"});
767        if let Some(id) = session_id {
768            args["session_id"] = json!(id);
769        }
770        self.call_tool("recording", args).await
771    }
772
773    /// Stop the recording and return the session.
774    ///
775    /// # Errors
776    ///
777    /// Returns errors from [`VictauriClient::call_tool`].
778    pub async fn stop_recording(&mut self) -> Result<Value, TestError> {
779        self.call_tool("recording", json!({"action": "stop"})).await
780    }
781
782    /// Export the current recording session as JSON.
783    ///
784    /// # Errors
785    ///
786    /// Returns errors from [`VictauriClient::call_tool`].
787    pub async fn export_session(&mut self) -> Result<Value, TestError> {
788        self.call_tool("recording", json!({"action": "export"}))
789            .await
790    }
791
792    /// Search for elements by various criteria without a full snapshot.
793    ///
794    /// # Errors
795    ///
796    /// Returns errors from [`VictauriClient::call_tool`].
797    pub async fn find_elements(&mut self, query: Value) -> Result<Value, TestError> {
798        self.call_tool("find_elements", query).await
799    }
800
801    /// Double-click an element by ref handle ID.
802    ///
803    /// # Errors
804    ///
805    /// Returns errors from [`VictauriClient::call_tool`].
806    pub async fn double_click(&mut self, ref_id: &str) -> Result<Value, TestError> {
807        self.call_tool(
808            "interact",
809            json!({"action": "double_click", "ref_id": ref_id}),
810        )
811        .await
812    }
813
814    /// Hover over an element by ref handle.
815    ///
816    /// # Errors
817    ///
818    /// Returns errors from [`VictauriClient::call_tool`].
819    pub async fn hover(&mut self, ref_id: &str) -> Result<Value, TestError> {
820        self.call_tool("interact", json!({"action": "hover", "ref_id": ref_id}))
821            .await
822    }
823
824    /// Focus an element by ref handle.
825    ///
826    /// # Errors
827    ///
828    /// Returns errors from [`VictauriClient::call_tool`].
829    pub async fn focus(&mut self, ref_id: &str) -> Result<Value, TestError> {
830        self.call_tool("interact", json!({"action": "focus", "ref_id": ref_id}))
831            .await
832    }
833
834    /// Press a keyboard key.
835    ///
836    /// # Errors
837    ///
838    /// Returns errors from [`VictauriClient::call_tool`].
839    pub async fn press_key(&mut self, key: &str) -> Result<Value, TestError> {
840        self.call_tool("input", json!({"action": "press_key", "key": key}))
841            .await
842    }
843
844    /// Navigate to a URL.
845    ///
846    /// # Errors
847    ///
848    /// Returns errors from [`VictauriClient::call_tool`].
849    pub async fn navigate(&mut self, url: &str) -> Result<Value, TestError> {
850        self.call_tool("navigate", json!({"action": "go_to", "url": url}))
851            .await
852    }
853
854    /// Get logs by type (console, network, ipc, navigation, dialogs).
855    ///
856    /// # Errors
857    ///
858    /// Returns errors from [`VictauriClient::call_tool`].
859    pub async fn logs(&mut self, action: &str, limit: Option<usize>) -> Result<Value, TestError> {
860        self.call_tool("logs", json!({"action": action, "limit": limit}))
861            .await
862    }
863
864    /// Scroll an element into view by ref handle.
865    ///
866    /// # Errors
867    ///
868    /// Returns errors from [`VictauriClient::call_tool`].
869    pub async fn scroll_to(&mut self, ref_id: &str) -> Result<Value, TestError> {
870        self.call_tool(
871            "interact",
872            json!({"action": "scroll_into_view", "ref_id": ref_id}),
873        )
874        .await
875    }
876
877    /// Select option(s) in a `<select>` element.
878    ///
879    /// # Errors
880    ///
881    /// Returns errors from [`VictauriClient::call_tool`].
882    pub async fn select_option(
883        &mut self,
884        ref_id: &str,
885        values: &[&str],
886    ) -> Result<Value, TestError> {
887        self.call_tool(
888            "interact",
889            json!({"action": "select_option", "ref_id": ref_id, "values": values}),
890        )
891        .await
892    }
893
894    /// Get the server base URL.
895    #[must_use]
896    pub fn base_url(&self) -> &str {
897        &self.base_url
898    }
899
900    /// Get the host the client is connected to.
901    #[must_use]
902    pub fn host(&self) -> &str {
903        &self.host
904    }
905
906    /// Get the port the client is connected to.
907    #[must_use]
908    pub fn port(&self) -> u16 {
909        self.port
910    }
911
912    /// Get the MCP session ID.
913    #[must_use]
914    pub fn session_id(&self) -> &str {
915        &self.session_id
916    }
917
918    pub(crate) fn http_client(&self) -> &reqwest::Client {
919        &self.http
920    }
921
922    // ── IPC Log Helpers ───────────────────────────────────────────────────────
923
924    /// Get IPC calls filtered to a specific command.
925    ///
926    /// Returns a Vec of all IPC log entries matching the given command name.
927    ///
928    /// # Errors
929    ///
930    /// Returns errors from [`VictauriClient::call_tool`].
931    #[deprecated(since = "0.2.0", note = "renamed to get_ipc_calls_for")]
932    pub async fn get_ipc_calls(&mut self, command: &str) -> Result<Vec<Value>, TestError> {
933        let log = self.get_ipc_log(None).await?;
934        let entries = if let Some(arr) = log.as_array() {
935            arr.clone()
936        } else if let Some(entries) = log.get("entries").and_then(Value::as_array) {
937            entries.clone()
938        } else {
939            return Ok(Vec::new());
940        };
941        Ok(entries
942            .into_iter()
943            .filter(|e| {
944                e.get("command")
945                    .and_then(Value::as_str)
946                    .is_some_and(|c| c == command)
947            })
948            .collect())
949    }
950
951    /// Get IPC calls made since a previous checkpoint.
952    ///
953    /// # Errors
954    ///
955    /// Returns errors from [`VictauriClient::call_tool`].
956    #[deprecated(since = "0.2.0", note = "renamed to get_ipc_calls_since")]
957    pub async fn ipc_calls_since(&mut self, checkpoint: usize) -> Result<Vec<Value>, TestError> {
958        let log = self.get_ipc_log(None).await?;
959        let entries = if let Some(arr) = log.as_array() {
960            arr.clone()
961        } else if let Some(entries) = log.get("entries").and_then(Value::as_array) {
962            entries.clone()
963        } else {
964            return Ok(Vec::new());
965        };
966        Ok(entries.into_iter().skip(checkpoint).collect())
967    }
968
969    /// Filter the IPC log for calls to a specific command.
970    ///
971    /// # Errors
972    ///
973    /// Returns errors from [`VictauriClient::call_tool`].
974    pub async fn get_ipc_calls_for(&mut self, command: &str) -> Result<Vec<Value>, TestError> {
975        #[allow(deprecated)]
976        self.get_ipc_calls(command).await
977    }
978
979    /// Get IPC calls made since a previous checkpoint.
980    ///
981    /// # Errors
982    ///
983    /// Returns errors from [`VictauriClient::call_tool`].
984    pub async fn get_ipc_calls_since(
985        &mut self,
986        checkpoint: usize,
987    ) -> Result<Vec<Value>, TestError> {
988        #[allow(deprecated)]
989        self.ipc_calls_since(checkpoint).await
990    }
991
992    // ── Builder-Style Wait (Phase 4B) ──────────────────────────────────────────
993
994    /// Start a builder-style wait for a condition.
995    ///
996    /// This is a fluent alternative to [`VictauriClient::wait_for`] that avoids
997    /// positional `Option` arguments.
998    ///
999    /// # Examples
1000    ///
1001    /// ```rust,ignore
1002    /// client.wait("text")
1003    ///     .value("Welcome")
1004    ///     .timeout_ms(5000)
1005    ///     .run()
1006    ///     .await
1007    ///     .unwrap();
1008    /// ```
1009    pub fn wait(&mut self, condition: &str) -> WaitForBuilder<'_> {
1010        WaitForBuilder {
1011            client: self,
1012            condition: condition.to_string(),
1013            value: None,
1014            timeout_ms: 10_000,
1015            poll_ms: 200,
1016        }
1017    }
1018
1019    // ── Deprecated Aliases (Phase 4C) ────────────────────────────────────────
1020
1021    /// Snapshot the current IPC log length, for use with `ipc_calls_since`.
1022    ///
1023    /// Prefer [`VictauriClient::create_ipc_checkpoint`] — this alias exists
1024    /// for backwards compatibility.
1025    ///
1026    /// # Errors
1027    ///
1028    /// Returns errors from [`VictauriClient::call_tool`].
1029    #[deprecated(since = "0.2.0", note = "renamed to create_ipc_checkpoint")]
1030    pub async fn ipc_checkpoint(&mut self) -> Result<usize, TestError> {
1031        self.create_ipc_checkpoint().await
1032    }
1033
1034    /// Snapshot the current IPC log length, for use with `ipc_calls_since`.
1035    ///
1036    /// Returns the number of IPC calls recorded so far. Pass this value to
1037    /// [`VictauriClient::ipc_calls_since`] to get only the calls that occurred
1038    /// after the checkpoint.
1039    ///
1040    /// # Errors
1041    ///
1042    /// Returns errors from [`VictauriClient::call_tool`].
1043    pub async fn create_ipc_checkpoint(&mut self) -> Result<usize, TestError> {
1044        let log = self.get_ipc_log(None).await?;
1045        let len = if let Some(arr) = log.as_array() {
1046            arr.len()
1047        } else if let Some(entries) = log.get("entries").and_then(Value::as_array) {
1048            entries.len()
1049        } else {
1050            0
1051        };
1052        Ok(len)
1053    }
1054
1055    // ── Typed Response Methods (Phase 4E) ────────────────────────────────────
1056
1057    /// Read plugin info as a typed [`PluginInfo`] struct.
1058    ///
1059    /// This is a typed alternative to [`VictauriClient::get_plugin_info`] which
1060    /// returns raw JSON.
1061    ///
1062    /// # Errors
1063    ///
1064    /// Returns [`TestError::Other`] if the response cannot be deserialized.
1065    /// Returns other errors from [`VictauriClient::call_tool`].
1066    pub async fn plugin_info(&mut self) -> Result<PluginInfo, TestError> {
1067        let value = self.get_plugin_info().await?;
1068        serde_json::from_value(value)
1069            .map_err(|e| TestError::Other(format!("failed to deserialize PluginInfo: {e}")))
1070    }
1071
1072    /// Read process memory statistics as a typed [`MemoryStats`] struct.
1073    ///
1074    /// This is a typed alternative to [`VictauriClient::get_memory_stats`] which
1075    /// returns raw JSON.
1076    ///
1077    /// # Errors
1078    ///
1079    /// Returns [`TestError::Other`] if the response cannot be deserialized.
1080    /// Returns other errors from [`VictauriClient::call_tool`].
1081    pub async fn memory_stats(&mut self) -> Result<MemoryStats, TestError> {
1082        let value = self.get_memory_stats().await?;
1083        serde_json::from_value(value)
1084            .map_err(|e| TestError::Other(format!("failed to deserialize MemoryStats: {e}")))
1085    }
1086
1087    // ── Fluent Verification Builder ───────────────────────────────────────────
1088
1089    /// Start a fluent verification chain that checks multiple conditions at once.
1090    ///
1091    /// Unlike individual assertions that panic on failure, `verify()` collects
1092    /// all results and reports them together — making test failures more
1093    /// informative and reducing test reruns.
1094    ///
1095    /// # Examples
1096    ///
1097    /// ```rust,ignore
1098    /// let report = client.verify()
1099    ///     .has_text("Welcome")
1100    ///     .ipc_was_called("greet")
1101    ///     .no_console_errors()
1102    ///     .run()
1103    ///     .await
1104    ///     .unwrap();
1105    /// report.assert_all_passed();
1106    /// ```
1107    pub fn verify(&mut self) -> VerifyBuilder<'_> {
1108        VerifyBuilder::new(self)
1109    }
1110
1111    // ── High-Level Playwright-Style API ─────────────────────────────────────
1112
1113    /// Click the first element whose accessible text contains the given string.
1114    ///
1115    /// Takes a DOM snapshot, finds the element, and clicks it.
1116    ///
1117    /// # Errors
1118    ///
1119    /// Returns [`TestError::ElementNotFound`] if no matching element is found.
1120    /// Returns other errors from [`VictauriClient::call_tool`].
1121    pub async fn click_by_text(&mut self, text: &str) -> Result<Value, TestError> {
1122        let ref_id = self.find_ref_by_text(text).await?;
1123        self.click(&ref_id).await
1124    }
1125
1126    /// Click the element with the given HTML `id` attribute.
1127    ///
1128    /// # Errors
1129    ///
1130    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
1131    /// Returns other errors from [`VictauriClient::call_tool`].
1132    pub async fn click_by_id(&mut self, id: &str) -> Result<Value, TestError> {
1133        let ref_id = self.find_ref_by_id(id).await?;
1134        self.click(&ref_id).await
1135    }
1136
1137    /// Double-click the first element whose accessible text contains the given string.
1138    ///
1139    /// # Errors
1140    ///
1141    /// Returns [`TestError::ElementNotFound`] if no matching element is found.
1142    /// Returns other errors from [`VictauriClient::call_tool`].
1143    pub async fn double_click_by_text(&mut self, text: &str) -> Result<Value, TestError> {
1144        let ref_id = self.find_ref_by_text(text).await?;
1145        self.double_click(&ref_id).await
1146    }
1147
1148    /// Double-click the element with the given HTML `id` attribute.
1149    ///
1150    /// # Errors
1151    ///
1152    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
1153    /// Returns other errors from [`VictauriClient::call_tool`].
1154    pub async fn double_click_by_id(&mut self, id: &str) -> Result<Value, TestError> {
1155        let ref_id = self.find_ref_by_id(id).await?;
1156        self.double_click(&ref_id).await
1157    }
1158
1159    /// Double-click the first element matching a CSS selector.
1160    ///
1161    /// Resolves the selector via `find_elements`, then double-clicks the first match.
1162    ///
1163    /// # Errors
1164    ///
1165    /// Returns [`TestError::ElementNotFound`] if no element matches the selector.
1166    /// Returns other errors from [`VictauriClient::call_tool`].
1167    pub async fn double_click_by_selector(&mut self, selector: &str) -> Result<Value, TestError> {
1168        let ref_id = self.find_ref_by_selector(selector).await?;
1169        self.double_click(&ref_id).await
1170    }
1171
1172    /// Click the first element matching a CSS selector.
1173    ///
1174    /// Resolves the selector via `find_elements`, then clicks the first match.
1175    ///
1176    /// # Errors
1177    ///
1178    /// Returns [`TestError::ElementNotFound`] if no element matches the selector.
1179    /// Returns other errors from [`VictauriClient::call_tool`].
1180    pub async fn click_by_selector(&mut self, selector: &str) -> Result<Value, TestError> {
1181        let ref_id = self.find_ref_by_selector(selector).await?;
1182        self.click(&ref_id).await
1183    }
1184
1185    /// Fill an input identified by HTML `id` with the given value.
1186    ///
1187    /// # Errors
1188    ///
1189    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
1190    /// Returns other errors from [`VictauriClient::call_tool`].
1191    pub async fn fill_by_id(&mut self, id: &str, value: &str) -> Result<Value, TestError> {
1192        let ref_id = self.find_ref_by_id(id).await?;
1193        self.fill(&ref_id, value).await
1194    }
1195
1196    /// Fill an input whose accessible text contains the given string.
1197    ///
1198    /// # Errors
1199    ///
1200    /// Returns [`TestError::ElementNotFound`] if no matching element is found.
1201    /// Returns other errors from [`VictauriClient::call_tool`].
1202    pub async fn fill_by_text(&mut self, text: &str, value: &str) -> Result<Value, TestError> {
1203        let ref_id = self.find_ref_by_text(text).await?;
1204        self.fill(&ref_id, value).await
1205    }
1206
1207    /// Fill an input matching a CSS selector with the given value.
1208    ///
1209    /// Resolves the selector via `find_elements`, then fills the first match.
1210    ///
1211    /// # Errors
1212    ///
1213    /// Returns [`TestError::ElementNotFound`] if no element matches the selector.
1214    /// Returns other errors from [`VictauriClient::call_tool`].
1215    pub async fn fill_by_selector(
1216        &mut self,
1217        selector: &str,
1218        value: &str,
1219    ) -> Result<Value, TestError> {
1220        let ref_id = self.find_ref_by_selector(selector).await?;
1221        self.fill(&ref_id, value).await
1222    }
1223
1224    /// Type text into an input identified by HTML `id`, character by character.
1225    ///
1226    /// # Errors
1227    ///
1228    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
1229    /// Returns other errors from [`VictauriClient::call_tool`].
1230    pub async fn type_by_id(&mut self, id: &str, text: &str) -> Result<Value, TestError> {
1231        let ref_id = self.find_ref_by_id(id).await?;
1232        self.type_text(&ref_id, text).await
1233    }
1234
1235    /// Wait until the page contains the given text (polls DOM snapshots).
1236    ///
1237    /// Default timeout: 5000ms, poll interval: 200ms.
1238    ///
1239    /// # Errors
1240    ///
1241    /// Returns [`TestError::Timeout`] if the text doesn't appear within the timeout.
1242    /// Returns other errors from [`VictauriClient::call_tool`].
1243    pub async fn expect_text(&mut self, text: &str) -> Result<(), TestError> {
1244        self.expect_text_with_timeout(text, 5000).await
1245    }
1246
1247    /// Wait until the page contains the given text, with a custom timeout in ms.
1248    ///
1249    /// # Errors
1250    ///
1251    /// Returns [`TestError::Timeout`] if the text doesn't appear within the timeout.
1252    /// Returns other errors from [`VictauriClient::call_tool`].
1253    pub async fn expect_text_with_timeout(
1254        &mut self,
1255        text: &str,
1256        timeout_ms: u64,
1257    ) -> Result<(), TestError> {
1258        let result = self
1259            .wait_for("text", Some(text), Some(timeout_ms), Some(200))
1260            .await?;
1261        if result.get("ok").and_then(Value::as_bool) == Some(true) {
1262            Ok(())
1263        } else {
1264            Err(TestError::Timeout(format!(
1265                "text \"{text}\" did not appear within {timeout_ms}ms"
1266            )))
1267        }
1268    }
1269
1270    /// Wait until the page no longer contains the given text.
1271    ///
1272    /// Default timeout: 3000ms, poll interval: 200ms.
1273    ///
1274    /// # Errors
1275    ///
1276    /// Returns [`TestError::Timeout`] if the text is still present after the timeout.
1277    /// Returns other errors from [`VictauriClient::call_tool`].
1278    pub async fn expect_no_text(&mut self, text: &str) -> Result<(), TestError> {
1279        let result = self
1280            .wait_for("text_gone", Some(text), Some(3000), Some(200))
1281            .await?;
1282        if result.get("ok").and_then(Value::as_bool) == Some(true) {
1283            Ok(())
1284        } else {
1285            Err(TestError::Timeout(format!(
1286                "text \"{text}\" still present after 3000ms"
1287            )))
1288        }
1289    }
1290
1291    /// Select an option in a `<select>` element identified by HTML `id`.
1292    ///
1293    /// # Errors
1294    ///
1295    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
1296    /// Returns other errors from [`VictauriClient::call_tool`].
1297    pub async fn select_by_id(&mut self, id: &str, value: &str) -> Result<Value, TestError> {
1298        let ref_id = self.find_ref_by_id(id).await?;
1299        self.select_option(&ref_id, &[value]).await
1300    }
1301
1302    /// Select option(s) in a `<select>` element identified by HTML `id`.
1303    ///
1304    /// Accepts multiple values for multi-select elements.
1305    ///
1306    /// # Errors
1307    ///
1308    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
1309    /// Returns other errors from [`VictauriClient::call_tool`].
1310    pub async fn select_option_by_id(
1311        &mut self,
1312        id: &str,
1313        values: &[&str],
1314    ) -> Result<Value, TestError> {
1315        let ref_id = self.find_ref_by_id(id).await?;
1316        self.select_option(&ref_id, values).await
1317    }
1318
1319    /// Select option(s) in a `<select>` element whose accessible text contains
1320    /// the given string.
1321    ///
1322    /// # Errors
1323    ///
1324    /// Returns [`TestError::ElementNotFound`] if no matching element is found.
1325    /// Returns other errors from [`VictauriClient::call_tool`].
1326    pub async fn select_option_by_text(
1327        &mut self,
1328        text: &str,
1329        values: &[&str],
1330    ) -> Result<Value, TestError> {
1331        let ref_id = self.find_ref_by_text(text).await?;
1332        self.select_option(&ref_id, values).await
1333    }
1334
1335    /// Select option(s) in a `<select>` element matching a CSS selector.
1336    ///
1337    /// Resolves the selector via `find_elements`, then selects in the first match.
1338    ///
1339    /// # Errors
1340    ///
1341    /// Returns [`TestError::ElementNotFound`] if no element matches the selector.
1342    /// Returns other errors from [`VictauriClient::call_tool`].
1343    pub async fn select_option_by_selector(
1344        &mut self,
1345        selector: &str,
1346        values: &[&str],
1347    ) -> Result<Value, TestError> {
1348        let ref_id = self.find_ref_by_selector(selector).await?;
1349        self.select_option(&ref_id, values).await
1350    }
1351
1352    /// Scroll an element matching a CSS selector into view.
1353    ///
1354    /// Resolves the selector via `find_elements`, then scrolls the first match.
1355    ///
1356    /// # Errors
1357    ///
1358    /// Returns [`TestError::ElementNotFound`] if no element matches the selector.
1359    /// Returns other errors from [`VictauriClient::call_tool`].
1360    pub async fn scroll_to_by_selector(&mut self, selector: &str) -> Result<Value, TestError> {
1361        let ref_id = self.find_ref_by_selector(selector).await?;
1362        self.scroll_to(&ref_id).await
1363    }
1364
1365    /// Scroll an element with the given HTML `id` into view.
1366    ///
1367    /// # Errors
1368    ///
1369    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
1370    /// Returns other errors from [`VictauriClient::call_tool`].
1371    pub async fn scroll_to_by_id(&mut self, id: &str) -> Result<Value, TestError> {
1372        let ref_id = self.find_ref_by_id(id).await?;
1373        self.scroll_to(&ref_id).await
1374    }
1375
1376    /// Get the text content of an element identified by HTML `id`.
1377    ///
1378    /// # Errors
1379    ///
1380    /// Returns [`TestError::ElementNotFound`] if no element has the given id.
1381    /// Returns other errors from [`VictauriClient::call_tool`].
1382    pub async fn text_by_id(&mut self, id: &str) -> Result<String, TestError> {
1383        let snap = self.snapshot_json().await?;
1384        let tree = &snap["tree"];
1385        find_text_by_attr_id(tree, id)
1386            .ok_or_else(|| TestError::ElementNotFound(format!("id=\"{id}\"")))
1387    }
1388
1389    // ── Internal helpers for high-level API ─────────────────────────────────
1390
1391    async fn snapshot_json(&mut self) -> Result<Value, TestError> {
1392        self.call_tool("dom_snapshot", json!({"format": "json"}))
1393            .await
1394    }
1395
1396    async fn find_ref_by_text(&mut self, text: &str) -> Result<String, TestError> {
1397        let snap = self.snapshot_json().await?;
1398        let tree = &snap["tree"];
1399        find_in_tree_by_text(tree, text)
1400            .ok_or_else(|| TestError::ElementNotFound(format!("text=\"{text}\"")))
1401    }
1402
1403    async fn find_ref_by_id(&mut self, id: &str) -> Result<String, TestError> {
1404        let snap = self.snapshot_json().await?;
1405        let tree = &snap["tree"];
1406        find_in_tree_by_attr_id(tree, id)
1407            .ok_or_else(|| TestError::ElementNotFound(format!("id=\"{id}\"")))
1408    }
1409
1410    async fn find_ref_by_selector(&mut self, selector: &str) -> Result<String, TestError> {
1411        let result = self.find_elements(json!({"selector": selector})).await?;
1412        // find_elements returns an array of matched elements with ref_id fields
1413        let elements = result
1414            .as_array()
1415            .or_else(|| result.get("elements").and_then(Value::as_array));
1416        if let Some(elems) = elements
1417            && let Some(first) = elems.first()
1418            && let Some(ref_id) = first.get("ref_id").and_then(Value::as_str)
1419        {
1420            return Ok(ref_id.to_string());
1421        }
1422        Err(TestError::ElementNotFound(format!(
1423            "selector=\"{selector}\""
1424        )))
1425    }
1426}
1427
1428fn extract_screenshot_base64(result: &Value) -> Result<String, TestError> {
1429    // Try various response shapes the plugin may return
1430    if let Some(data) = result.get("base64").and_then(Value::as_str) {
1431        return Ok(data.to_string());
1432    }
1433    if let Some(data) = result.get("data").and_then(Value::as_str) {
1434        return Ok(data.to_string());
1435    }
1436    if let Some(data) = result.get("image").and_then(Value::as_str) {
1437        return Ok(data.to_string());
1438    }
1439    if let Some(data) = result
1440        .pointer("/result/content/0/data")
1441        .and_then(Value::as_str)
1442    {
1443        return Ok(data.to_string());
1444    }
1445    Err(TestError::Other(
1446        "screenshot result does not contain recognizable base64 image data".to_string(),
1447    ))
1448}
1449
1450fn find_in_tree_by_text(node: &Value, text: &str) -> Option<String> {
1451    let node_text = node.get("text").and_then(Value::as_str).unwrap_or("");
1452    let node_name = node.get("name").and_then(Value::as_str).unwrap_or("");
1453    if (node_text.contains(text) || node_name.contains(text))
1454        && let Some(ref_id) = node.get("ref_id").and_then(Value::as_str)
1455    {
1456        return Some(ref_id.to_string());
1457    }
1458    if let Some(children) = node.get("children").and_then(Value::as_array) {
1459        for child in children {
1460            if let Some(found) = find_in_tree_by_text(child, text) {
1461                return Some(found);
1462            }
1463        }
1464    }
1465    None
1466}
1467
1468fn find_in_tree_by_attr_id(node: &Value, id: &str) -> Option<String> {
1469    if node
1470        .get("attributes")
1471        .and_then(|a| a.get("id"))
1472        .and_then(Value::as_str)
1473        == Some(id)
1474        && let Some(ref_id) = node.get("ref_id").and_then(Value::as_str)
1475    {
1476        return Some(ref_id.to_string());
1477    }
1478    if let Some(children) = node.get("children").and_then(Value::as_array) {
1479        for child in children {
1480            if let Some(found) = find_in_tree_by_attr_id(child, id) {
1481                return Some(found);
1482            }
1483        }
1484    }
1485    None
1486}
1487
1488fn find_text_by_attr_id(node: &Value, id: &str) -> Option<String> {
1489    if node
1490        .get("attributes")
1491        .and_then(|a| a.get("id"))
1492        .and_then(Value::as_str)
1493        == Some(id)
1494    {
1495        let text = node.get("text").and_then(Value::as_str).unwrap_or("");
1496        return Some(text.to_string());
1497    }
1498    if let Some(children) = node.get("children").and_then(Value::as_array) {
1499        for child in children {
1500            if let Some(found) = find_text_by_attr_id(child, id) {
1501                return Some(found);
1502            }
1503        }
1504    }
1505    None
1506}
1507
1508// ── Assertion Helpers ────────────────────────────────────────────────────────
1509
1510/// Assert that a JSON value at the given pointer equals the expected value.
1511///
1512/// # Panics
1513///
1514/// Panics if the value at `pointer` is missing or does not equal `expected`.
1515///
1516/// # Examples
1517///
1518/// ```
1519/// use serde_json::json;
1520///
1521/// let state = json!({"visible": true, "title": "My App"});
1522/// victauri_test::assert_json_eq(&state, "/visible", &json!(true));
1523/// victauri_test::assert_json_eq(&state, "/title", &json!("My App"));
1524/// ```
1525pub fn assert_json_eq(value: &Value, pointer: &str, expected: &Value) {
1526    let actual = value.pointer(pointer);
1527    assert!(
1528        actual == Some(expected),
1529        "JSON pointer {pointer}: expected {expected}, got {}",
1530        actual.map_or("missing".to_string(), std::string::ToString::to_string)
1531    );
1532}
1533
1534/// Assert that a JSON value at the given pointer is truthy (not null/false/0/"").
1535///
1536/// # Panics
1537///
1538/// Panics if the value at `pointer` is missing, null, false, zero, or empty.
1539///
1540/// # Examples
1541///
1542/// ```
1543/// use serde_json::json;
1544///
1545/// let value = json!({"active": true, "name": "test", "count": 42});
1546/// victauri_test::assert_json_truthy(&value, "/active");
1547/// victauri_test::assert_json_truthy(&value, "/name");
1548/// victauri_test::assert_json_truthy(&value, "/count");
1549/// ```
1550pub fn assert_json_truthy(value: &Value, pointer: &str) {
1551    let actual = value.pointer(pointer);
1552    let is_truthy = match actual {
1553        None | Some(Value::Null) => false,
1554        Some(Value::Bool(b)) => *b,
1555        Some(Value::Number(n)) => n.as_f64().unwrap_or(0.0) != 0.0,
1556        Some(Value::String(s)) => !s.is_empty(),
1557        Some(Value::Array(a)) => !a.is_empty(),
1558        Some(Value::Object(_)) => true,
1559    };
1560    assert!(
1561        is_truthy,
1562        "JSON pointer {pointer}: expected truthy, got {}",
1563        actual.map_or("missing".to_string(), std::string::ToString::to_string)
1564    );
1565}
1566
1567/// Assert that an accessibility audit has zero violations.
1568///
1569/// # Panics
1570///
1571/// Panics if the audit contains any violations.
1572///
1573/// # Examples
1574///
1575/// ```
1576/// use serde_json::json;
1577///
1578/// let audit = json!({"summary": {"violations": 0, "passes": 12}});
1579/// victauri_test::assert_no_a11y_violations(&audit);
1580/// ```
1581pub fn assert_no_a11y_violations(audit: &Value) {
1582    let violations = audit
1583        .pointer("/summary/violations")
1584        .and_then(serde_json::Value::as_u64)
1585        .unwrap_or(u64::MAX);
1586    assert_eq!(
1587        violations, 0,
1588        "expected 0 accessibility violations, got {violations}"
1589    );
1590}
1591
1592/// Assert that all performance metrics are within budget.
1593///
1594/// # Panics
1595///
1596/// Panics if load time exceeds `max_load_ms` or heap usage exceeds `max_heap_mb`.
1597///
1598/// # Examples
1599///
1600/// ```
1601/// use serde_json::json;
1602///
1603/// let metrics = json!({
1604///     "navigation": {"load_event_ms": 450.0},
1605///     "js_heap": {"used_mb": 12.5}
1606/// });
1607/// victauri_test::assert_performance_budget(&metrics, 1000.0, 50.0);
1608/// ```
1609pub fn assert_performance_budget(metrics: &Value, max_load_ms: f64, max_heap_mb: f64) {
1610    if let Some(load) = metrics
1611        .pointer("/navigation/load_event_ms")
1612        .and_then(serde_json::Value::as_f64)
1613    {
1614        assert!(
1615            load <= max_load_ms,
1616            "load event took {load}ms, budget is {max_load_ms}ms"
1617        );
1618    }
1619
1620    if let Some(heap) = metrics
1621        .pointer("/js_heap/used_mb")
1622        .and_then(serde_json::Value::as_f64)
1623    {
1624        assert!(
1625            heap <= max_heap_mb,
1626            "JS heap is {heap}MB, budget is {max_heap_mb}MB"
1627        );
1628    }
1629}
1630
1631/// Assert that IPC integrity is healthy (no stale or errored calls).
1632///
1633/// # Panics
1634///
1635/// Panics if the integrity check reports an unhealthy state.
1636///
1637/// # Examples
1638///
1639/// ```
1640/// use serde_json::json;
1641///
1642/// let integrity = json!({"healthy": true, "stale_calls": 0, "error_calls": 0});
1643/// victauri_test::assert_ipc_healthy(&integrity);
1644/// ```
1645pub fn assert_ipc_healthy(integrity: &Value) {
1646    let healthy = integrity
1647        .get("healthy")
1648        .and_then(serde_json::Value::as_bool)
1649        .unwrap_or(false);
1650    assert!(
1651        healthy,
1652        "IPC integrity check failed: {}",
1653        serde_json::to_string_pretty(integrity).unwrap_or_default()
1654    );
1655}
1656
1657/// Assert that state verification passed with no divergences.
1658///
1659/// # Panics
1660///
1661/// Panics if the verification reports any divergences.
1662///
1663/// # Examples
1664///
1665/// ```
1666/// use serde_json::json;
1667///
1668/// let verification = json!({"passed": true, "divergences": []});
1669/// victauri_test::assert_state_matches(&verification);
1670/// ```
1671pub fn assert_state_matches(verification: &Value) {
1672    let passed = verification
1673        .get("passed")
1674        .and_then(serde_json::Value::as_bool)
1675        .unwrap_or(false);
1676    assert!(
1677        passed,
1678        "state verification failed: {}",
1679        serde_json::to_string_pretty(verification).unwrap_or_default()
1680    );
1681}