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