1use 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#[derive(Debug, Clone)]
37pub struct SmokeCheckResult {
38 pub name: String,
40 pub passed: bool,
42 pub detail: String,
44 pub duration: Duration,
46}
47
48#[derive(Debug)]
66pub struct SmokeReport {
67 pub checks: Vec<SmokeCheckResult>,
69 pub duration: Duration,
71}
72
73impl SmokeReport {
74 #[must_use]
76 pub fn all_passed(&self) -> bool {
77 self.checks.iter().all(|c| c.passed)
78 }
79
80 #[must_use]
82 pub fn failures(&self) -> Vec<&SmokeCheckResult> {
83 self.checks.iter().filter(|c| !c.passed).collect()
84 }
85
86 #[must_use]
88 pub fn passed_count(&self) -> usize {
89 self.checks.iter().filter(|c| c.passed).count()
90 }
91
92 #[must_use]
94 pub fn total_count(&self) -> usize {
95 self.checks.len()
96 }
97
98 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 #[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 #[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#[derive(Debug, Clone)]
169pub struct SmokeConfig {
170 pub max_dom_complete_ms: u64,
172 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
185impl VictauriClient {
188 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 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 pub async fn assert_screenshot_ok(&mut self) -> Result<(), TestError> {
227 let _result = self.screenshot().await?;
230 Ok(())
231 }
232
233 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 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 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 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 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 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 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 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 pub async fn smoke_test(&mut self) -> Result<SmokeReport, TestError> {
464 self.smoke_test_with_config(&SmokeConfig::default()).await
465 }
466
467 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}