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 without error is sufficient — headless CI
228        // environments (Xvfb) may not produce image data.
229        let _result = self.screenshot().await?;
230        Ok(())
231    }
232
233    /// Assert that at least one window exists.
234    ///
235    /// # Errors
236    ///
237    /// Returns [`TestError::Assertion`] if no windows are found.
238    pub async fn assert_windows_exist(&mut self) -> Result<(), TestError> {
239        let windows = self.list_windows().await?;
240        let count = windows.as_array().map_or(0, Vec::len);
241        if count == 0 {
242            return Err(TestError::Assertion("no windows found".to_string()));
243        }
244        Ok(())
245    }
246
247    /// Assert that IPC integrity is healthy.
248    ///
249    /// # Errors
250    ///
251    /// Returns [`TestError::Assertion`] if the IPC integrity check reports
252    /// stale or errored calls.
253    pub async fn assert_ipc_integrity_ok(&mut self) -> Result<(), TestError> {
254        let integrity = self.check_ipc_integrity().await?;
255        let healthy = integrity
256            .get("healthy")
257            .and_then(Value::as_bool)
258            .unwrap_or(false);
259        if !healthy {
260            return Err(TestError::Assertion(format!(
261                "IPC integrity unhealthy: {}",
262                serde_json::to_string(&integrity).unwrap_or_default()
263            )));
264        }
265        Ok(())
266    }
267
268    /// Assert that the accessibility audit has zero violations.
269    ///
270    /// # Errors
271    ///
272    /// Returns [`TestError::Assertion`] if any a11y violations are found.
273    pub async fn assert_accessible(&mut self) -> Result<(), TestError> {
274        let audit = self.audit_accessibility().await?;
275        let violations = audit
276            .pointer("/summary/violations")
277            .and_then(Value::as_u64)
278            .unwrap_or(0);
279        if violations > 0 {
280            let details = audit.get("violations").cloned().unwrap_or(Value::Null);
281            return Err(TestError::Assertion(format!(
282                "{violations} a11y violation(s): {}",
283                serde_json::to_string(&details).unwrap_or_default()
284            )));
285        }
286        Ok(())
287    }
288
289    /// Assert DOM complete time is under the given duration.
290    ///
291    /// Passes silently if the browser does not expose navigation timing.
292    ///
293    /// # Errors
294    ///
295    /// Returns [`TestError::Assertion`] if load time exceeds the budget.
296    pub async fn assert_dom_complete_under(&mut self, max: Duration) -> Result<(), TestError> {
297        let metrics = self.get_performance_metrics().await?;
298        if let Some(ms) = metrics
299            .pointer("/navigation/dom_complete_ms")
300            .and_then(Value::as_f64)
301        {
302            let max_ms = max.as_millis() as f64;
303            if ms > max_ms {
304                return Err(TestError::Assertion(format!(
305                    "DOM complete took {ms:.0}ms, budget is {max_ms:.0}ms"
306                )));
307            }
308        }
309        Ok(())
310    }
311
312    /// Assert JS heap usage is under the given megabyte limit.
313    ///
314    /// Passes silently if the browser does not expose heap metrics.
315    ///
316    /// # Errors
317    ///
318    /// Returns [`TestError::Assertion`] if heap exceeds the budget.
319    pub async fn assert_heap_under_mb(&mut self, max_mb: f64) -> Result<(), TestError> {
320        let metrics = self.get_performance_metrics().await?;
321        if let Some(used) = metrics.pointer("/js_heap/used_mb").and_then(Value::as_f64)
322            && used > max_mb
323        {
324            return Err(TestError::Assertion(format!(
325                "JS heap is {used:.1}MB, budget is {max_mb:.1}MB"
326            )));
327        }
328        Ok(())
329    }
330
331    /// Assert there are no uncaught errors in the console log.
332    ///
333    /// Checks for entries with `[uncaught]` prefix (from the JS bridge's
334    /// `window.onerror` and `unhandledrejection` handlers).
335    ///
336    /// # Errors
337    ///
338    /// Returns [`TestError::Assertion`] if uncaught errors are found.
339    pub async fn assert_no_uncaught_errors(&mut self) -> Result<(), TestError> {
340        let log = self.logs("console", None).await?;
341        let entries = log
342            .as_array()
343            .or_else(|| log.get("entries").and_then(Value::as_array));
344        if let Some(entries) = entries {
345            let uncaught: Vec<&str> = entries
346                .iter()
347                .filter_map(|e| {
348                    let msg = e.get("message").and_then(Value::as_str)?;
349                    if msg.starts_with("[uncaught]") {
350                        Some(msg)
351                    } else {
352                        None
353                    }
354                })
355                .collect();
356            if !uncaught.is_empty() {
357                return Err(TestError::Assertion(format!(
358                    "{} uncaught error(s): {}",
359                    uncaught.len(),
360                    uncaught
361                        .iter()
362                        .take(3)
363                        .copied()
364                        .collect::<Vec<_>>()
365                        .join("; ")
366                )));
367            }
368        }
369        Ok(())
370    }
371
372    /// Assert that the recording lifecycle works end-to-end.
373    ///
374    /// Starts a recording, generates activity via `eval_js`, waits for the
375    /// event drain loop (2 seconds), stops recording, and verifies events
376    /// were captured.
377    ///
378    /// # Errors
379    ///
380    /// Returns [`TestError::Assertion`] if recording captures zero events.
381    pub async fn assert_recording_lifecycle(&mut self) -> Result<(), TestError> {
382        let _ = self.stop_recording().await;
383        self.start_recording(None).await?;
384        self.eval_js("console.log('victauri-smoke-test')").await?;
385        self.eval_js("document.title").await?;
386        tokio::time::sleep(Duration::from_secs(2)).await;
387        let session = self.stop_recording().await?;
388        let event_count = session
389            .get("events")
390            .and_then(Value::as_array)
391            .map_or(0, Vec::len);
392        if event_count == 0 {
393            return Err(TestError::Assertion(
394                "recording captured 0 events — drain loop may not be running".to_string(),
395            ));
396        }
397        Ok(())
398    }
399
400    /// Assert that `/health` returns only `{"status":"ok"}`.
401    ///
402    /// Verifies the endpoint doesn't leak internal state like uptime,
403    /// memory stats, or event counts.
404    ///
405    /// # Errors
406    ///
407    /// Returns [`TestError::Assertion`] if extra fields are present or the
408    /// response shape is wrong.
409    pub async fn assert_health_hardened(&mut self) -> Result<(), TestError> {
410        let url = format!("{}/health", self.base_url());
411        let resp =
412            self.http_client()
413                .get(&url)
414                .send()
415                .await
416                .map_err(|e| TestError::Connection {
417                    host: self.host().to_string(),
418                    port: self.port(),
419                    reason: e.to_string(),
420                })?;
421        if !resp.status().is_success() {
422            return Err(TestError::Assertion(format!(
423                "/health returned status {}",
424                resp.status()
425            )));
426        }
427        let text = resp.text().await.map_err(|e| TestError::Connection {
428            host: self.host().to_string(),
429            port: self.port(),
430            reason: e.to_string(),
431        })?;
432        let json: Value = serde_json::from_str(&text).map_err(|_| {
433            TestError::Assertion(format!(
434                "/health returned non-JSON: {}",
435                &text[..text.len().min(200)]
436            ))
437        })?;
438        let obj = json.as_object().ok_or_else(|| {
439            TestError::Assertion("/health response is not a JSON object".to_string())
440        })?;
441        if obj.len() != 1 || obj.get("status").and_then(Value::as_str) != Some("ok") {
442            return Err(TestError::Assertion(format!(
443                "/health should return only {{\"status\":\"ok\"}}, got: {text}"
444            )));
445        }
446        Ok(())
447    }
448
449    // ── Layer 2: Built-in Smoke Suite ─────────────────────────────────────
450
451    /// Run the built-in smoke test suite with default configuration.
452    ///
453    /// Exercises all core Victauri capabilities: eval, DOM, screenshot,
454    /// windows, IPC integrity, accessibility, performance budgets,
455    /// recording lifecycle, and health endpoint hardening.
456    ///
457    /// Individual check failures are captured in the [`SmokeReport`] — the
458    /// method itself only returns `Err` on fatal transport errors.
459    ///
460    /// # Errors
461    ///
462    /// Returns [`TestError`] on connection or transport failures.
463    pub async fn smoke_test(&mut self) -> Result<SmokeReport, TestError> {
464        self.smoke_test_with_config(&SmokeConfig::default()).await
465    }
466
467    /// Run the built-in smoke test suite with custom thresholds.
468    ///
469    /// # Errors
470    ///
471    /// Returns [`TestError`] on connection or transport failures.
472    pub async fn smoke_test_with_config(
473        &mut self,
474        config: &SmokeConfig,
475    ) -> Result<SmokeReport, TestError> {
476        let suite_start = Instant::now();
477        let mut checks = Vec::new();
478
479        macro_rules! check {
480            ($name:expr, $expr:expr) => {{
481                let start = Instant::now();
482                let result: Result<(), TestError> = $expr;
483                checks.push(SmokeCheckResult {
484                    name: $name.to_string(),
485                    passed: result.is_ok(),
486                    detail: result.err().map_or_else(String::new, |e| e.to_string()),
487                    duration: start.elapsed(),
488                });
489            }};
490        }
491
492        check!("eval_js works", self.assert_eval_works().await);
493        check!("DOM snapshot valid", self.assert_dom_snapshot_valid().await);
494        check!(
495            "screenshot captures image",
496            self.assert_screenshot_ok().await
497        );
498        check!("windows exist", self.assert_windows_exist().await);
499        check!(
500            "IPC integrity healthy",
501            self.assert_ipc_integrity_ok().await
502        );
503        check!("no uncaught errors", self.assert_no_uncaught_errors().await);
504        check!("accessibility audit", self.assert_accessible().await);
505        check!(
506            format!("DOM complete < {}ms", config.max_dom_complete_ms),
507            self.assert_dom_complete_under(Duration::from_millis(config.max_dom_complete_ms))
508                .await
509        );
510        check!(
511            format!("heap < {:.0}MB", config.max_heap_mb),
512            self.assert_heap_under_mb(config.max_heap_mb).await
513        );
514        check!(
515            "recording lifecycle",
516            self.assert_recording_lifecycle().await
517        );
518        check!(
519            "health endpoint hardened",
520            self.assert_health_hardened().await
521        );
522
523        Ok(SmokeReport {
524            checks,
525            duration: suite_start.elapsed(),
526        })
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    fn pass(name: &str, ms: u64) -> SmokeCheckResult {
535        SmokeCheckResult {
536            name: name.to_string(),
537            passed: true,
538            detail: String::new(),
539            duration: Duration::from_millis(ms),
540        }
541    }
542
543    fn fail(name: &str, detail: &str, ms: u64) -> SmokeCheckResult {
544        SmokeCheckResult {
545            name: name.to_string(),
546            passed: false,
547            detail: detail.to_string(),
548            duration: Duration::from_millis(ms),
549        }
550    }
551
552    #[test]
553    fn all_passed_empty_report() {
554        let report = SmokeReport {
555            checks: vec![],
556            duration: Duration::ZERO,
557        };
558        assert!(report.all_passed());
559        assert_eq!(report.passed_count(), 0);
560        assert_eq!(report.total_count(), 0);
561    }
562
563    #[test]
564    fn all_passed_with_passes() {
565        let report = SmokeReport {
566            checks: vec![pass("a", 10), pass("b", 20)],
567            duration: Duration::from_millis(30),
568        };
569        assert!(report.all_passed());
570        assert_eq!(report.passed_count(), 2);
571        assert_eq!(report.total_count(), 2);
572        assert!(report.failures().is_empty());
573    }
574
575    #[test]
576    fn all_passed_false_with_failure() {
577        let report = SmokeReport {
578            checks: vec![pass("a", 10), fail("b", "broke", 20)],
579            duration: Duration::from_millis(30),
580        };
581        assert!(!report.all_passed());
582        assert_eq!(report.passed_count(), 1);
583        assert_eq!(report.failures().len(), 1);
584        assert_eq!(report.failures()[0].name, "b");
585    }
586
587    #[test]
588    #[should_panic(expected = "smoke_test failed")]
589    fn assert_all_passed_panics() {
590        let report = SmokeReport {
591            checks: vec![fail("bad", "it broke", 10)],
592            duration: Duration::from_millis(10),
593        };
594        report.assert_all_passed();
595    }
596
597    #[test]
598    fn to_verify_report_converts() {
599        let report = SmokeReport {
600            checks: vec![pass("ok", 10), fail("bad", "err", 20)],
601            duration: Duration::from_millis(30),
602        };
603        let verify = report.to_verify_report();
604        assert_eq!(verify.results.len(), 2);
605        assert!(verify.results[0].passed);
606        assert!(!verify.results[1].passed);
607        assert_eq!(verify.results[1].detail, "err");
608    }
609
610    #[test]
611    fn summary_includes_all_checks() {
612        let report = SmokeReport {
613            checks: vec![pass("eval works", 15), fail("screenshot", "no data", 200)],
614            duration: Duration::from_millis(215),
615        };
616        let summary = report.to_summary();
617        assert!(summary.contains("1/2 passed"));
618        assert!(summary.contains("[PASS] eval works"));
619        assert!(summary.contains("[FAIL] screenshot"));
620        assert!(summary.contains("no data"));
621    }
622
623    #[test]
624    fn smoke_config_defaults() {
625        let config = SmokeConfig::default();
626        assert_eq!(config.max_dom_complete_ms, 10_000);
627        assert!((config.max_heap_mb - 512.0).abs() < f64::EPSILON);
628    }
629
630    #[test]
631    fn to_junit_via_verify_report() {
632        let report = SmokeReport {
633            checks: vec![pass("check1", 100)],
634            duration: Duration::from_millis(100),
635        };
636        let verify = report.to_verify_report();
637        let junit = verify.to_junit("smoke", Duration::from_millis(100));
638        let xml = junit.to_xml();
639        assert!(xml.contains("tests=\"1\""));
640        assert!(xml.contains("failures=\"0\""));
641    }
642
643    #[test]
644    fn summary_shows_all_failures() {
645        let report = SmokeReport {
646            checks: vec![
647                fail("check1", "error 1", 10),
648                fail("check2", "error 2", 20),
649                pass("check3", 30),
650            ],
651            duration: Duration::from_millis(60),
652        };
653        let summary = report.to_summary();
654        assert!(summary.contains("1/3 passed"));
655        assert!(summary.contains("[FAIL] check1"));
656        assert!(summary.contains("error 1"));
657        assert!(summary.contains("[FAIL] check2"));
658        assert!(summary.contains("error 2"));
659        assert!(summary.contains("[PASS] check3"));
660    }
661
662    #[test]
663    fn failures_returns_only_failed() {
664        let report = SmokeReport {
665            checks: vec![
666                pass("ok", 10),
667                fail("bad1", "e1", 20),
668                fail("bad2", "e2", 30),
669            ],
670            duration: Duration::from_millis(60),
671        };
672        let failures = report.failures();
673        assert_eq!(failures.len(), 2);
674        assert_eq!(failures[0].name, "bad1");
675        assert_eq!(failures[1].name, "bad2");
676    }
677}