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