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