1use serde_json::Value;
2
3use crate::VictauriClient;
4use crate::error::TestError;
5
6pub struct VerifyBuilder<'a> {
28 client: &'a mut VictauriClient,
29 checks: Vec<Check>,
30}
31
32enum Check {
33 HasText(String),
34 HasNoText(String),
35 IpcWasCalled(String),
36 IpcWasCalledWith(String, Value),
37 IpcWasNotCalled(String),
38 NetworkRequest {
39 method: Option<String>,
40 url_contains: String,
41 },
42 NoNetworkRequest {
43 url_contains: String,
44 },
45 NoConsoleErrors,
46 StateMatches {
47 frontend_expr: String,
48 backend_state: Value,
49 },
50 IpcHealthy,
51 NoGhostCommands,
52 CoverageAbove(f64),
53}
54
55#[derive(Debug, Clone)]
57pub struct CheckResult {
58 pub description: String,
60 pub passed: bool,
62 pub detail: String,
64}
65
66#[derive(Debug)]
68pub struct VerifyReport {
69 pub results: Vec<CheckResult>,
71}
72
73impl VerifyReport {
74 #[must_use]
76 pub fn all_passed(&self) -> bool {
77 self.results.iter().all(|r| r.passed)
78 }
79
80 #[must_use]
82 pub fn failures(&self) -> Vec<&CheckResult> {
83 self.results.iter().filter(|r| !r.passed).collect()
84 }
85
86 #[must_use]
88 pub fn to_junit(
89 &self,
90 suite_name: &str,
91 duration: std::time::Duration,
92 ) -> crate::reporting::JunitReport {
93 crate::reporting::JunitReport::from_verify_report(self, suite_name, duration)
94 }
95
96 pub fn assert_all_passed(&self) {
102 if self.all_passed() {
103 return;
104 }
105 let failures: Vec<String> = self
106 .failures()
107 .iter()
108 .enumerate()
109 .map(|(i, f)| format!(" {}. {} — {}", i + 1, f.description, f.detail))
110 .collect();
111 panic!(
112 "verify() failed ({}/{} checks passed):\n{}",
113 self.results.len() - failures.len(),
114 self.results.len(),
115 failures.join("\n")
116 );
117 }
118}
119
120impl<'a> VerifyBuilder<'a> {
121 pub(crate) fn new(client: &'a mut VictauriClient) -> Self {
122 Self {
123 client,
124 checks: Vec::new(),
125 }
126 }
127
128 #[must_use]
130 pub fn has_text(mut self, text: &str) -> Self {
131 self.checks.push(Check::HasText(text.to_string()));
132 self
133 }
134
135 #[must_use]
137 pub fn has_no_text(mut self, text: &str) -> Self {
138 self.checks.push(Check::HasNoText(text.to_string()));
139 self
140 }
141
142 #[must_use]
144 pub fn ipc_was_called(mut self, command: &str) -> Self {
145 self.checks.push(Check::IpcWasCalled(command.to_string()));
146 self
147 }
148
149 #[must_use]
151 pub fn ipc_was_called_with(mut self, command: &str, args: Value) -> Self {
152 self.checks
153 .push(Check::IpcWasCalledWith(command.to_string(), args));
154 self
155 }
156
157 #[must_use]
159 pub fn ipc_was_not_called(mut self, command: &str) -> Self {
160 self.checks
161 .push(Check::IpcWasNotCalled(command.to_string()));
162 self
163 }
164
165 #[must_use]
167 pub fn network_request(mut self, method: Option<&str>, url_contains: &str) -> Self {
168 self.checks.push(Check::NetworkRequest {
169 method: method.map(String::from),
170 url_contains: url_contains.to_string(),
171 });
172 self
173 }
174
175 #[must_use]
177 pub fn no_network_request(mut self, url_contains: &str) -> Self {
178 self.checks.push(Check::NoNetworkRequest {
179 url_contains: url_contains.to_string(),
180 });
181 self
182 }
183
184 #[must_use]
186 pub fn no_console_errors(mut self) -> Self {
187 self.checks.push(Check::NoConsoleErrors);
188 self
189 }
190
191 #[must_use]
193 pub fn state_matches(mut self, frontend_expr: &str, backend_state: Value) -> Self {
194 self.checks.push(Check::StateMatches {
195 frontend_expr: frontend_expr.to_string(),
196 backend_state,
197 });
198 self
199 }
200
201 #[must_use]
203 pub fn ipc_healthy(mut self) -> Self {
204 self.checks.push(Check::IpcHealthy);
205 self
206 }
207
208 #[must_use]
210 pub fn no_ghost_commands(mut self) -> Self {
211 self.checks.push(Check::NoGhostCommands);
212 self
213 }
214
215 #[must_use]
221 pub fn coverage_above(mut self, threshold: f64) -> Self {
222 self.checks.push(Check::CoverageAbove(threshold));
223 self
224 }
225
226 pub async fn run(self) -> Result<VerifyReport, TestError> {
233 let client = self.client;
234 let mut results = Vec::with_capacity(self.checks.len());
235
236 for check in self.checks {
237 let result = run_check(client, &check).await?;
238 results.push(result);
239 }
240
241 Ok(VerifyReport { results })
242 }
243}
244
245async fn run_check(client: &mut VictauriClient, check: &Check) -> Result<CheckResult, TestError> {
246 match check {
247 Check::HasText(text) => {
248 let snap = client.dom_snapshot().await?;
249 let snap_str = serde_json::to_string(&snap).unwrap_or_default();
250 let found = snap_str.contains(text.as_str());
251 Ok(CheckResult {
252 description: format!("page contains \"{text}\""),
253 passed: found,
254 detail: if found {
255 String::new()
256 } else {
257 format!("text \"{text}\" not found in DOM")
258 },
259 })
260 }
261 Check::HasNoText(text) => {
262 let snap = client.dom_snapshot().await?;
263 let snap_str = serde_json::to_string(&snap).unwrap_or_default();
264 let found = snap_str.contains(text.as_str());
265 Ok(CheckResult {
266 description: format!("page does NOT contain \"{text}\""),
267 passed: !found,
268 detail: if found {
269 format!("text \"{text}\" was found in DOM but shouldn't be")
270 } else {
271 String::new()
272 },
273 })
274 }
275 Check::IpcWasCalled(command) => {
276 let log = client.get_ipc_log(None).await?;
277 let found = ipc_log_contains_command(&log, command);
278 Ok(CheckResult {
279 description: format!("IPC command \"{command}\" was called"),
280 passed: found,
281 detail: if found {
282 String::new()
283 } else {
284 format!("command \"{command}\" not found in IPC log")
285 },
286 })
287 }
288 Check::IpcWasCalledWith(command, expected_args) => {
289 let log = client.get_ipc_log(None).await?;
290 let (found, actual_args) = ipc_log_find_with_args(&log, command, expected_args);
291 Ok(CheckResult {
292 description: format!("IPC \"{command}\" called with {expected_args}"),
293 passed: found,
294 detail: if found {
295 String::new()
296 } else if let Some(actual) = actual_args {
297 format!("command called but with args: {actual}")
298 } else {
299 format!("command \"{command}\" not found in IPC log")
300 },
301 })
302 }
303 Check::IpcWasNotCalled(command) => {
304 let log = client.get_ipc_log(None).await?;
305 let found = ipc_log_contains_command(&log, command);
306 Ok(CheckResult {
307 description: format!("IPC command \"{command}\" was NOT called"),
308 passed: !found,
309 detail: if found {
310 format!("command \"{command}\" WAS called but shouldn't have been")
311 } else {
312 String::new()
313 },
314 })
315 }
316 Check::NetworkRequest {
317 method,
318 url_contains,
319 } => {
320 let log = client.logs("network", None).await?;
321 let found = network_log_matches(&log, method.as_deref(), url_contains);
322 let desc = match method {
323 Some(m) => format!("network {m} request to \"*{url_contains}*\""),
324 None => format!("network request to \"*{url_contains}*\""),
325 };
326 Ok(CheckResult {
327 description: desc,
328 passed: found,
329 detail: if found {
330 String::new()
331 } else {
332 "no matching network request found".to_string()
333 },
334 })
335 }
336 Check::NoNetworkRequest { url_contains } => {
337 let log = client.logs("network", None).await?;
338 let found = network_log_matches(&log, None, url_contains);
339 Ok(CheckResult {
340 description: format!("NO network request to \"*{url_contains}*\""),
341 passed: !found,
342 detail: if found {
343 format!("found network request matching \"{url_contains}\" but shouldn't have")
344 } else {
345 String::new()
346 },
347 })
348 }
349 Check::NoConsoleErrors => {
350 let log = client.logs("console", None).await?;
351 let errors = console_log_errors(&log);
352 Ok(CheckResult {
353 description: "no console errors".to_string(),
354 passed: errors.is_empty(),
355 detail: if errors.is_empty() {
356 String::new()
357 } else {
358 format!("{} error(s): {}", errors.len(), errors.join("; "))
359 },
360 })
361 }
362 Check::StateMatches {
363 frontend_expr,
364 backend_state,
365 } => {
366 let result = client
367 .verify_state(frontend_expr, backend_state.clone())
368 .await?;
369 let passed = result
370 .get("passed")
371 .and_then(Value::as_bool)
372 .unwrap_or(false);
373 Ok(CheckResult {
374 description: format!("state matches ({frontend_expr})"),
375 passed,
376 detail: if passed {
377 String::new()
378 } else {
379 let divs = result.get("divergences").cloned().unwrap_or(Value::Null);
380 format!("divergences: {divs}")
381 },
382 })
383 }
384 Check::IpcHealthy => {
385 let result = client.check_ipc_integrity().await?;
386 let healthy = result
387 .get("healthy")
388 .and_then(Value::as_bool)
389 .unwrap_or(false);
390 Ok(CheckResult {
391 description: "IPC integrity healthy".to_string(),
392 passed: healthy,
393 detail: if healthy {
394 String::new()
395 } else {
396 serde_json::to_string(&result).unwrap_or_default()
397 },
398 })
399 }
400 Check::NoGhostCommands => {
401 let result = client.detect_ghost_commands().await?;
402 let ghosts = result
403 .get("frontend_only")
404 .or_else(|| result.get("ghost_commands"))
405 .and_then(Value::as_array)
406 .map_or(0, Vec::len);
407 let reliability = result
412 .get("reliability")
413 .and_then(Value::as_str)
414 .unwrap_or("unknown");
415 let counts_as_ghosts = ghosts > 0 && reliability == "high";
416 Ok(CheckResult {
417 description: "no ghost commands".to_string(),
418 passed: !counts_as_ghosts,
419 detail: if counts_as_ghosts {
420 format!("{ghosts} ghost command(s) found (reliability: high)")
421 } else if ghosts > 0 {
422 format!(
423 "{ghosts} frontend-only command(s) found but registry is \
424 incomplete (reliability: {reliability}) — not treated as ghosts"
425 )
426 } else {
427 String::new()
428 },
429 })
430 }
431 Check::CoverageAbove(threshold) => {
432 let report = crate::coverage::coverage_report(client).await?;
433 let passed = report.meets_threshold(*threshold);
434 Ok(CheckResult {
435 description: format!(
436 "IPC coverage >= {threshold:.1}% (actual: {:.1}%)",
437 report.coverage_percentage
438 ),
439 passed,
440 detail: if passed {
441 String::new()
442 } else {
443 format!(
444 "coverage {:.1}% is below threshold {threshold:.1}%",
445 report.coverage_percentage
446 )
447 },
448 })
449 }
450 }
451}
452
453fn ipc_log_contains_command(log: &Value, command: &str) -> bool {
454 if let Some(arr) = log.as_array() {
455 return arr.iter().any(|entry| {
456 entry
457 .get("command")
458 .and_then(Value::as_str)
459 .is_some_and(|c| c == command)
460 });
461 }
462 if let Some(entries) = log.get("entries").and_then(Value::as_array) {
463 return entries.iter().any(|entry| {
464 entry
465 .get("command")
466 .and_then(Value::as_str)
467 .is_some_and(|c| c == command)
468 });
469 }
470 false
471}
472
473fn ipc_log_find_with_args(
474 log: &Value,
475 command: &str,
476 expected_args: &Value,
477) -> (bool, Option<Value>) {
478 let entries = if let Some(arr) = log.as_array() {
479 arr.clone()
480 } else if let Some(entries) = log.get("entries").and_then(Value::as_array) {
481 entries.clone()
482 } else {
483 return (false, None);
484 };
485
486 let mut last_args = None;
487 for entry in &entries {
488 let cmd = entry.get("command").and_then(Value::as_str).unwrap_or("");
489 if cmd != command {
490 continue;
491 }
492 let args = entry.get("args").or_else(|| entry.get("request_body"));
493 if let Some(args) = args {
494 if args_match(args, expected_args) {
495 return (true, None);
496 }
497 last_args = Some(args.clone());
498 } else if expected_args.is_null()
499 || expected_args == &Value::Object(serde_json::Map::default())
500 {
501 return (true, None);
502 }
503 }
504 (false, last_args)
505}
506
507fn args_match(actual: &Value, expected: &Value) -> bool {
508 match expected {
509 Value::Object(exp_map) => {
510 let Some(actual_map) = actual.as_object() else {
511 return false;
512 };
513 exp_map
514 .iter()
515 .all(|(k, v)| actual_map.get(k).is_some_and(|av| av == v))
516 }
517 _ => actual == expected,
518 }
519}
520
521fn network_log_matches(log: &Value, method: Option<&str>, url_contains: &str) -> bool {
522 let entries = if let Some(arr) = log.as_array() {
523 arr.as_slice()
524 } else if let Some(entries) = log.get("entries").and_then(Value::as_array) {
525 entries.as_slice()
526 } else {
527 return false;
528 };
529
530 entries.iter().any(|entry| {
531 let url = entry.get("url").and_then(Value::as_str).unwrap_or("");
532 if !url.contains(url_contains) {
533 return false;
534 }
535 if let Some(m) = method {
536 let req_method = entry.get("method").and_then(Value::as_str).unwrap_or("");
537 return req_method.eq_ignore_ascii_case(m);
538 }
539 true
540 })
541}
542
543fn console_log_errors(log: &Value) -> Vec<String> {
544 let entries = if let Some(arr) = log.as_array() {
545 arr.as_slice()
546 } else if let Some(entries) = log.get("entries").and_then(Value::as_array) {
547 entries.as_slice()
548 } else {
549 return Vec::new();
550 };
551
552 entries
553 .iter()
554 .filter_map(|entry| {
555 let level = entry.get("level").and_then(Value::as_str).unwrap_or("");
556 if level == "error" {
557 let msg = entry
558 .get("message")
559 .and_then(Value::as_str)
560 .unwrap_or("(no message)")
561 .to_string();
562 Some(msg)
563 } else {
564 None
565 }
566 })
567 .collect()
568}
569
570pub fn assert_ipc_called(log: &Value, command: &str) {
587 assert!(
588 ipc_log_contains_command(log, command),
589 "expected IPC command \"{command}\" to have been called, but it was not found in the log"
590 );
591}
592
593pub fn assert_ipc_called_with(log: &Value, command: &str, expected_args: &Value) {
610 let (found, actual_args) = ipc_log_find_with_args(log, command, expected_args);
611 if !found {
612 if let Some(actual) = actual_args {
613 panic!(
614 "IPC command \"{command}\" was called but with different args:\n expected: {expected_args}\n actual: {actual}"
615 );
616 } else {
617 panic!("IPC command \"{command}\" was never called (expected args: {expected_args})");
618 }
619 }
620}
621
622pub fn assert_ipc_not_called(log: &Value, command: &str) {
637 assert!(
638 !ipc_log_contains_command(log, command),
639 "expected IPC command \"{command}\" to NOT have been called, but it was"
640 );
641}
642
643#[cfg(test)]
644mod tests {
645 use serde_json::json;
646
647 use super::*;
648
649 #[test]
650 fn ipc_contains_finds_command_in_array() {
651 let log = json!([
652 {"command": "greet", "args": {"name": "World"}},
653 {"command": "save_settings", "args": {"theme": "dark"}}
654 ]);
655 assert!(ipc_log_contains_command(&log, "greet"));
656 assert!(ipc_log_contains_command(&log, "save_settings"));
657 assert!(!ipc_log_contains_command(&log, "delete_account"));
658 }
659
660 #[test]
661 fn ipc_contains_finds_command_in_entries_object() {
662 let log = json!({"entries": [{"command": "fetch_data"}]});
663 assert!(ipc_log_contains_command(&log, "fetch_data"));
664 assert!(!ipc_log_contains_command(&log, "nope"));
665 }
666
667 #[test]
668 fn args_match_partial_object() {
669 let actual = json!({"theme": "dark", "lang": "en", "notifications": true});
670 let expected = json!({"theme": "dark"});
671 assert!(args_match(&actual, &expected));
672 }
673
674 #[test]
675 fn args_match_full_object() {
676 let actual = json!({"theme": "dark"});
677 let expected = json!({"theme": "dark"});
678 assert!(args_match(&actual, &expected));
679 }
680
681 #[test]
682 fn args_match_fails_on_mismatch() {
683 let actual = json!({"theme": "light"});
684 let expected = json!({"theme": "dark"});
685 assert!(!args_match(&actual, &expected));
686 }
687
688 #[test]
689 fn args_match_scalar() {
690 assert!(args_match(&json!("hello"), &json!("hello")));
691 assert!(!args_match(&json!("hello"), &json!("world")));
692 }
693
694 #[test]
695 fn ipc_find_with_args_partial_match() {
696 let log = json!([
697 {"command": "save", "args": {"theme": "dark", "lang": "en"}}
698 ]);
699 let (found, _) = ipc_log_find_with_args(&log, "save", &json!({"theme": "dark"}));
700 assert!(found);
701 }
702
703 #[test]
704 fn ipc_find_with_args_no_match_returns_actual() {
705 let log = json!([
706 {"command": "save", "args": {"theme": "light"}}
707 ]);
708 let (found, actual) = ipc_log_find_with_args(&log, "save", &json!({"theme": "dark"}));
709 assert!(!found);
710 assert_eq!(actual, Some(json!({"theme": "light"})));
711 }
712
713 #[test]
714 fn ipc_find_with_args_command_not_found() {
715 let log = json!([{"command": "other", "args": {}}]);
716 let (found, actual) = ipc_log_find_with_args(&log, "save", &json!({"theme": "dark"}));
717 assert!(!found);
718 assert_eq!(actual, None);
719 }
720
721 #[test]
722 fn network_log_matches_url() {
723 let log = json!([
724 {"url": "http://api.example.com/users", "method": "GET", "status": 200},
725 {"url": "http://api.example.com/settings", "method": "POST", "status": 201}
726 ]);
727 assert!(network_log_matches(&log, None, "/users"));
728 assert!(network_log_matches(&log, Some("POST"), "/settings"));
729 assert!(!network_log_matches(&log, Some("DELETE"), "/settings"));
730 assert!(!network_log_matches(&log, None, "/nonexistent"));
731 }
732
733 #[test]
734 fn console_errors_filters_by_level() {
735 let log = json!([
736 {"level": "log", "message": "info msg"},
737 {"level": "error", "message": "something broke"},
738 {"level": "warn", "message": "careful"},
739 {"level": "error", "message": "another error"}
740 ]);
741 let errors = console_log_errors(&log);
742 assert_eq!(errors.len(), 2);
743 assert_eq!(errors[0], "something broke");
744 assert_eq!(errors[1], "another error");
745 }
746
747 #[test]
748 fn console_errors_empty_for_no_errors() {
749 let log = json!([{"level": "log", "message": "all good"}]);
750 assert!(console_log_errors(&log).is_empty());
751 }
752
753 #[test]
754 fn assert_ipc_called_passes() {
755 let log = json!([{"command": "greet", "args": {"name": "World"}}]);
756 assert_ipc_called(&log, "greet");
757 }
758
759 #[test]
760 #[should_panic(expected = "was not found in the log")]
761 fn assert_ipc_called_fails() {
762 let log = json!([{"command": "greet", "args": {}}]);
763 assert_ipc_called(&log, "nonexistent");
764 }
765
766 #[test]
767 fn assert_ipc_called_with_passes() {
768 let log = json!([{"command": "save", "args": {"theme": "dark", "extra": true}}]);
769 assert_ipc_called_with(&log, "save", &json!({"theme": "dark"}));
770 }
771
772 #[test]
773 #[should_panic(expected = "different args")]
774 fn assert_ipc_called_with_fails_wrong_args() {
775 let log = json!([{"command": "save", "args": {"theme": "light"}}]);
776 assert_ipc_called_with(&log, "save", &json!({"theme": "dark"}));
777 }
778
779 #[test]
780 fn assert_ipc_not_called_passes() {
781 let log = json!([{"command": "greet", "args": {}}]);
782 assert_ipc_not_called(&log, "delete_everything");
783 }
784
785 #[test]
786 #[should_panic(expected = "NOT have been called")]
787 fn assert_ipc_not_called_fails() {
788 let log = json!([{"command": "greet", "args": {}}]);
789 assert_ipc_not_called(&log, "greet");
790 }
791
792 #[test]
793 fn verify_report_all_passed() {
794 let report = VerifyReport {
795 results: vec![
796 CheckResult {
797 description: "check1".into(),
798 passed: true,
799 detail: String::new(),
800 },
801 CheckResult {
802 description: "check2".into(),
803 passed: true,
804 detail: String::new(),
805 },
806 ],
807 };
808 assert!(report.all_passed());
809 assert!(report.failures().is_empty());
810 }
811
812 #[test]
813 fn verify_report_with_failures() {
814 let report = VerifyReport {
815 results: vec![
816 CheckResult {
817 description: "pass".into(),
818 passed: true,
819 detail: String::new(),
820 },
821 CheckResult {
822 description: "fail".into(),
823 passed: false,
824 detail: "something wrong".into(),
825 },
826 ],
827 };
828 assert!(!report.all_passed());
829 assert_eq!(report.failures().len(), 1);
830 assert_eq!(report.failures()[0].description, "fail");
831 }
832
833 #[test]
834 #[should_panic(expected = "verify() failed")]
835 fn verify_report_assert_panics_on_failure() {
836 let report = VerifyReport {
837 results: vec![CheckResult {
838 description: "bad".into(),
839 passed: false,
840 detail: "it broke".into(),
841 }],
842 };
843 report.assert_all_passed();
844 }
845}