Skip to main content

victauri_test/
smoke.rs

1//! Built-in smoke test suite for Victauri-powered Tauri apps.
2//!
3//! **Layer 1** — Individual assertion helpers on [`VictauriClient`] that
4//! combine fetching data and verifying it in a single call. Each returns
5//! `Result<(), TestError>` where [`TestError::Assertion`] indicates a
6//! failed check.
7//!
8//! **Layer 2** — [`VictauriClient::smoke_test()`] runs all generic checks
9//! and produces a [`SmokeReport`] without stopping at the first failure.
10//!
11//! # Example
12//!
13//! ```rust,ignore
14//! use victauri_test::VictauriClient;
15//!
16//! let mut client = VictauriClient::discover().await.unwrap();
17//!
18//! // Layer 1 — single assertion
19//! client.assert_eval_works().await.unwrap();
20//! client.assert_heap_under_mb(256.0).await.unwrap();
21//!
22//! // Layer 2 — full smoke suite
23//! let report = client.smoke_test().await.unwrap();
24//! report.assert_all_passed();
25//! ```
26
27use std::time::{Duration, Instant};
28
29use serde_json::Value;
30
31use crate::assertions::{CheckResult, VerifyReport};
32use crate::client::VictauriClient;
33use crate::error::TestError;
34
35/// Result of a single smoke check with timing.
36#[derive(Debug, Clone)]
37pub struct SmokeCheckResult {
38    /// Human-readable name of the check.
39    pub name: String,
40    /// Whether the check passed.
41    pub passed: bool,
42    /// Failure detail (empty when passed).
43    pub detail: String,
44    /// Wall-clock duration of this check.
45    pub duration: Duration,
46}
47
48/// Aggregate report from [`VictauriClient::smoke_test()`].
49///
50/// ```
51/// use victauri_test::smoke::{SmokeCheckResult, SmokeReport};
52/// use std::time::Duration;
53///
54/// let report = SmokeReport {
55///     checks: vec![SmokeCheckResult {
56///         name: "eval works".to_string(),
57///         passed: true,
58///         detail: String::new(),
59///         duration: Duration::from_millis(50),
60///     }],
61///     duration: Duration::from_millis(50),
62/// };
63/// assert!(report.all_passed());
64/// ```
65#[derive(Debug)]
66pub struct SmokeReport {
67    /// Individual check results in execution order.
68    pub checks: Vec<SmokeCheckResult>,
69    /// Total wall-clock duration of the suite.
70    pub duration: Duration,
71}
72
73impl SmokeReport {
74    /// Returns `true` if every check passed.
75    #[must_use]
76    pub fn all_passed(&self) -> bool {
77        self.checks.iter().all(|c| c.passed)
78    }
79
80    /// Returns only the failed checks.
81    #[must_use]
82    pub fn failures(&self) -> Vec<&SmokeCheckResult> {
83        self.checks.iter().filter(|c| !c.passed).collect()
84    }
85
86    /// Number of passing checks.
87    #[must_use]
88    pub fn passed_count(&self) -> usize {
89        self.checks.iter().filter(|c| c.passed).count()
90    }
91
92    /// Total number of checks.
93    #[must_use]
94    pub fn total_count(&self) -> usize {
95        self.checks.len()
96    }
97
98    /// Panics with a formatted summary if any check failed.
99    ///
100    /// # Panics
101    ///
102    /// Panics when at least one check did not pass.
103    pub fn assert_all_passed(&self) {
104        if self.all_passed() {
105            return;
106        }
107        let failures: Vec<String> = self
108            .failures()
109            .iter()
110            .enumerate()
111            .map(|(i, f)| format!("  {}. {} — {}", i + 1, f.name, f.detail))
112            .collect();
113        panic!(
114            "smoke_test failed ({}/{} passed):\n{}",
115            self.passed_count(),
116            self.total_count(),
117            failures.join("\n")
118        );
119    }
120
121    /// Converts to a [`VerifyReport`] for `JUnit` XML output.
122    #[must_use]
123    pub fn to_verify_report(&self) -> VerifyReport {
124        VerifyReport {
125            results: self
126                .checks
127                .iter()
128                .map(|c| CheckResult {
129                    description: c.name.clone(),
130                    passed: c.passed,
131                    detail: c.detail.clone(),
132                })
133                .collect(),
134        }
135    }
136
137    /// Formats as a human-readable summary.
138    #[must_use]
139    pub fn to_summary(&self) -> String {
140        let mut out = String::with_capacity(1024);
141        out.push_str(&format!(
142            "Smoke Test: {}/{} passed ({:.1}s)\n\n",
143            self.passed_count(),
144            self.total_count(),
145            self.duration.as_secs_f64(),
146        ));
147        for check in &self.checks {
148            let status = if check.passed { "PASS" } else { "FAIL" };
149            out.push_str(&format!(
150                "  [{status}] {} ({:.0}ms)\n",
151                check.name,
152                check.duration.as_millis(),
153            ));
154            if !check.passed && !check.detail.is_empty() {
155                out.push_str(&format!("         {}\n", check.detail));
156            }
157        }
158        out
159    }
160}
161
162/// Configuration for the smoke test suite.
163///
164/// ```
165/// let config = victauri_test::smoke::SmokeConfig::default();
166/// assert_eq!(config.max_dom_complete_ms, 10_000);
167/// ```
168#[derive(Debug, Clone)]
169pub struct SmokeConfig {
170    /// Maximum acceptable DOM complete time in milliseconds (default: 10 000).
171    pub max_dom_complete_ms: u64,
172    /// Maximum acceptable JS heap usage in megabytes (default: 512).
173    pub max_heap_mb: f64,
174}
175
176impl Default for SmokeConfig {
177    fn default() -> Self {
178        Self {
179            max_dom_complete_ms: 10_000,
180            max_heap_mb: 512.0,
181        }
182    }
183}
184
185// ── Layer 1: Individual Assertion Helpers ──────────────────────────────────
186
187impl VictauriClient {
188    /// Assert that JavaScript evaluation works (evaluates `1+1`).
189    ///
190    /// # Errors
191    ///
192    /// Returns [`TestError::Assertion`] if evaluation returns the wrong result.
193    pub async fn assert_eval_works(&mut self) -> Result<(), TestError> {
194        let result = self.eval_js("1+1").await?;
195        let val = result
196            .as_f64()
197            .or_else(|| result.as_str().and_then(|s| s.parse::<f64>().ok()));
198        if val != Some(2.0) {
199            return Err(TestError::Assertion(format!(
200                "eval_js(\"1+1\") returned {result}, expected 2"
201            )));
202        }
203        Ok(())
204    }
205
206    /// Assert that DOM snapshot returns a valid tree with elements.
207    ///
208    /// # Errors
209    ///
210    /// Returns [`TestError::Assertion`] if the snapshot is empty or malformed.
211    pub async fn assert_dom_snapshot_valid(&mut self) -> Result<(), TestError> {
212        let snap = self.dom_snapshot().await?;
213        if snap.get("tree").is_none() && snap.get("ref_id").is_none() {
214            return Err(TestError::Assertion(
215                "DOM snapshot has no tree or ref_id".to_string(),
216            ));
217        }
218        Ok(())
219    }
220
221    /// Assert that screenshot captures window image data.
222    ///
223    /// # Errors
224    ///
225    /// Returns [`TestError::Assertion`] if no image data in the response.
226    pub async fn assert_screenshot_ok(&mut self) -> Result<(), TestError> {
227        // The tool responding is sufficient. Headless CI (Xvfb / no display
228        // session) has no native window handle to capture — which, now that the
229        // SDK honors MCP `isError` (0.7.6), surfaces as a tool-level error instead
230        // of being silently swallowed. That is an environment limitation, not a
231        // Victauri failure, so tolerate the "no window handle" case while still
232        // failing on transport/connection errors.
233        match self.screenshot().await {
234            Ok(_) => Ok(()),
235            Err(TestError::ToolError(msg))
236                if msg.contains("handle") || msg.contains("not available") =>
237            {
238                Ok(())
239            }
240            Err(e) => Err(e),
241        }
242    }
243
244    /// Assert that at least one window exists.
245    ///
246    /// # Errors
247    ///
248    /// Returns [`TestError::Assertion`] if no windows are found.
249    pub async fn assert_windows_exist(&mut self) -> Result<(), TestError> {
250        let windows = self.list_windows().await?;
251        let count = windows.as_array().map_or(0, Vec::len);
252        if count == 0 {
253            return Err(TestError::Assertion("no windows found".to_string()));
254        }
255        Ok(())
256    }
257
258    /// Assert that IPC integrity is healthy.
259    ///
260    /// # Errors
261    ///
262    /// Returns [`TestError::Assertion`] if the IPC integrity check reports
263    /// stale or errored calls.
264    pub async fn assert_ipc_integrity_ok(&mut self) -> Result<(), TestError> {
265        let integrity = self.check_ipc_integrity().await?;
266        let healthy = integrity
267            .get("healthy")
268            .and_then(Value::as_bool)
269            .unwrap_or(false);
270        if !healthy {
271            return Err(TestError::Assertion(format!(
272                "IPC integrity unhealthy: {}",
273                serde_json::to_string(&integrity).unwrap_or_default()
274            )));
275        }
276        Ok(())
277    }
278
279    /// Assert that the accessibility audit has zero violations.
280    ///
281    /// # Errors
282    ///
283    /// Returns [`TestError::Assertion`] if any a11y violations are found.
284    pub async fn assert_accessible(&mut self) -> Result<(), TestError> {
285        let audit = self.audit_accessibility().await?;
286        let violations = audit
287            .pointer("/summary/violations")
288            .and_then(Value::as_u64)
289            .unwrap_or(0);
290        if violations > 0 {
291            let details = audit.get("violations").cloned().unwrap_or(Value::Null);
292            return Err(TestError::Assertion(format!(
293                "{violations} a11y violation(s): {}",
294                serde_json::to_string(&details).unwrap_or_default()
295            )));
296        }
297        Ok(())
298    }
299
300    /// Assert DOM complete time is under the given duration.
301    ///
302    /// Passes silently if the browser does not expose navigation timing.
303    ///
304    /// # Errors
305    ///
306    /// Returns [`TestError::Assertion`] if load time exceeds the budget.
307    pub async fn assert_dom_complete_under(&mut self, max: Duration) -> Result<(), TestError> {
308        let metrics = self.get_performance_metrics().await?;
309        if let Some(ms) = metrics
310            .pointer("/navigation/dom_complete_ms")
311            .and_then(Value::as_f64)
312        {
313            let max_ms = max.as_millis() as f64;
314            if ms > max_ms {
315                return Err(TestError::Assertion(format!(
316                    "DOM complete took {ms:.0}ms, budget is {max_ms:.0}ms"
317                )));
318            }
319        }
320        Ok(())
321    }
322
323    /// Assert JS heap usage is under the given megabyte limit.
324    ///
325    /// Passes silently if the browser does not expose heap metrics.
326    ///
327    /// # Errors
328    ///
329    /// Returns [`TestError::Assertion`] if heap exceeds the budget.
330    pub async fn assert_heap_under_mb(&mut self, max_mb: f64) -> Result<(), TestError> {
331        let metrics = self.get_performance_metrics().await?;
332        if let Some(used) = metrics.pointer("/js_heap/used_mb").and_then(Value::as_f64)
333            && used > max_mb
334        {
335            return Err(TestError::Assertion(format!(
336                "JS heap is {used:.1}MB, budget is {max_mb:.1}MB"
337            )));
338        }
339        Ok(())
340    }
341
342    /// Assert there are no uncaught errors in the console log.
343    ///
344    /// Checks for entries with `[uncaught]` prefix (from the JS bridge's
345    /// `window.onerror` and `unhandledrejection` handlers).
346    ///
347    /// # Errors
348    ///
349    /// Returns [`TestError::Assertion`] if uncaught errors are found.
350    pub async fn assert_no_uncaught_errors(&mut self) -> Result<(), TestError> {
351        let log = self.logs("console", None).await?;
352        let entries = log
353            .as_array()
354            .or_else(|| log.get("entries").and_then(Value::as_array));
355        if let Some(entries) = entries {
356            let uncaught: Vec<&str> = entries
357                .iter()
358                .filter_map(|e| {
359                    let msg = e.get("message").and_then(Value::as_str)?;
360                    if msg.starts_with("[uncaught]") {
361                        Some(msg)
362                    } else {
363                        None
364                    }
365                })
366                .collect();
367            if !uncaught.is_empty() {
368                return Err(TestError::Assertion(format!(
369                    "{} uncaught error(s): {}",
370                    uncaught.len(),
371                    uncaught
372                        .iter()
373                        .take(3)
374                        .copied()
375                        .collect::<Vec<_>>()
376                        .join("; ")
377                )));
378            }
379        }
380        Ok(())
381    }
382
383    /// Assert that the recording lifecycle works end-to-end.
384    ///
385    /// Starts a recording, generates activity via `eval_js`, waits for the
386    /// event drain loop (2 seconds), stops recording, and verifies events
387    /// were captured.
388    ///
389    /// # Errors
390    ///
391    /// Returns [`TestError::Assertion`] if recording captures zero events.
392    pub async fn assert_recording_lifecycle(&mut self) -> Result<(), TestError> {
393        let _ = self.stop_recording().await;
394        self.start_recording(None).await?;
395        self.eval_js("console.log('victauri-smoke-test')").await?;
396        self.eval_js("document.title").await?;
397        tokio::time::sleep(Duration::from_secs(2)).await;
398        let session = self.stop_recording().await?;
399        let event_count = session
400            .get("events")
401            .and_then(Value::as_array)
402            .map_or(0, Vec::len);
403        if event_count == 0 {
404            return Err(TestError::Assertion(
405                "recording captured 0 events — drain loop may not be running".to_string(),
406            ));
407        }
408        Ok(())
409    }
410
411    /// Assert that `/health` returns only `{"status":"ok"}`.
412    ///
413    /// Verifies the endpoint doesn't leak internal state like uptime,
414    /// memory stats, or event counts.
415    ///
416    /// # Errors
417    ///
418    /// Returns [`TestError::Assertion`] if extra fields are present or the
419    /// response shape is wrong.
420    pub async fn assert_health_hardened(&mut self) -> Result<(), TestError> {
421        let url = format!("{}/health", self.base_url());
422        let resp =
423            self.http_client()
424                .get(&url)
425                .send()
426                .await
427                .map_err(|e| TestError::Connection {
428                    host: self.host().to_string(),
429                    port: self.port(),
430                    reason: e.to_string(),
431                })?;
432        if !resp.status().is_success() {
433            return Err(TestError::Assertion(format!(
434                "/health returned status {}",
435                resp.status()
436            )));
437        }
438        let text = resp.text().await.map_err(|e| TestError::Connection {
439            host: self.host().to_string(),
440            port: self.port(),
441            reason: e.to_string(),
442        })?;
443        let json: Value = serde_json::from_str(&text).map_err(|_| {
444            TestError::Assertion(format!(
445                "/health returned non-JSON: {}",
446                &text[..text.len().min(200)]
447            ))
448        })?;
449        let obj = json.as_object().ok_or_else(|| {
450            TestError::Assertion("/health response is not a JSON object".to_string())
451        })?;
452        if obj.len() != 1 || obj.get("status").and_then(Value::as_str) != Some("ok") {
453            return Err(TestError::Assertion(format!(
454                "/health should return only {{\"status\":\"ok\"}}, got: {text}"
455            )));
456        }
457        Ok(())
458    }
459
460    // ── Layer 2: Built-in Smoke Suite ─────────────────────────────────────
461
462    /// Run the built-in smoke test suite with default configuration.
463    ///
464    /// Exercises all core Victauri capabilities: eval, DOM, screenshot,
465    /// windows, IPC integrity, accessibility, performance budgets,
466    /// recording lifecycle, and health endpoint hardening.
467    ///
468    /// Individual check failures are captured in the [`SmokeReport`] — the
469    /// method itself only returns `Err` on fatal transport errors.
470    ///
471    /// # Errors
472    ///
473    /// Returns [`TestError`] on connection or transport failures.
474    pub async fn smoke_test(&mut self) -> Result<SmokeReport, TestError> {
475        self.smoke_test_with_config(&SmokeConfig::default()).await
476    }
477
478    /// Run the built-in smoke test suite with custom thresholds.
479    ///
480    /// # Errors
481    ///
482    /// Returns [`TestError`] on connection or transport failures.
483    pub async fn smoke_test_with_config(
484        &mut self,
485        config: &SmokeConfig,
486    ) -> Result<SmokeReport, TestError> {
487        let suite_start = Instant::now();
488        let mut checks = Vec::new();
489
490        macro_rules! check {
491            ($name:expr, $expr:expr) => {{
492                let start = Instant::now();
493                let result: Result<(), TestError> = $expr;
494                checks.push(SmokeCheckResult {
495                    name: $name.to_string(),
496                    passed: result.is_ok(),
497                    detail: result.err().map_or_else(String::new, |e| e.to_string()),
498                    duration: start.elapsed(),
499                });
500            }};
501        }
502
503        check!("eval_js works", self.assert_eval_works().await);
504        check!("DOM snapshot valid", self.assert_dom_snapshot_valid().await);
505        check!(
506            "screenshot captures image",
507            self.assert_screenshot_ok().await
508        );
509        check!("windows exist", self.assert_windows_exist().await);
510        check!(
511            "IPC integrity healthy",
512            self.assert_ipc_integrity_ok().await
513        );
514        check!("no uncaught errors", self.assert_no_uncaught_errors().await);
515        check!("accessibility audit", self.assert_accessible().await);
516        check!(
517            format!("DOM complete < {}ms", config.max_dom_complete_ms),
518            self.assert_dom_complete_under(Duration::from_millis(config.max_dom_complete_ms))
519                .await
520        );
521        check!(
522            format!("heap < {:.0}MB", config.max_heap_mb),
523            self.assert_heap_under_mb(config.max_heap_mb).await
524        );
525        check!(
526            "recording lifecycle",
527            self.assert_recording_lifecycle().await
528        );
529        check!(
530            "health endpoint hardened",
531            self.assert_health_hardened().await
532        );
533
534        Ok(SmokeReport {
535            checks,
536            duration: suite_start.elapsed(),
537        })
538    }
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544
545    fn pass(name: &str, ms: u64) -> SmokeCheckResult {
546        SmokeCheckResult {
547            name: name.to_string(),
548            passed: true,
549            detail: String::new(),
550            duration: Duration::from_millis(ms),
551        }
552    }
553
554    fn fail(name: &str, detail: &str, ms: u64) -> SmokeCheckResult {
555        SmokeCheckResult {
556            name: name.to_string(),
557            passed: false,
558            detail: detail.to_string(),
559            duration: Duration::from_millis(ms),
560        }
561    }
562
563    #[test]
564    fn all_passed_empty_report() {
565        let report = SmokeReport {
566            checks: vec![],
567            duration: Duration::ZERO,
568        };
569        assert!(report.all_passed());
570        assert_eq!(report.passed_count(), 0);
571        assert_eq!(report.total_count(), 0);
572    }
573
574    #[test]
575    fn all_passed_with_passes() {
576        let report = SmokeReport {
577            checks: vec![pass("a", 10), pass("b", 20)],
578            duration: Duration::from_millis(30),
579        };
580        assert!(report.all_passed());
581        assert_eq!(report.passed_count(), 2);
582        assert_eq!(report.total_count(), 2);
583        assert!(report.failures().is_empty());
584    }
585
586    #[test]
587    fn all_passed_false_with_failure() {
588        let report = SmokeReport {
589            checks: vec![pass("a", 10), fail("b", "broke", 20)],
590            duration: Duration::from_millis(30),
591        };
592        assert!(!report.all_passed());
593        assert_eq!(report.passed_count(), 1);
594        assert_eq!(report.failures().len(), 1);
595        assert_eq!(report.failures()[0].name, "b");
596    }
597
598    #[test]
599    #[should_panic(expected = "smoke_test failed")]
600    fn assert_all_passed_panics() {
601        let report = SmokeReport {
602            checks: vec![fail("bad", "it broke", 10)],
603            duration: Duration::from_millis(10),
604        };
605        report.assert_all_passed();
606    }
607
608    #[test]
609    fn to_verify_report_converts() {
610        let report = SmokeReport {
611            checks: vec![pass("ok", 10), fail("bad", "err", 20)],
612            duration: Duration::from_millis(30),
613        };
614        let verify = report.to_verify_report();
615        assert_eq!(verify.results.len(), 2);
616        assert!(verify.results[0].passed);
617        assert!(!verify.results[1].passed);
618        assert_eq!(verify.results[1].detail, "err");
619    }
620
621    #[test]
622    fn summary_includes_all_checks() {
623        let report = SmokeReport {
624            checks: vec![pass("eval works", 15), fail("screenshot", "no data", 200)],
625            duration: Duration::from_millis(215),
626        };
627        let summary = report.to_summary();
628        assert!(summary.contains("1/2 passed"));
629        assert!(summary.contains("[PASS] eval works"));
630        assert!(summary.contains("[FAIL] screenshot"));
631        assert!(summary.contains("no data"));
632    }
633
634    #[test]
635    fn smoke_config_defaults() {
636        let config = SmokeConfig::default();
637        assert_eq!(config.max_dom_complete_ms, 10_000);
638        assert!((config.max_heap_mb - 512.0).abs() < f64::EPSILON);
639    }
640
641    #[test]
642    fn to_junit_via_verify_report() {
643        let report = SmokeReport {
644            checks: vec![pass("check1", 100)],
645            duration: Duration::from_millis(100),
646        };
647        let verify = report.to_verify_report();
648        let junit = verify.to_junit("smoke", Duration::from_millis(100));
649        let xml = junit.to_xml();
650        assert!(xml.contains("tests=\"1\""));
651        assert!(xml.contains("failures=\"0\""));
652    }
653
654    #[test]
655    fn summary_shows_all_failures() {
656        let report = SmokeReport {
657            checks: vec![
658                fail("check1", "error 1", 10),
659                fail("check2", "error 2", 20),
660                pass("check3", 30),
661            ],
662            duration: Duration::from_millis(60),
663        };
664        let summary = report.to_summary();
665        assert!(summary.contains("1/3 passed"));
666        assert!(summary.contains("[FAIL] check1"));
667        assert!(summary.contains("error 1"));
668        assert!(summary.contains("[FAIL] check2"));
669        assert!(summary.contains("error 2"));
670        assert!(summary.contains("[PASS] check3"));
671    }
672
673    #[test]
674    fn failures_returns_only_failed() {
675        let report = SmokeReport {
676            checks: vec![
677                pass("ok", 10),
678                fail("bad1", "e1", 20),
679                fail("bad2", "e2", 30),
680            ],
681            duration: Duration::from_millis(60),
682        };
683        let failures = report.failures();
684        assert_eq!(failures.len(), 2);
685        assert_eq!(failures[0].name, "bad1");
686        assert_eq!(failures[1].name, "bad2");
687    }
688}