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 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 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 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 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 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 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 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 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 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 pub async fn smoke_test(&mut self) -> Result<SmokeReport, TestError> {
475 self.smoke_test_with_config(&SmokeConfig::default()).await
476 }
477
478 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}