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