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