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