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