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 = self
418            .http_client()
419            .get(&url)
420            .send()
421            .await
422            .map_err(|e| TestError::Connection(e.to_string()))?;
423        if !resp.status().is_success() {
424            return Err(TestError::Assertion(format!(
425                "/health returned status {}",
426                resp.status()
427            )));
428        }
429        let text = resp
430            .text()
431            .await
432            .map_err(|e| TestError::Connection(e.to_string()))?;
433        let json: Value = serde_json::from_str(&text).map_err(|_| {
434            TestError::Assertion(format!(
435                "/health returned non-JSON: {}",
436                &text[..text.len().min(200)]
437            ))
438        })?;
439        let obj = json.as_object().ok_or_else(|| {
440            TestError::Assertion("/health response is not a JSON object".to_string())
441        })?;
442        if obj.len() != 1 || obj.get("status").and_then(Value::as_str) != Some("ok") {
443            return Err(TestError::Assertion(format!(
444                "/health should return only {{\"status\":\"ok\"}}, got: {text}"
445            )));
446        }
447        Ok(())
448    }
449
450    // ── Layer 2: Built-in Smoke Suite ─────────────────────────────────────
451
452    /// Run the built-in smoke test suite with default configuration.
453    ///
454    /// Exercises all core Victauri capabilities: eval, DOM, screenshot,
455    /// windows, IPC integrity, accessibility, performance budgets,
456    /// recording lifecycle, and health endpoint hardening.
457    ///
458    /// Individual check failures are captured in the [`SmokeReport`] — the
459    /// method itself only returns `Err` on fatal transport errors.
460    ///
461    /// # Errors
462    ///
463    /// Returns [`TestError`] on connection or transport failures.
464    pub async fn smoke_test(&mut self) -> Result<SmokeReport, TestError> {
465        self.smoke_test_with_config(&SmokeConfig::default()).await
466    }
467
468    /// Run the built-in smoke test suite with custom thresholds.
469    ///
470    /// # Errors
471    ///
472    /// Returns [`TestError`] on connection or transport failures.
473    pub async fn smoke_test_with_config(
474        &mut self,
475        config: &SmokeConfig,
476    ) -> Result<SmokeReport, TestError> {
477        let suite_start = Instant::now();
478        let mut checks = Vec::new();
479
480        macro_rules! check {
481            ($name:expr, $expr:expr) => {{
482                let start = Instant::now();
483                let result: Result<(), TestError> = $expr;
484                checks.push(SmokeCheckResult {
485                    name: $name.to_string(),
486                    passed: result.is_ok(),
487                    detail: result.err().map_or_else(String::new, |e| e.to_string()),
488                    duration: start.elapsed(),
489                });
490            }};
491        }
492
493        check!("eval_js works", self.assert_eval_works().await);
494        check!("DOM snapshot valid", self.assert_dom_snapshot_valid().await);
495        check!(
496            "screenshot captures image",
497            self.assert_screenshot_ok().await
498        );
499        check!("windows exist", self.assert_windows_exist().await);
500        check!(
501            "IPC integrity healthy",
502            self.assert_ipc_integrity_ok().await
503        );
504        check!("no uncaught errors", self.assert_no_uncaught_errors().await);
505        check!("accessibility audit", self.assert_accessible().await);
506        check!(
507            format!("DOM complete < {}ms", config.max_dom_complete_ms),
508            self.assert_dom_complete_under(Duration::from_millis(config.max_dom_complete_ms))
509                .await
510        );
511        check!(
512            format!("heap < {:.0}MB", config.max_heap_mb),
513            self.assert_heap_under_mb(config.max_heap_mb).await
514        );
515        check!(
516            "recording lifecycle",
517            self.assert_recording_lifecycle().await
518        );
519        check!(
520            "health endpoint hardened",
521            self.assert_health_hardened().await
522        );
523
524        Ok(SmokeReport {
525            checks,
526            duration: suite_start.elapsed(),
527        })
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534
535    fn pass(name: &str, ms: u64) -> SmokeCheckResult {
536        SmokeCheckResult {
537            name: name.to_string(),
538            passed: true,
539            detail: String::new(),
540            duration: Duration::from_millis(ms),
541        }
542    }
543
544    fn fail(name: &str, detail: &str, ms: u64) -> SmokeCheckResult {
545        SmokeCheckResult {
546            name: name.to_string(),
547            passed: false,
548            detail: detail.to_string(),
549            duration: Duration::from_millis(ms),
550        }
551    }
552
553    #[test]
554    fn all_passed_empty_report() {
555        let report = SmokeReport {
556            checks: vec![],
557            duration: Duration::ZERO,
558        };
559        assert!(report.all_passed());
560        assert_eq!(report.passed_count(), 0);
561        assert_eq!(report.total_count(), 0);
562    }
563
564    #[test]
565    fn all_passed_with_passes() {
566        let report = SmokeReport {
567            checks: vec![pass("a", 10), pass("b", 20)],
568            duration: Duration::from_millis(30),
569        };
570        assert!(report.all_passed());
571        assert_eq!(report.passed_count(), 2);
572        assert_eq!(report.total_count(), 2);
573        assert!(report.failures().is_empty());
574    }
575
576    #[test]
577    fn all_passed_false_with_failure() {
578        let report = SmokeReport {
579            checks: vec![pass("a", 10), fail("b", "broke", 20)],
580            duration: Duration::from_millis(30),
581        };
582        assert!(!report.all_passed());
583        assert_eq!(report.passed_count(), 1);
584        assert_eq!(report.failures().len(), 1);
585        assert_eq!(report.failures()[0].name, "b");
586    }
587
588    #[test]
589    #[should_panic(expected = "smoke_test failed")]
590    fn assert_all_passed_panics() {
591        let report = SmokeReport {
592            checks: vec![fail("bad", "it broke", 10)],
593            duration: Duration::from_millis(10),
594        };
595        report.assert_all_passed();
596    }
597
598    #[test]
599    fn to_verify_report_converts() {
600        let report = SmokeReport {
601            checks: vec![pass("ok", 10), fail("bad", "err", 20)],
602            duration: Duration::from_millis(30),
603        };
604        let verify = report.to_verify_report();
605        assert_eq!(verify.results.len(), 2);
606        assert!(verify.results[0].passed);
607        assert!(!verify.results[1].passed);
608        assert_eq!(verify.results[1].detail, "err");
609    }
610
611    #[test]
612    fn summary_includes_all_checks() {
613        let report = SmokeReport {
614            checks: vec![pass("eval works", 15), fail("screenshot", "no data", 200)],
615            duration: Duration::from_millis(215),
616        };
617        let summary = report.to_summary();
618        assert!(summary.contains("1/2 passed"));
619        assert!(summary.contains("[PASS] eval works"));
620        assert!(summary.contains("[FAIL] screenshot"));
621        assert!(summary.contains("no data"));
622    }
623
624    #[test]
625    fn smoke_config_defaults() {
626        let config = SmokeConfig::default();
627        assert_eq!(config.max_dom_complete_ms, 10_000);
628        assert!((config.max_heap_mb - 512.0).abs() < f64::EPSILON);
629    }
630
631    #[test]
632    fn to_junit_via_verify_report() {
633        let report = SmokeReport {
634            checks: vec![pass("check1", 100)],
635            duration: Duration::from_millis(100),
636        };
637        let verify = report.to_verify_report();
638        let junit = verify.to_junit("smoke", Duration::from_millis(100));
639        let xml = junit.to_xml();
640        assert!(xml.contains("tests=\"1\""));
641        assert!(xml.contains("failures=\"0\""));
642    }
643
644    #[test]
645    fn summary_shows_all_failures() {
646        let report = SmokeReport {
647            checks: vec![
648                fail("check1", "error 1", 10),
649                fail("check2", "error 2", 20),
650                pass("check3", 30),
651            ],
652            duration: Duration::from_millis(60),
653        };
654        let summary = report.to_summary();
655        assert!(summary.contains("1/3 passed"));
656        assert!(summary.contains("[FAIL] check1"));
657        assert!(summary.contains("error 1"));
658        assert!(summary.contains("[FAIL] check2"));
659        assert!(summary.contains("error 2"));
660        assert!(summary.contains("[PASS] check3"));
661    }
662
663    #[test]
664    fn failures_returns_only_failed() {
665        let report = SmokeReport {
666            checks: vec![
667                pass("ok", 10),
668                fail("bad1", "e1", 20),
669                fail("bad2", "e2", 30),
670            ],
671            duration: Duration::from_millis(60),
672        };
673        let failures = report.failures();
674        assert_eq!(failures.len(), 2);
675        assert_eq!(failures[0].name, "bad1");
676        assert_eq!(failures[1].name, "bad2");
677    }
678}