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. Default: port 7373, no auth
249    ///
250    /// # Errors
251    ///
252    /// Returns [`TestError::Connection`] if the server is unreachable or
253    /// returns a non-success status. Returns [`TestError::Request`] on
254    /// HTTP transport failures.
255    pub async fn discover() -> Result<Self, TestError> {
256        let port = Self::discover_port();
257        let token = Self::discover_token();
258        Self::connect_with_token(port, token.as_deref()).await
259    }
260
261    fn discover_port() -> u16 {
262        if let Ok(p) = std::env::var("VICTAURI_PORT")
263            && let Ok(port) = p.parse::<u16>()
264        {
265            return port;
266        }
267        // Scan per-process discovery directories for live servers
268        if let Some(port) = scan_discovery_dirs_for_port() {
269            return port;
270        }
271        7373
272    }
273
274    fn discover_token() -> Option<String> {
275        if let Ok(token) = std::env::var("VICTAURI_AUTH_TOKEN") {
276            return Some(token);
277        }
278        // Scan per-process discovery directories
279        if let Some(token) = scan_discovery_dirs_for_token() {
280            return Some(token);
281        }
282        None
283    }
284
285    /// Check whether the server is still reachable.
286    ///
287    /// Sends a GET to `/health` and returns `true` if the response is 200 OK.
288    #[must_use]
289    pub async fn is_alive(&self) -> bool {
290        self.http
291            .get(format!("{}/health", self.base_url))
292            .send()
293            .await
294            .is_ok_and(|r| r.status().is_success())
295    }
296
297    /// Re-establish an MCP session after the app restarts.
298    ///
299    /// Polls `/health` up to `max_wait` and then re-runs the
300    /// initialize/initialized handshake. The returned client has a fresh
301    /// session ID; the old client should be dropped.
302    ///
303    /// # Errors
304    ///
305    /// Returns [`TestError::Connection`] if the server doesn't come back
306    /// within `max_wait`.
307    pub async fn reconnect(&self, max_wait: std::time::Duration) -> Result<Self, TestError> {
308        let start = std::time::Instant::now();
309        loop {
310            if self.is_alive().await {
311                return Self::connect_with_token(self.port, self.auth_token.as_deref()).await;
312            }
313            if start.elapsed() > max_wait {
314                return Err(TestError::Connection {
315                    host: self.host.clone(),
316                    port: self.port,
317                    reason: format!("server did not recover within {}s", max_wait.as_secs()),
318                });
319            }
320            tokio::time::sleep(std::time::Duration::from_millis(250)).await;
321        }
322    }
323
324    /// Call an MCP tool by name and return the result content as JSON.
325    ///
326    /// Retries up to 3 times with exponential backoff on 429 (rate limited).
327    ///
328    /// # Errors
329    ///
330    /// Returns [`TestError::Connection`] if the request fails after retries.
331    /// Returns [`TestError::Request`] on HTTP transport errors.
332    /// Returns [`TestError::Mcp`] if the server returns a JSON-RPC error.
333    pub async fn call_tool(&mut self, name: &str, arguments: Value) -> Result<Value, TestError> {
334        let id = self.next_id;
335        self.next_id += 1;
336
337        let call_body = json!({
338            "jsonrpc": "2.0",
339            "id": id,
340            "method": "tools/call",
341            "params": {
342                "name": name,
343                "arguments": arguments
344            }
345        });
346
347        let mut resp = None;
348        for attempt in 0..4 {
349            let mut req = self
350                .http
351                .post(format!("{}/mcp", self.base_url))
352                .header("Content-Type", "application/json")
353                .header("Accept", "application/json, text/event-stream")
354                .header("mcp-session-id", &self.session_id)
355                .json(&call_body);
356            if let Some(ref t) = self.auth_token {
357                req = req.header("Authorization", format!("Bearer {t}"));
358            }
359            let r = req.send().await?;
360
361            if r.status() == 429 && attempt < 3 {
362                let delay = std::time::Duration::from_millis(100 * (1 << attempt));
363                tokio::time::sleep(delay).await;
364                continue;
365            }
366            resp = Some(r);
367            break;
368        }
369
370        let resp = resp.ok_or_else(|| TestError::Connection {
371            host: self.host.clone(),
372            port: self.port,
373            reason: "tool call failed after retries".into(),
374        })?;
375        let body = Self::parse_response(resp, &self.host, self.port).await?;
376
377        if let Some(error) = body.get("error") {
378            return Err(TestError::Mcp {
379                code: error["code"].as_i64().unwrap_or(-1),
380                message: error["message"].as_str().map_or_else(
381                    || {
382                        format!(
383                            "unknown error (raw: {})",
384                            serde_json::to_string(error).unwrap_or_else(|_| "<unparseable>".into())
385                        )
386                    },
387                    String::from,
388                ),
389            });
390        }
391
392        let content = &body["result"]["content"];
393        if let Some(arr) = content.as_array()
394            && let Some(first) = arr.first()
395            && let Some(text) = first["text"].as_str()
396        {
397            if let Ok(parsed) = serde_json::from_str::<Value>(text) {
398                return Ok(parsed);
399            }
400            return Ok(Value::String(text.to_string()));
401        }
402
403        Ok(body)
404    }
405
406    /// Parse a response that may be JSON or SSE (text/event-stream).
407    ///
408    /// rmcp's Streamable HTTP transport always returns SSE format with the
409    /// JSON-RPC payload in a `data:` line. This method handles both formats.
410    async fn parse_response(
411        resp: reqwest::Response,
412        host: &str,
413        port: u16,
414    ) -> Result<Value, TestError> {
415        let content_type = resp
416            .headers()
417            .get("content-type")
418            .and_then(|v| v.to_str().ok())
419            .unwrap_or("")
420            .to_string();
421
422        let text = resp.text().await?;
423
424        if content_type.contains("text/event-stream") {
425            for line in text.lines() {
426                let data = line
427                    .strip_prefix("data: ")
428                    .or_else(|| line.strip_prefix("data:"));
429                let Some(data) = data else { continue };
430                let trimmed = data.trim();
431                if trimmed.is_empty() {
432                    continue;
433                }
434                if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
435                    return Ok(parsed);
436                }
437            }
438            Err(TestError::Connection {
439                host: host.to_string(),
440                port,
441                reason: "SSE stream contained no JSON-RPC data".into(),
442            })
443        } else {
444            serde_json::from_str(&text).map_err(|e| TestError::Connection {
445                host: host.to_string(),
446                port,
447                reason: format!(
448                    "JSON parse error: {e}, body: {}",
449                    &text[..200.min(text.len())]
450                ),
451            })
452        }
453    }
454
455    /// Evaluate JavaScript in the webview and return the result.
456    ///
457    /// # Errors
458    ///
459    /// Returns errors from [`VictauriClient::call_tool`].
460    pub async fn eval_js(&mut self, code: &str) -> Result<Value, TestError> {
461        self.call_tool("eval_js", json!({"code": code})).await
462    }
463
464    /// Get a DOM snapshot of the current page.
465    ///
466    /// # Errors
467    ///
468    /// Returns errors from [`VictauriClient::call_tool`].
469    pub async fn dom_snapshot(&mut self) -> Result<Value, TestError> {
470        self.call_tool("dom_snapshot", json!({})).await
471    }
472
473    /// Get a DOM snapshot of a specific webview by label.
474    ///
475    /// # Errors
476    ///
477    /// Returns errors from [`VictauriClient::call_tool`].
478    pub async fn dom_snapshot_for(&mut self, label: &str) -> Result<Value, TestError> {
479        self.call_tool("dom_snapshot", json!({"webview_label": label}))
480            .await
481    }
482
483    /// Capture a screenshot of a specific webview by label.
484    ///
485    /// # Errors
486    ///
487    /// Returns errors from [`VictauriClient::call_tool`].
488    pub async fn screenshot_for(&mut self, label: &str) -> Result<Value, TestError> {
489        self.call_tool("screenshot", json!({"window_label": label}))
490            .await
491    }
492
493    /// Click an element by ref handle ID.
494    ///
495    /// # Errors
496    ///
497    /// Returns errors from [`VictauriClient::call_tool`].
498    pub async fn click(&mut self, ref_id: &str) -> Result<Value, TestError> {
499        self.call_tool("interact", json!({"action": "click", "ref_id": ref_id}))
500            .await
501    }
502
503    /// Fill an input element with a value.
504    ///
505    /// # Errors
506    ///
507    /// Returns errors from [`VictauriClient::call_tool`].
508    pub async fn fill(&mut self, ref_id: &str, value: &str) -> Result<Value, TestError> {
509        self.call_tool(
510            "input",
511            json!({"action": "fill", "ref_id": ref_id, "value": value}),
512        )
513        .await
514    }
515
516    /// Type text into an element character by character.
517    ///
518    /// # Errors
519    ///
520    /// Returns errors from [`VictauriClient::call_tool`].
521    pub async fn type_text(&mut self, ref_id: &str, text: &str) -> Result<Value, TestError> {
522        self.call_tool(
523            "input",
524            json!({"action": "type_text", "ref_id": ref_id, "text": text}),
525        )
526        .await
527    }
528
529    /// List all window labels.
530    ///
531    /// # Errors
532    ///
533    /// Returns errors from [`VictauriClient::call_tool`].
534    pub async fn list_windows(&mut self) -> Result<Value, TestError> {
535        self.call_tool("window", json!({"action": "list"})).await
536    }
537
538    /// Get the state of a specific window (or all windows).
539    ///
540    /// # Errors
541    ///
542    /// Returns errors from [`VictauriClient::call_tool`].
543    pub async fn get_window_state(&mut self, label: Option<&str>) -> Result<Value, TestError> {
544        let mut args = json!({"action": "get_state"});
545        if let Some(l) = label {
546            args["label"] = json!(l);
547        }
548        self.call_tool("window", args).await
549    }
550
551    /// Take a screenshot and return base64-encoded PNG.
552    ///
553    /// # Errors
554    ///
555    /// Returns errors from [`VictauriClient::call_tool`].
556    pub async fn screenshot(&mut self) -> Result<Value, TestError> {
557        self.call_tool("screenshot", json!({})).await
558    }
559
560    /// Take a screenshot and compare it against a stored baseline.
561    ///
562    /// Captures the current window, extracts the base64 PNG data, and passes
563    /// it to [`visual::compare_screenshot`](crate::visual::compare_screenshot).
564    /// On first run the screenshot is saved as the new baseline.
565    ///
566    /// # Errors
567    ///
568    /// Returns [`TestError::VisualRegression`] if the diff exceeds the
569    /// threshold, or [`TestError::Other`] if the screenshot result does not
570    /// contain recognizable image data.
571    pub async fn screenshot_visual(
572        &mut self,
573        name: &str,
574        options: &VisualOptions,
575    ) -> Result<VisualDiff, TestError> {
576        let result = self.screenshot().await?;
577        let base64_data = extract_screenshot_base64(&result)?;
578        crate::visual::compare_screenshot(name, &base64_data, options)
579    }
580
581    /// Invoke a Tauri command by name with optional arguments.
582    ///
583    /// # Errors
584    ///
585    /// Returns errors from [`VictauriClient::call_tool`].
586    pub async fn invoke_command(
587        &mut self,
588        command: &str,
589        args: Option<Value>,
590    ) -> Result<Value, TestError> {
591        let mut params = json!({"command": command});
592        if let Some(a) = args {
593            params["args"] = a;
594        }
595        self.call_tool("invoke_command", params).await
596    }
597
598    /// Get the IPC call log.
599    ///
600    /// # Errors
601    ///
602    /// Returns errors from [`VictauriClient::call_tool`].
603    pub async fn get_ipc_log(&mut self, limit: Option<usize>) -> Result<Value, TestError> {
604        let mut args = json!({"action": "ipc"});
605        if let Some(n) = limit {
606            args["limit"] = json!(n);
607        }
608        self.call_tool("logs", args).await
609    }
610
611    /// Verify frontend state against backend state.
612    ///
613    /// # Errors
614    ///
615    /// Returns errors from [`VictauriClient::call_tool`].
616    pub async fn verify_state(
617        &mut self,
618        frontend_expr: &str,
619        backend_state: Value,
620    ) -> Result<Value, TestError> {
621        self.call_tool(
622            "verify_state",
623            json!({
624                "frontend_expr": frontend_expr,
625                "backend_state": backend_state,
626            }),
627        )
628        .await
629    }
630
631    /// Detect ghost commands (registered but never called, or called but not registered).
632    ///
633    /// # Errors
634    ///
635    /// Returns errors from [`VictauriClient::call_tool`].
636    pub async fn detect_ghost_commands(&mut self) -> Result<Value, TestError> {
637        self.call_tool("detect_ghost_commands", json!({})).await
638    }
639
640    /// Check IPC call health (pending, stale, errored).
641    ///
642    /// # Errors
643    ///
644    /// Returns errors from [`VictauriClient::call_tool`].
645    pub async fn check_ipc_integrity(&mut self) -> Result<Value, TestError> {
646        self.call_tool("check_ipc_integrity", json!({})).await
647    }
648
649    /// Run a semantic assertion against a JS expression.
650    ///
651    /// # Errors
652    ///
653    /// Returns errors from [`VictauriClient::call_tool`].
654    pub async fn assert_semantic(
655        &mut self,
656        expression: &str,
657        label: &str,
658        condition: &str,
659        expected: Value,
660    ) -> Result<Value, TestError> {
661        self.call_tool(
662            "assert_semantic",
663            json!({
664                "expression": expression,
665                "label": label,
666                "condition": condition,
667                "expected": expected,
668            }),
669        )
670        .await
671    }
672
673    /// Run an accessibility audit.
674    ///
675    /// # Errors
676    ///
677    /// Returns errors from [`VictauriClient::call_tool`].
678    pub async fn audit_accessibility(&mut self) -> Result<Value, TestError> {
679        self.call_tool("inspect", json!({"action": "audit_accessibility"}))
680            .await
681    }
682
683    /// Get performance metrics (timing, heap, resources).
684    ///
685    /// # Errors
686    ///
687    /// Returns errors from [`VictauriClient::call_tool`].
688    pub async fn get_performance_metrics(&mut self) -> Result<Value, TestError> {
689        self.call_tool("inspect", json!({"action": "get_performance"}))
690            .await
691    }
692
693    /// Get the command registry.
694    ///
695    /// # Errors
696    ///
697    /// Returns errors from [`VictauriClient::call_tool`].
698    pub async fn get_registry(&mut self) -> Result<Value, TestError> {
699        self.call_tool("get_registry", json!({})).await
700    }
701
702    /// Get process memory statistics.
703    ///
704    /// # Errors
705    ///
706    /// Returns errors from [`VictauriClient::call_tool`].
707    pub async fn get_memory_stats(&mut self) -> Result<Value, TestError> {
708        self.call_tool("get_memory_stats", json!({})).await
709    }
710
711    /// Read plugin info (version, uptime, tool count).
712    ///
713    /// # Errors
714    ///
715    /// Returns errors from [`VictauriClient::call_tool`].
716    pub async fn get_plugin_info(&mut self) -> Result<Value, TestError> {
717        self.call_tool("get_plugin_info", json!({})).await
718    }
719
720    /// Run environment diagnostics to detect potential compatibility issues.
721    ///
722    /// Checks for service workers, closed shadow DOM, iframes, large DOM,
723    /// and CSP status. Returns warnings and environment info.
724    ///
725    /// # Errors
726    ///
727    /// Returns errors from [`VictauriClient::call_tool`].
728    pub async fn get_diagnostics(&mut self) -> Result<Value, TestError> {
729        self.call_tool("get_diagnostics", 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    // ── Locator Factories ──────────────────────────────────────────────────
1428
1429    /// Create a [`Locator`](crate::Locator) matching elements by ARIA role.
1430    ///
1431    /// Equivalent to Playwright's `page.getByRole()`.
1432    #[must_use]
1433    pub fn get_by_role(&self, role: &str) -> crate::locator::Locator {
1434        crate::locator::Locator::role(role)
1435    }
1436
1437    /// Create a [`Locator`](crate::Locator) matching elements by visible text content.
1438    ///
1439    /// Equivalent to Playwright's `page.getByText()`.
1440    #[must_use]
1441    pub fn get_by_text(&self, text: &str) -> crate::locator::Locator {
1442        crate::locator::Locator::text(text)
1443    }
1444
1445    /// Create a [`Locator`](crate::Locator) matching elements by `data-testid` attribute.
1446    ///
1447    /// Equivalent to Playwright's `page.getByTestId()`.
1448    #[must_use]
1449    pub fn get_by_test_id(&self, id: &str) -> crate::locator::Locator {
1450        crate::locator::Locator::test_id(id)
1451    }
1452
1453    /// Create a [`Locator`](crate::Locator) matching form controls by associated label text.
1454    ///
1455    /// Equivalent to Playwright's `page.getByLabel()`.
1456    #[must_use]
1457    pub fn get_by_label(&self, text: &str) -> crate::locator::Locator {
1458        crate::locator::Locator::label(text)
1459    }
1460
1461    /// Create a [`Locator`](crate::Locator) matching elements by placeholder text.
1462    ///
1463    /// Equivalent to Playwright's `page.getByPlaceholder()`.
1464    #[must_use]
1465    pub fn get_by_placeholder(&self, text: &str) -> crate::locator::Locator {
1466        crate::locator::Locator::placeholder(text)
1467    }
1468
1469    /// Create a [`Locator`](crate::Locator) matching elements by CSS selector.
1470    ///
1471    /// Equivalent to Playwright's `page.locator()`.
1472    #[must_use]
1473    pub fn locator(&self, css: &str) -> crate::locator::Locator {
1474        crate::locator::Locator::css(css)
1475    }
1476
1477    /// Create a [`Locator`](crate::Locator) matching elements by alt text (images).
1478    ///
1479    /// Equivalent to Playwright's `page.getByAltText()`.
1480    #[must_use]
1481    pub fn get_by_alt_text(&self, alt: &str) -> crate::locator::Locator {
1482        crate::locator::Locator::alt_text(alt)
1483    }
1484
1485    /// Create a [`Locator`](crate::Locator) matching elements by title attribute.
1486    ///
1487    /// Equivalent to Playwright's `page.getByTitle()`.
1488    #[must_use]
1489    pub fn get_by_title(&self, title: &str) -> crate::locator::Locator {
1490        crate::locator::Locator::title(title)
1491    }
1492
1493    // ── Screenshot to File ─────────────────────────────────────────────────
1494
1495    /// Take a screenshot and save it to a file on disk.
1496    ///
1497    /// Captures the default window, decodes the base64 PNG, and writes it
1498    /// to the given path. Returns the canonical path of the saved file.
1499    ///
1500    /// # Errors
1501    ///
1502    /// Returns [`TestError::Other`] if the screenshot cannot be captured,
1503    /// decoded, or written to disk.
1504    pub async fn screenshot_to_file(
1505        &mut self,
1506        path: impl AsRef<std::path::Path>,
1507    ) -> Result<std::path::PathBuf, TestError> {
1508        let result = self.screenshot().await?;
1509        let base64_data = extract_screenshot_base64(&result)?;
1510        save_screenshot_to_file(&base64_data, path.as_ref())
1511    }
1512
1513    /// Take a screenshot of a specific window and save it to a file.
1514    ///
1515    /// # Errors
1516    ///
1517    /// Returns [`TestError::Other`] if the screenshot cannot be captured,
1518    /// decoded, or written to disk.
1519    pub async fn screenshot_to_file_for(
1520        &mut self,
1521        label: &str,
1522        path: impl AsRef<std::path::Path>,
1523    ) -> Result<std::path::PathBuf, TestError> {
1524        let result = self.screenshot_for(label).await?;
1525        let base64_data = extract_screenshot_base64(&result)?;
1526        save_screenshot_to_file(&base64_data, path.as_ref())
1527    }
1528}
1529
1530fn save_screenshot_to_file(
1531    base64_data: &str,
1532    path: &std::path::Path,
1533) -> Result<std::path::PathBuf, TestError> {
1534    use base64::Engine;
1535    let bytes = base64::engine::general_purpose::STANDARD
1536        .decode(base64_data)
1537        .map_err(|e| TestError::Other(format!("failed to decode screenshot base64: {e}")))?;
1538    if let Some(parent) = path.parent() {
1539        std::fs::create_dir_all(parent)
1540            .map_err(|e| TestError::Other(format!("failed to create directory: {e}")))?;
1541    }
1542    std::fs::write(path, &bytes)
1543        .map_err(|e| TestError::Other(format!("failed to write screenshot: {e}")))?;
1544    path.canonicalize()
1545        .or_else(|_| Ok(path.to_path_buf()))
1546        .map_err(|e: std::io::Error| TestError::Other(format!("path error: {e}")))
1547}
1548
1549fn extract_screenshot_base64(result: &Value) -> Result<String, TestError> {
1550    // Try various response shapes the plugin may return
1551    if let Some(data) = result.get("base64").and_then(Value::as_str) {
1552        return Ok(data.to_string());
1553    }
1554    if let Some(data) = result.get("data").and_then(Value::as_str) {
1555        return Ok(data.to_string());
1556    }
1557    if let Some(data) = result.get("image").and_then(Value::as_str) {
1558        return Ok(data.to_string());
1559    }
1560    if let Some(data) = result
1561        .pointer("/result/content/0/data")
1562        .and_then(Value::as_str)
1563    {
1564        return Ok(data.to_string());
1565    }
1566    Err(TestError::Other(
1567        "screenshot result does not contain recognizable base64 image data".to_string(),
1568    ))
1569}
1570
1571fn find_in_tree_by_text(node: &Value, text: &str) -> Option<String> {
1572    let node_text = node.get("text").and_then(Value::as_str).unwrap_or("");
1573    let node_name = node.get("name").and_then(Value::as_str).unwrap_or("");
1574    if (node_text.contains(text) || node_name.contains(text))
1575        && let Some(ref_id) = node.get("ref_id").and_then(Value::as_str)
1576    {
1577        return Some(ref_id.to_string());
1578    }
1579    if let Some(children) = node.get("children").and_then(Value::as_array) {
1580        for child in children {
1581            if let Some(found) = find_in_tree_by_text(child, text) {
1582                return Some(found);
1583            }
1584        }
1585    }
1586    None
1587}
1588
1589fn find_in_tree_by_attr_id(node: &Value, id: &str) -> Option<String> {
1590    if node
1591        .get("attributes")
1592        .and_then(|a| a.get("id"))
1593        .and_then(Value::as_str)
1594        == Some(id)
1595        && let Some(ref_id) = node.get("ref_id").and_then(Value::as_str)
1596    {
1597        return Some(ref_id.to_string());
1598    }
1599    if let Some(children) = node.get("children").and_then(Value::as_array) {
1600        for child in children {
1601            if let Some(found) = find_in_tree_by_attr_id(child, id) {
1602                return Some(found);
1603            }
1604        }
1605    }
1606    None
1607}
1608
1609fn find_text_by_attr_id(node: &Value, id: &str) -> Option<String> {
1610    if node
1611        .get("attributes")
1612        .and_then(|a| a.get("id"))
1613        .and_then(Value::as_str)
1614        == Some(id)
1615    {
1616        let text = node.get("text").and_then(Value::as_str).unwrap_or("");
1617        return Some(text.to_string());
1618    }
1619    if let Some(children) = node.get("children").and_then(Value::as_array) {
1620        for child in children {
1621            if let Some(found) = find_text_by_attr_id(child, id) {
1622                return Some(found);
1623            }
1624        }
1625    }
1626    None
1627}
1628
1629// ── Assertion Helpers ────────────────────────────────────────────────────────
1630
1631/// Assert that a JSON value at the given pointer equals the expected value.
1632///
1633/// # Panics
1634///
1635/// Panics if the value at `pointer` is missing or does not equal `expected`.
1636///
1637/// # Examples
1638///
1639/// ```
1640/// use serde_json::json;
1641///
1642/// let state = json!({"visible": true, "title": "My App"});
1643/// victauri_test::assert_json_eq(&state, "/visible", &json!(true));
1644/// victauri_test::assert_json_eq(&state, "/title", &json!("My App"));
1645/// ```
1646pub fn assert_json_eq(value: &Value, pointer: &str, expected: &Value) {
1647    let actual = value.pointer(pointer);
1648    assert!(
1649        actual == Some(expected),
1650        "JSON pointer {pointer}: expected {expected}, got {}",
1651        actual.map_or("missing".to_string(), std::string::ToString::to_string)
1652    );
1653}
1654
1655/// Assert that a JSON value at the given pointer is truthy (not null/false/0/"").
1656///
1657/// # Panics
1658///
1659/// Panics if the value at `pointer` is missing, null, false, zero, or empty.
1660///
1661/// # Examples
1662///
1663/// ```
1664/// use serde_json::json;
1665///
1666/// let value = json!({"active": true, "name": "test", "count": 42});
1667/// victauri_test::assert_json_truthy(&value, "/active");
1668/// victauri_test::assert_json_truthy(&value, "/name");
1669/// victauri_test::assert_json_truthy(&value, "/count");
1670/// ```
1671pub fn assert_json_truthy(value: &Value, pointer: &str) {
1672    let actual = value.pointer(pointer);
1673    let is_truthy = match actual {
1674        None | Some(Value::Null) => false,
1675        Some(Value::Bool(b)) => *b,
1676        Some(Value::Number(n)) => n.as_f64().unwrap_or(0.0) != 0.0,
1677        Some(Value::String(s)) => !s.is_empty(),
1678        Some(Value::Array(a)) => !a.is_empty(),
1679        Some(Value::Object(_)) => true,
1680    };
1681    assert!(
1682        is_truthy,
1683        "JSON pointer {pointer}: expected truthy, got {}",
1684        actual.map_or("missing".to_string(), std::string::ToString::to_string)
1685    );
1686}
1687
1688/// Assert that an accessibility audit has zero violations.
1689///
1690/// # Panics
1691///
1692/// Panics if the audit contains any violations.
1693///
1694/// # Examples
1695///
1696/// ```
1697/// use serde_json::json;
1698///
1699/// let audit = json!({"summary": {"violations": 0, "passes": 12}});
1700/// victauri_test::assert_no_a11y_violations(&audit);
1701/// ```
1702pub fn assert_no_a11y_violations(audit: &Value) {
1703    let violations = audit
1704        .pointer("/summary/violations")
1705        .and_then(serde_json::Value::as_u64)
1706        .unwrap_or(u64::MAX);
1707    assert_eq!(
1708        violations, 0,
1709        "expected 0 accessibility violations, got {violations}"
1710    );
1711}
1712
1713/// Assert that all performance metrics are within budget.
1714///
1715/// # Panics
1716///
1717/// Panics if load time exceeds `max_load_ms` or heap usage exceeds `max_heap_mb`.
1718///
1719/// # Examples
1720///
1721/// ```
1722/// use serde_json::json;
1723///
1724/// let metrics = json!({
1725///     "navigation": {"load_event_ms": 450.0},
1726///     "js_heap": {"used_mb": 12.5}
1727/// });
1728/// victauri_test::assert_performance_budget(&metrics, 1000.0, 50.0);
1729/// ```
1730pub fn assert_performance_budget(metrics: &Value, max_load_ms: f64, max_heap_mb: f64) {
1731    if let Some(load) = metrics
1732        .pointer("/navigation/load_event_ms")
1733        .and_then(serde_json::Value::as_f64)
1734    {
1735        assert!(
1736            load <= max_load_ms,
1737            "load event took {load}ms, budget is {max_load_ms}ms"
1738        );
1739    }
1740
1741    if let Some(heap) = metrics
1742        .pointer("/js_heap/used_mb")
1743        .and_then(serde_json::Value::as_f64)
1744    {
1745        assert!(
1746            heap <= max_heap_mb,
1747            "JS heap is {heap}MB, budget is {max_heap_mb}MB"
1748        );
1749    }
1750}
1751
1752/// Assert that IPC integrity is healthy (no stale or errored calls).
1753///
1754/// # Panics
1755///
1756/// Panics if the integrity check reports an unhealthy state.
1757///
1758/// # Examples
1759///
1760/// ```
1761/// use serde_json::json;
1762///
1763/// let integrity = json!({"healthy": true, "stale_calls": 0, "error_calls": 0});
1764/// victauri_test::assert_ipc_healthy(&integrity);
1765/// ```
1766pub fn assert_ipc_healthy(integrity: &Value) {
1767    let healthy = integrity
1768        .get("healthy")
1769        .and_then(serde_json::Value::as_bool)
1770        .unwrap_or(false);
1771    assert!(
1772        healthy,
1773        "IPC integrity check failed: {}",
1774        serde_json::to_string_pretty(integrity).unwrap_or_default()
1775    );
1776}
1777
1778/// Assert that state verification passed with no divergences.
1779///
1780/// # Panics
1781///
1782/// Panics if the verification reports any divergences.
1783///
1784/// # Examples
1785///
1786/// ```
1787/// use serde_json::json;
1788///
1789/// let verification = json!({"passed": true, "divergences": []});
1790/// victauri_test::assert_state_matches(&verification);
1791/// ```
1792pub fn assert_state_matches(verification: &Value) {
1793    let passed = verification
1794        .get("passed")
1795        .and_then(serde_json::Value::as_bool)
1796        .unwrap_or(false);
1797    assert!(
1798        passed,
1799        "state verification failed: {}",
1800        serde_json::to_string_pretty(verification).unwrap_or_default()
1801    );
1802}