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?;
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 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 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 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 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 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 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 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 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 pub async fn smoke_test(&mut self) -> Result<SmokeReport, TestError> {
470 self.smoke_test_with_config(&SmokeConfig::default()).await
471 }
472
473 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}