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("ghost_commands")
404 .and_then(Value::as_array)
405 .map_or(0, Vec::len);
406 Ok(CheckResult {
407 description: "no ghost commands".to_string(),
408 passed: ghosts == 0,
409 detail: if ghosts == 0 {
410 String::new()
411 } else {
412 format!("{ghosts} ghost command(s) found")
413 },
414 })
415 }
416 Check::CoverageAbove(threshold) => {
417 let report = crate::coverage::coverage_report(client).await?;
418 let passed = report.meets_threshold(*threshold);
419 Ok(CheckResult {
420 description: format!(
421 "IPC coverage >= {threshold:.1}% (actual: {:.1}%)",
422 report.coverage_percentage
423 ),
424 passed,
425 detail: if passed {
426 String::new()
427 } else {
428 format!(
429 "coverage {:.1}% is below threshold {threshold:.1}%",
430 report.coverage_percentage
431 )
432 },
433 })
434 }
435 }
436}
437
438fn ipc_log_contains_command(log: &Value, command: &str) -> bool {
439 if let Some(arr) = log.as_array() {
440 return arr.iter().any(|entry| {
441 entry
442 .get("command")
443 .and_then(Value::as_str)
444 .is_some_and(|c| c == command)
445 });
446 }
447 if let Some(entries) = log.get("entries").and_then(Value::as_array) {
448 return entries.iter().any(|entry| {
449 entry
450 .get("command")
451 .and_then(Value::as_str)
452 .is_some_and(|c| c == command)
453 });
454 }
455 false
456}
457
458fn ipc_log_find_with_args(
459 log: &Value,
460 command: &str,
461 expected_args: &Value,
462) -> (bool, Option<Value>) {
463 let entries = if let Some(arr) = log.as_array() {
464 arr.clone()
465 } else if let Some(entries) = log.get("entries").and_then(Value::as_array) {
466 entries.clone()
467 } else {
468 return (false, None);
469 };
470
471 let mut last_args = None;
472 for entry in &entries {
473 let cmd = entry.get("command").and_then(Value::as_str).unwrap_or("");
474 if cmd != command {
475 continue;
476 }
477 let args = entry.get("args").or_else(|| entry.get("request_body"));
478 if let Some(args) = args {
479 if args_match(args, expected_args) {
480 return (true, None);
481 }
482 last_args = Some(args.clone());
483 } else if expected_args.is_null()
484 || expected_args == &Value::Object(serde_json::Map::default())
485 {
486 return (true, None);
487 }
488 }
489 (false, last_args)
490}
491
492fn args_match(actual: &Value, expected: &Value) -> bool {
493 match expected {
494 Value::Object(exp_map) => {
495 let Some(actual_map) = actual.as_object() else {
496 return false;
497 };
498 exp_map
499 .iter()
500 .all(|(k, v)| actual_map.get(k).is_some_and(|av| av == v))
501 }
502 _ => actual == expected,
503 }
504}
505
506fn network_log_matches(log: &Value, method: Option<&str>, url_contains: &str) -> bool {
507 let entries = if let Some(arr) = log.as_array() {
508 arr.as_slice()
509 } else if let Some(entries) = log.get("entries").and_then(Value::as_array) {
510 entries.as_slice()
511 } else {
512 return false;
513 };
514
515 entries.iter().any(|entry| {
516 let url = entry.get("url").and_then(Value::as_str).unwrap_or("");
517 if !url.contains(url_contains) {
518 return false;
519 }
520 if let Some(m) = method {
521 let req_method = entry.get("method").and_then(Value::as_str).unwrap_or("");
522 return req_method.eq_ignore_ascii_case(m);
523 }
524 true
525 })
526}
527
528fn console_log_errors(log: &Value) -> Vec<String> {
529 let entries = if let Some(arr) = log.as_array() {
530 arr.as_slice()
531 } else if let Some(entries) = log.get("entries").and_then(Value::as_array) {
532 entries.as_slice()
533 } else {
534 return Vec::new();
535 };
536
537 entries
538 .iter()
539 .filter_map(|entry| {
540 let level = entry.get("level").and_then(Value::as_str).unwrap_or("");
541 if level == "error" {
542 let msg = entry
543 .get("message")
544 .and_then(Value::as_str)
545 .unwrap_or("(no message)")
546 .to_string();
547 Some(msg)
548 } else {
549 None
550 }
551 })
552 .collect()
553}
554
555pub fn assert_ipc_called(log: &Value, command: &str) {
572 assert!(
573 ipc_log_contains_command(log, command),
574 "expected IPC command \"{command}\" to have been called, but it was not found in the log"
575 );
576}
577
578pub fn assert_ipc_called_with(log: &Value, command: &str, expected_args: &Value) {
595 let (found, actual_args) = ipc_log_find_with_args(log, command, expected_args);
596 if !found {
597 if let Some(actual) = actual_args {
598 panic!(
599 "IPC command \"{command}\" was called but with different args:\n expected: {expected_args}\n actual: {actual}"
600 );
601 } else {
602 panic!("IPC command \"{command}\" was never called (expected args: {expected_args})");
603 }
604 }
605}
606
607pub fn assert_ipc_not_called(log: &Value, command: &str) {
622 assert!(
623 !ipc_log_contains_command(log, command),
624 "expected IPC command \"{command}\" to NOT have been called, but it was"
625 );
626}
627
628#[cfg(test)]
629mod tests {
630 use serde_json::json;
631
632 use super::*;
633
634 #[test]
635 fn ipc_contains_finds_command_in_array() {
636 let log = json!([
637 {"command": "greet", "args": {"name": "World"}},
638 {"command": "save_settings", "args": {"theme": "dark"}}
639 ]);
640 assert!(ipc_log_contains_command(&log, "greet"));
641 assert!(ipc_log_contains_command(&log, "save_settings"));
642 assert!(!ipc_log_contains_command(&log, "delete_account"));
643 }
644
645 #[test]
646 fn ipc_contains_finds_command_in_entries_object() {
647 let log = json!({"entries": [{"command": "fetch_data"}]});
648 assert!(ipc_log_contains_command(&log, "fetch_data"));
649 assert!(!ipc_log_contains_command(&log, "nope"));
650 }
651
652 #[test]
653 fn args_match_partial_object() {
654 let actual = json!({"theme": "dark", "lang": "en", "notifications": true});
655 let expected = json!({"theme": "dark"});
656 assert!(args_match(&actual, &expected));
657 }
658
659 #[test]
660 fn args_match_full_object() {
661 let actual = json!({"theme": "dark"});
662 let expected = json!({"theme": "dark"});
663 assert!(args_match(&actual, &expected));
664 }
665
666 #[test]
667 fn args_match_fails_on_mismatch() {
668 let actual = json!({"theme": "light"});
669 let expected = json!({"theme": "dark"});
670 assert!(!args_match(&actual, &expected));
671 }
672
673 #[test]
674 fn args_match_scalar() {
675 assert!(args_match(&json!("hello"), &json!("hello")));
676 assert!(!args_match(&json!("hello"), &json!("world")));
677 }
678
679 #[test]
680 fn ipc_find_with_args_partial_match() {
681 let log = json!([
682 {"command": "save", "args": {"theme": "dark", "lang": "en"}}
683 ]);
684 let (found, _) = ipc_log_find_with_args(&log, "save", &json!({"theme": "dark"}));
685 assert!(found);
686 }
687
688 #[test]
689 fn ipc_find_with_args_no_match_returns_actual() {
690 let log = json!([
691 {"command": "save", "args": {"theme": "light"}}
692 ]);
693 let (found, actual) = ipc_log_find_with_args(&log, "save", &json!({"theme": "dark"}));
694 assert!(!found);
695 assert_eq!(actual, Some(json!({"theme": "light"})));
696 }
697
698 #[test]
699 fn ipc_find_with_args_command_not_found() {
700 let log = json!([{"command": "other", "args": {}}]);
701 let (found, actual) = ipc_log_find_with_args(&log, "save", &json!({"theme": "dark"}));
702 assert!(!found);
703 assert_eq!(actual, None);
704 }
705
706 #[test]
707 fn network_log_matches_url() {
708 let log = json!([
709 {"url": "http://api.example.com/users", "method": "GET", "status": 200},
710 {"url": "http://api.example.com/settings", "method": "POST", "status": 201}
711 ]);
712 assert!(network_log_matches(&log, None, "/users"));
713 assert!(network_log_matches(&log, Some("POST"), "/settings"));
714 assert!(!network_log_matches(&log, Some("DELETE"), "/settings"));
715 assert!(!network_log_matches(&log, None, "/nonexistent"));
716 }
717
718 #[test]
719 fn console_errors_filters_by_level() {
720 let log = json!([
721 {"level": "log", "message": "info msg"},
722 {"level": "error", "message": "something broke"},
723 {"level": "warn", "message": "careful"},
724 {"level": "error", "message": "another error"}
725 ]);
726 let errors = console_log_errors(&log);
727 assert_eq!(errors.len(), 2);
728 assert_eq!(errors[0], "something broke");
729 assert_eq!(errors[1], "another error");
730 }
731
732 #[test]
733 fn console_errors_empty_for_no_errors() {
734 let log = json!([{"level": "log", "message": "all good"}]);
735 assert!(console_log_errors(&log).is_empty());
736 }
737
738 #[test]
739 fn assert_ipc_called_passes() {
740 let log = json!([{"command": "greet", "args": {"name": "World"}}]);
741 assert_ipc_called(&log, "greet");
742 }
743
744 #[test]
745 #[should_panic(expected = "was not found in the log")]
746 fn assert_ipc_called_fails() {
747 let log = json!([{"command": "greet", "args": {}}]);
748 assert_ipc_called(&log, "nonexistent");
749 }
750
751 #[test]
752 fn assert_ipc_called_with_passes() {
753 let log = json!([{"command": "save", "args": {"theme": "dark", "extra": true}}]);
754 assert_ipc_called_with(&log, "save", &json!({"theme": "dark"}));
755 }
756
757 #[test]
758 #[should_panic(expected = "different args")]
759 fn assert_ipc_called_with_fails_wrong_args() {
760 let log = json!([{"command": "save", "args": {"theme": "light"}}]);
761 assert_ipc_called_with(&log, "save", &json!({"theme": "dark"}));
762 }
763
764 #[test]
765 fn assert_ipc_not_called_passes() {
766 let log = json!([{"command": "greet", "args": {}}]);
767 assert_ipc_not_called(&log, "delete_everything");
768 }
769
770 #[test]
771 #[should_panic(expected = "NOT have been called")]
772 fn assert_ipc_not_called_fails() {
773 let log = json!([{"command": "greet", "args": {}}]);
774 assert_ipc_not_called(&log, "greet");
775 }
776
777 #[test]
778 fn verify_report_all_passed() {
779 let report = VerifyReport {
780 results: vec![
781 CheckResult {
782 description: "check1".into(),
783 passed: true,
784 detail: String::new(),
785 },
786 CheckResult {
787 description: "check2".into(),
788 passed: true,
789 detail: String::new(),
790 },
791 ],
792 };
793 assert!(report.all_passed());
794 assert!(report.failures().is_empty());
795 }
796
797 #[test]
798 fn verify_report_with_failures() {
799 let report = VerifyReport {
800 results: vec![
801 CheckResult {
802 description: "pass".into(),
803 passed: true,
804 detail: String::new(),
805 },
806 CheckResult {
807 description: "fail".into(),
808 passed: false,
809 detail: "something wrong".into(),
810 },
811 ],
812 };
813 assert!(!report.all_passed());
814 assert_eq!(report.failures().len(), 1);
815 assert_eq!(report.failures()[0].description, "fail");
816 }
817
818 #[test]
819 #[should_panic(expected = "verify() failed")]
820 fn verify_report_assert_panics_on_failure() {
821 let report = VerifyReport {
822 results: vec![CheckResult {
823 description: "bad".into(),
824 passed: false,
825 detail: "it broke".into(),
826 }],
827 };
828 report.assert_all_passed();
829 }
830}