1use crate::error::Result;
8use crate::protocol::{Locator, Page};
9#[cfg(feature = "screenshot-diff")]
10use std::path::Path;
11use std::time::Duration;
12
13const DEFAULT_ASSERTION_TIMEOUT: Duration = Duration::from_secs(5);
15
16const DEFAULT_POLL_INTERVAL: Duration = Duration::from_millis(100);
18
19pub fn expect(locator: Locator) -> Expectation {
99 Expectation::new(locator)
100}
101
102fn normalize_whitespace(s: &str) -> String {
107 s.split_whitespace().collect::<Vec<_>>().join(" ")
108}
109
110pub struct Expectation {
112 locator: Locator,
113 timeout: Duration,
114 poll_interval: Duration,
115 negate: bool,
116}
117
118#[allow(clippy::wrong_self_convention)]
121impl Expectation {
122 pub(crate) fn new(locator: Locator) -> Self {
124 Self {
125 locator,
126 timeout: DEFAULT_ASSERTION_TIMEOUT,
127 poll_interval: DEFAULT_POLL_INTERVAL,
128 negate: false,
129 }
130 }
131
132 pub fn with_timeout(mut self, timeout: Duration) -> Self {
135 self.timeout = timeout;
136 self
137 }
138
139 pub fn with_poll_interval(mut self, interval: Duration) -> Self {
143 self.poll_interval = interval;
144 self
145 }
146
147 #[allow(clippy::should_implement_trait)]
152 pub fn not(mut self) -> Self {
153 self.negate = true;
154 self
155 }
156
157 pub async fn to_be_visible(self) -> Result<()> {
163 let start = std::time::Instant::now();
164 let selector = self.locator.selector().to_string();
165
166 loop {
167 let is_visible = self.locator.is_visible().await?;
168
169 let matches = if self.negate { !is_visible } else { is_visible };
171
172 if matches {
173 return Ok(());
174 }
175
176 if start.elapsed() >= self.timeout {
178 let message = if self.negate {
179 format!(
180 "Expected element '{}' NOT to be visible, but it was visible after {:?}",
181 selector, self.timeout
182 )
183 } else {
184 format!(
185 "Expected element '{}' to be visible, but it was not visible after {:?}",
186 selector, self.timeout
187 )
188 };
189 return Err(crate::error::Error::AssertionTimeout(message));
190 }
191
192 tokio::time::sleep(self.poll_interval).await;
194 }
195 }
196
197 pub async fn to_be_hidden(self) -> Result<()> {
203 let negated = Expectation {
206 negate: !self.negate, ..self
208 };
209 negated.to_be_visible().await
210 }
211
212 pub async fn to_have_text(self, expected: &str) -> Result<()> {
223 let start = std::time::Instant::now();
224 let selector = self.locator.selector().to_string();
225 let expected = normalize_whitespace(expected);
226
227 loop {
228 let actual_text = self.locator.inner_text().await?;
230 let actual = normalize_whitespace(&actual_text);
231
232 let matches = if self.negate {
234 actual != expected
235 } else {
236 actual == expected
237 };
238
239 if matches {
240 return Ok(());
241 }
242
243 if start.elapsed() >= self.timeout {
245 let message = if self.negate {
246 format!(
247 "Expected element '{}' NOT to have text '{}', but it did after {:?}",
248 selector, expected, self.timeout
249 )
250 } else {
251 format!(
252 "Expected element '{}' to have text '{}', but had '{}' after {:?}",
253 selector, expected, actual, self.timeout
254 )
255 };
256 return Err(crate::error::Error::AssertionTimeout(message));
257 }
258
259 tokio::time::sleep(self.poll_interval).await;
261 }
262 }
263
264 pub async fn to_have_text_regex(self, pattern: &str) -> Result<()> {
268 let start = std::time::Instant::now();
269 let selector = self.locator.selector().to_string();
270 let re = regex::Regex::new(pattern)
271 .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
272
273 loop {
274 let actual_text = self.locator.inner_text().await?;
275 let actual = actual_text.trim();
276
277 let matches = if self.negate {
279 !re.is_match(actual)
280 } else {
281 re.is_match(actual)
282 };
283
284 if matches {
285 return Ok(());
286 }
287
288 if start.elapsed() >= self.timeout {
290 let message = if self.negate {
291 format!(
292 "Expected element '{}' NOT to match pattern '{}', but it did after {:?}",
293 selector, pattern, self.timeout
294 )
295 } else {
296 format!(
297 "Expected element '{}' to match pattern '{}', but had '{}' after {:?}",
298 selector, pattern, actual, self.timeout
299 )
300 };
301 return Err(crate::error::Error::AssertionTimeout(message));
302 }
303
304 tokio::time::sleep(self.poll_interval).await;
306 }
307 }
308
309 pub async fn to_contain_text(self, expected: &str) -> Result<()> {
321 let start = std::time::Instant::now();
322 let selector = self.locator.selector().to_string();
323 let expected = normalize_whitespace(expected);
324
325 loop {
326 let actual_text = self.locator.inner_text().await?;
327 let actual = normalize_whitespace(&actual_text);
328
329 let matches = if self.negate {
331 !actual.contains(&expected)
332 } else {
333 actual.contains(&expected)
334 };
335
336 if matches {
337 return Ok(());
338 }
339
340 if start.elapsed() >= self.timeout {
342 let message = if self.negate {
343 format!(
344 "Expected element '{}' NOT to contain text '{}', but it did after {:?}",
345 selector, expected, self.timeout
346 )
347 } else {
348 format!(
349 "Expected element '{}' to contain text '{}', but had '{}' after {:?}",
350 selector, expected, actual, self.timeout
351 )
352 };
353 return Err(crate::error::Error::AssertionTimeout(message));
354 }
355
356 tokio::time::sleep(self.poll_interval).await;
358 }
359 }
360
361 pub async fn to_contain_text_regex(self, pattern: &str) -> Result<()> {
365 let start = std::time::Instant::now();
366 let selector = self.locator.selector().to_string();
367 let re = regex::Regex::new(pattern)
368 .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
369
370 loop {
371 let actual_text = self.locator.inner_text().await?;
372 let actual = actual_text.trim();
373
374 let matches = if self.negate {
376 !re.is_match(actual)
377 } else {
378 re.is_match(actual)
379 };
380
381 if matches {
382 return Ok(());
383 }
384
385 if start.elapsed() >= self.timeout {
387 let message = if self.negate {
388 format!(
389 "Expected element '{}' NOT to contain pattern '{}', but it did after {:?}",
390 selector, pattern, self.timeout
391 )
392 } else {
393 format!(
394 "Expected element '{}' to contain pattern '{}', but had '{}' after {:?}",
395 selector, pattern, actual, self.timeout
396 )
397 };
398 return Err(crate::error::Error::AssertionTimeout(message));
399 }
400
401 tokio::time::sleep(self.poll_interval).await;
403 }
404 }
405
406 pub async fn to_have_value(self, expected: &str) -> Result<()> {
412 let start = std::time::Instant::now();
413 let selector = self.locator.selector().to_string();
414
415 loop {
416 let actual = self.locator.input_value(None).await?;
417
418 let matches = if self.negate {
420 actual != expected
421 } else {
422 actual == expected
423 };
424
425 if matches {
426 return Ok(());
427 }
428
429 if start.elapsed() >= self.timeout {
431 let message = if self.negate {
432 format!(
433 "Expected input '{}' NOT to have value '{}', but it did after {:?}",
434 selector, expected, self.timeout
435 )
436 } else {
437 format!(
438 "Expected input '{}' to have value '{}', but had '{}' after {:?}",
439 selector, expected, actual, self.timeout
440 )
441 };
442 return Err(crate::error::Error::AssertionTimeout(message));
443 }
444
445 tokio::time::sleep(self.poll_interval).await;
447 }
448 }
449
450 pub async fn to_have_value_regex(self, pattern: &str) -> Result<()> {
454 let start = std::time::Instant::now();
455 let selector = self.locator.selector().to_string();
456 let re = regex::Regex::new(pattern)
457 .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
458
459 loop {
460 let actual = self.locator.input_value(None).await?;
461
462 let matches = if self.negate {
464 !re.is_match(&actual)
465 } else {
466 re.is_match(&actual)
467 };
468
469 if matches {
470 return Ok(());
471 }
472
473 if start.elapsed() >= self.timeout {
475 let message = if self.negate {
476 format!(
477 "Expected input '{}' NOT to match pattern '{}', but it did after {:?}",
478 selector, pattern, self.timeout
479 )
480 } else {
481 format!(
482 "Expected input '{}' to match pattern '{}', but had '{}' after {:?}",
483 selector, pattern, actual, self.timeout
484 )
485 };
486 return Err(crate::error::Error::AssertionTimeout(message));
487 }
488
489 tokio::time::sleep(self.poll_interval).await;
491 }
492 }
493
494 pub async fn to_be_enabled(self) -> Result<()> {
501 let start = std::time::Instant::now();
502 let selector = self.locator.selector().to_string();
503
504 loop {
505 let is_enabled = self.locator.is_enabled().await?;
506
507 let matches = if self.negate { !is_enabled } else { is_enabled };
509
510 if matches {
511 return Ok(());
512 }
513
514 if start.elapsed() >= self.timeout {
516 let message = if self.negate {
517 format!(
518 "Expected element '{}' NOT to be enabled, but it was enabled after {:?}",
519 selector, self.timeout
520 )
521 } else {
522 format!(
523 "Expected element '{}' to be enabled, but it was not enabled after {:?}",
524 selector, self.timeout
525 )
526 };
527 return Err(crate::error::Error::AssertionTimeout(message));
528 }
529
530 tokio::time::sleep(self.poll_interval).await;
532 }
533 }
534
535 pub async fn to_be_disabled(self) -> Result<()> {
542 let negated = Expectation {
545 negate: !self.negate, ..self
547 };
548 negated.to_be_enabled().await
549 }
550
551 pub async fn to_be_checked(self) -> Result<()> {
557 let start = std::time::Instant::now();
558 let selector = self.locator.selector().to_string();
559
560 loop {
561 let is_checked = self.locator.is_checked().await?;
562
563 let matches = if self.negate { !is_checked } else { is_checked };
565
566 if matches {
567 return Ok(());
568 }
569
570 if start.elapsed() >= self.timeout {
572 let message = if self.negate {
573 format!(
574 "Expected element '{}' NOT to be checked, but it was checked after {:?}",
575 selector, self.timeout
576 )
577 } else {
578 format!(
579 "Expected element '{}' to be checked, but it was not checked after {:?}",
580 selector, self.timeout
581 )
582 };
583 return Err(crate::error::Error::AssertionTimeout(message));
584 }
585
586 tokio::time::sleep(self.poll_interval).await;
588 }
589 }
590
591 pub async fn to_be_unchecked(self) -> Result<()> {
597 let negated = Expectation {
600 negate: !self.negate, ..self
602 };
603 negated.to_be_checked().await
604 }
605
606 pub async fn to_be_editable(self) -> Result<()> {
613 let start = std::time::Instant::now();
614 let selector = self.locator.selector().to_string();
615
616 loop {
617 let is_editable = self.locator.is_editable().await?;
618
619 let matches = if self.negate {
621 !is_editable
622 } else {
623 is_editable
624 };
625
626 if matches {
627 return Ok(());
628 }
629
630 if start.elapsed() >= self.timeout {
632 let message = if self.negate {
633 format!(
634 "Expected element '{}' NOT to be editable, but it was editable after {:?}",
635 selector, self.timeout
636 )
637 } else {
638 format!(
639 "Expected element '{}' to be editable, but it was not editable after {:?}",
640 selector, self.timeout
641 )
642 };
643 return Err(crate::error::Error::AssertionTimeout(message));
644 }
645
646 tokio::time::sleep(self.poll_interval).await;
648 }
649 }
650
651 pub async fn to_be_focused(self) -> Result<()> {
657 let start = std::time::Instant::now();
658 let selector = self.locator.selector().to_string();
659
660 loop {
661 let is_focused = self.locator.is_focused().await?;
662
663 let matches = if self.negate { !is_focused } else { is_focused };
665
666 if matches {
667 return Ok(());
668 }
669
670 if start.elapsed() >= self.timeout {
672 let message = if self.negate {
673 format!(
674 "Expected element '{}' NOT to be focused, but it was focused after {:?}",
675 selector, self.timeout
676 )
677 } else {
678 format!(
679 "Expected element '{}' to be focused, but it was not focused after {:?}",
680 selector, self.timeout
681 )
682 };
683 return Err(crate::error::Error::AssertionTimeout(message));
684 }
685
686 tokio::time::sleep(self.poll_interval).await;
688 }
689 }
690
691 pub async fn to_have_attribute(self, name: &str, value: &str) -> Result<()> {
697 let start = std::time::Instant::now();
698 let selector = self.locator.selector().to_string();
699
700 loop {
701 let actual = self.locator.get_attribute(name).await?;
702
703 let matched = actual.as_deref() == Some(value);
704 let matches = if self.negate { !matched } else { matched };
705
706 if matches {
707 return Ok(());
708 }
709
710 if start.elapsed() >= self.timeout {
711 let actual_display = actual.as_deref().unwrap_or("<missing>");
712 let message = if self.negate {
713 format!(
714 "Expected element '{}' NOT to have attribute '{}'='{}', but it did after {:?}",
715 selector, name, value, self.timeout
716 )
717 } else {
718 format!(
719 "Expected element '{}' to have attribute '{}'='{}', but had '{}' after {:?}",
720 selector, name, value, actual_display, self.timeout
721 )
722 };
723 return Err(crate::error::Error::AssertionTimeout(message));
724 }
725
726 tokio::time::sleep(self.poll_interval).await;
727 }
728 }
729
730 pub async fn to_have_attribute_regex(self, name: &str, pattern: &str) -> Result<()> {
734 let start = std::time::Instant::now();
735 let selector = self.locator.selector().to_string();
736 let re = regex::Regex::new(pattern)
737 .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
738
739 loop {
740 let actual = self.locator.get_attribute(name).await?;
741
742 let matched = actual.as_deref().is_some_and(|v| re.is_match(v));
743 let matches = if self.negate { !matched } else { matched };
744
745 if matches {
746 return Ok(());
747 }
748
749 if start.elapsed() >= self.timeout {
750 let actual_display = actual.as_deref().unwrap_or("<missing>");
751 let message = if self.negate {
752 format!(
753 "Expected element '{}' attribute '{}' NOT to match pattern '{}', but it did after {:?}",
754 selector, name, pattern, self.timeout
755 )
756 } else {
757 format!(
758 "Expected element '{}' attribute '{}' to match pattern '{}', but had '{}' after {:?}",
759 selector, name, pattern, actual_display, self.timeout
760 )
761 };
762 return Err(crate::error::Error::AssertionTimeout(message));
763 }
764
765 tokio::time::sleep(self.poll_interval).await;
766 }
767 }
768
769 pub async fn to_have_class(self, expected: &str) -> Result<()> {
777 let start = std::time::Instant::now();
778 let selector = self.locator.selector().to_string();
779
780 loop {
781 let actual = self
782 .locator
783 .get_attribute("class")
784 .await?
785 .unwrap_or_default();
786 let actual_trimmed = actual.trim();
787
788 let matched = actual_trimmed == expected;
789 let matches = if self.negate { !matched } else { matched };
790
791 if matches {
792 return Ok(());
793 }
794
795 if start.elapsed() >= self.timeout {
796 let message = if self.negate {
797 format!(
798 "Expected element '{}' NOT to have class '{}', but it did after {:?}",
799 selector, expected, self.timeout
800 )
801 } else {
802 format!(
803 "Expected element '{}' to have class '{}', but had '{}' after {:?}",
804 selector, expected, actual_trimmed, self.timeout
805 )
806 };
807 return Err(crate::error::Error::AssertionTimeout(message));
808 }
809
810 tokio::time::sleep(self.poll_interval).await;
811 }
812 }
813
814 pub async fn to_have_class_regex(self, pattern: &str) -> Result<()> {
816 let start = std::time::Instant::now();
817 let selector = self.locator.selector().to_string();
818 let re = regex::Regex::new(pattern)
819 .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
820
821 loop {
822 let actual = self
823 .locator
824 .get_attribute("class")
825 .await?
826 .unwrap_or_default();
827
828 let matched = re.is_match(&actual);
829 let matches = if self.negate { !matched } else { matched };
830
831 if matches {
832 return Ok(());
833 }
834
835 if start.elapsed() >= self.timeout {
836 let message = if self.negate {
837 format!(
838 "Expected element '{}' class NOT to match pattern '{}', but it did after {:?}",
839 selector, pattern, self.timeout
840 )
841 } else {
842 format!(
843 "Expected element '{}' class to match pattern '{}', but had '{}' after {:?}",
844 selector, pattern, actual, self.timeout
845 )
846 };
847 return Err(crate::error::Error::AssertionTimeout(message));
848 }
849
850 tokio::time::sleep(self.poll_interval).await;
851 }
852 }
853
854 pub async fn to_have_css(self, name: &str, value: &str) -> Result<()> {
862 self.to_have_css_inner(name, value, None).await
863 }
864
865 pub async fn to_have_css_pseudo(self, name: &str, value: &str, pseudo: &str) -> Result<()> {
871 self.to_have_css_inner(name, value, Some(pseudo)).await
872 }
873
874 async fn to_have_css_inner(self, name: &str, value: &str, pseudo: Option<&str>) -> Result<()> {
875 let start = std::time::Instant::now();
876 let selector = self.locator.selector().to_string();
877 let getter = match pseudo {
878 Some(p) => format!(
879 "getComputedStyle(el, {})",
880 serde_json::to_string(p).unwrap()
881 ),
882 None => "getComputedStyle(el)".to_string(),
883 };
884 let expr = format!(
885 "(el) => {}.getPropertyValue({})",
886 getter,
887 serde_json::to_string(name).unwrap()
888 );
889
890 loop {
891 let actual: String = self.locator.evaluate(&expr, None::<()>).await?;
892
893 let matched = actual == value;
894 let matches = if self.negate { !matched } else { matched };
895
896 if matches {
897 return Ok(());
898 }
899
900 if start.elapsed() >= self.timeout {
901 let message = if self.negate {
902 format!(
903 "Expected element '{}' NOT to have CSS '{}'='{}', but it did after {:?}",
904 selector, name, value, self.timeout
905 )
906 } else {
907 format!(
908 "Expected element '{}' to have CSS '{}'='{}', but had '{}' after {:?}",
909 selector, name, value, actual, self.timeout
910 )
911 };
912 return Err(crate::error::Error::AssertionTimeout(message));
913 }
914
915 tokio::time::sleep(self.poll_interval).await;
916 }
917 }
918
919 pub async fn to_have_css_regex(self, name: &str, pattern: &str) -> Result<()> {
921 let start = std::time::Instant::now();
922 let selector = self.locator.selector().to_string();
923 let re = regex::Regex::new(pattern)
924 .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
925 let expr = format!(
926 "(el) => getComputedStyle(el).getPropertyValue({})",
927 serde_json::to_string(name).unwrap()
928 );
929
930 loop {
931 let actual: String = self.locator.evaluate(&expr, None::<()>).await?;
932
933 let matched = re.is_match(&actual);
934 let matches = if self.negate { !matched } else { matched };
935
936 if matches {
937 return Ok(());
938 }
939
940 if start.elapsed() >= self.timeout {
941 let message = if self.negate {
942 format!(
943 "Expected element '{}' CSS '{}' NOT to match pattern '{}', but it did after {:?}",
944 selector, name, pattern, self.timeout
945 )
946 } else {
947 format!(
948 "Expected element '{}' CSS '{}' to match pattern '{}', but had '{}' after {:?}",
949 selector, name, pattern, actual, self.timeout
950 )
951 };
952 return Err(crate::error::Error::AssertionTimeout(message));
953 }
954
955 tokio::time::sleep(self.poll_interval).await;
956 }
957 }
958
959 pub async fn to_have_count(self, count: usize) -> Result<()> {
963 let start = std::time::Instant::now();
964 let selector = self.locator.selector().to_string();
965
966 loop {
967 let actual = self.locator.count().await?;
968
969 let matched = actual == count;
970 let matches = if self.negate { !matched } else { matched };
971
972 if matches {
973 return Ok(());
974 }
975
976 if start.elapsed() >= self.timeout {
977 let message = if self.negate {
978 format!(
979 "Expected locator '{}' NOT to have count {}, but it did after {:?}",
980 selector, count, self.timeout
981 )
982 } else {
983 format!(
984 "Expected locator '{}' to have count {}, but had {} after {:?}",
985 selector, count, actual, self.timeout
986 )
987 };
988 return Err(crate::error::Error::AssertionTimeout(message));
989 }
990
991 tokio::time::sleep(self.poll_interval).await;
992 }
993 }
994
995 pub async fn to_match_aria_snapshot(self, expected: &str) -> Result<()> {
1017 use crate::protocol::serialize_argument;
1018
1019 let selector = self.locator.selector().to_string();
1020 let timeout_ms = self.timeout.as_millis() as f64;
1021 let expected_value = serialize_argument(&serde_json::Value::String(expected.to_string()));
1022
1023 self.locator
1024 .frame()
1025 .frame_expect(
1026 &selector,
1027 "to.match.aria",
1028 expected_value,
1029 self.negate,
1030 timeout_ms,
1031 )
1032 .await
1033 }
1034
1035 #[cfg(feature = "screenshot-diff")]
1046 pub async fn to_have_screenshot(
1047 self,
1048 baseline_path: impl AsRef<Path>,
1049 options: Option<ScreenshotAssertionOptions>,
1050 ) -> Result<()> {
1051 let opts = options.unwrap_or_default();
1052 let baseline_path = baseline_path.as_ref();
1053
1054 if opts.animations == Some(Animations::Disabled) {
1056 let _ = self
1057 .locator
1058 .evaluate_js(DISABLE_ANIMATIONS_JS, None::<&()>)
1059 .await;
1060 }
1061
1062 let screenshot_opts = if let Some(ref mask_locators) = opts.mask {
1064 let mask_js = build_mask_js(mask_locators);
1066 let _ = self.locator.evaluate_js(&mask_js, None::<&()>).await;
1067 None
1068 } else {
1069 None
1070 };
1071
1072 compare_screenshot(
1073 &opts,
1074 baseline_path,
1075 self.timeout,
1076 self.poll_interval,
1077 self.negate,
1078 || async { self.locator.screenshot(screenshot_opts.clone()).await },
1079 )
1080 .await
1081 }
1082}
1083
1084#[cfg(feature = "screenshot-diff")]
1086const DISABLE_ANIMATIONS_JS: &str = r#"
1087(() => {
1088 const style = document.createElement('style');
1089 style.textContent = '*, *::before, *::after { animation-duration: 0s !important; animation-delay: 0s !important; transition-duration: 0s !important; transition-delay: 0s !important; }';
1090 style.setAttribute('data-playwright-no-animations', '');
1091 document.head.appendChild(style);
1092})()
1093"#;
1094
1095#[cfg(feature = "screenshot-diff")]
1097fn build_mask_js(locators: &[Locator]) -> String {
1098 let selectors: Vec<String> = locators
1099 .iter()
1100 .map(|l| {
1101 let sel = l.selector().replace('\'', "\\'");
1102 format!(
1103 r#"
1104 (function() {{
1105 var els = document.querySelectorAll('{}');
1106 els.forEach(function(el) {{
1107 var rect = el.getBoundingClientRect();
1108 var overlay = document.createElement('div');
1109 overlay.setAttribute('data-playwright-mask', '');
1110 overlay.style.cssText = 'position:fixed;z-index:2147483647;background:#FF00FF;pointer-events:none;'
1111 + 'left:' + rect.left + 'px;top:' + rect.top + 'px;width:' + rect.width + 'px;height:' + rect.height + 'px;';
1112 document.body.appendChild(overlay);
1113 }});
1114 }})();
1115 "#,
1116 sel
1117 )
1118 })
1119 .collect();
1120 selectors.join("\n")
1121}
1122
1123#[cfg(feature = "screenshot-diff")]
1126use crate::protocol::Animations;
1127
1128#[cfg(feature = "screenshot-diff")]
1132#[derive(Debug, Clone, Default)]
1133#[non_exhaustive]
1134pub struct ScreenshotAssertionOptions {
1135 pub max_diff_pixels: Option<u32>,
1137 pub max_diff_pixel_ratio: Option<f64>,
1139 pub threshold: Option<f64>,
1141 pub animations: Option<Animations>,
1143 pub mask: Option<Vec<Locator>>,
1145 pub update_snapshots: Option<bool>,
1147}
1148
1149#[cfg(feature = "screenshot-diff")]
1150impl ScreenshotAssertionOptions {
1151 pub fn builder() -> ScreenshotAssertionOptionsBuilder {
1153 ScreenshotAssertionOptionsBuilder::default()
1154 }
1155}
1156
1157#[cfg(feature = "screenshot-diff")]
1159#[derive(Debug, Clone, Default)]
1160pub struct ScreenshotAssertionOptionsBuilder {
1161 max_diff_pixels: Option<u32>,
1162 max_diff_pixel_ratio: Option<f64>,
1163 threshold: Option<f64>,
1164 animations: Option<Animations>,
1165 mask: Option<Vec<Locator>>,
1166 update_snapshots: Option<bool>,
1167}
1168
1169#[cfg(feature = "screenshot-diff")]
1170impl ScreenshotAssertionOptionsBuilder {
1171 pub fn max_diff_pixels(mut self, pixels: u32) -> Self {
1173 self.max_diff_pixels = Some(pixels);
1174 self
1175 }
1176
1177 pub fn max_diff_pixel_ratio(mut self, ratio: f64) -> Self {
1179 self.max_diff_pixel_ratio = Some(ratio);
1180 self
1181 }
1182
1183 pub fn threshold(mut self, threshold: f64) -> Self {
1185 self.threshold = Some(threshold);
1186 self
1187 }
1188
1189 pub fn animations(mut self, animations: Animations) -> Self {
1191 self.animations = Some(animations);
1192 self
1193 }
1194
1195 pub fn mask(mut self, locators: Vec<Locator>) -> Self {
1197 self.mask = Some(locators);
1198 self
1199 }
1200
1201 pub fn update_snapshots(mut self, update: bool) -> Self {
1203 self.update_snapshots = Some(update);
1204 self
1205 }
1206
1207 pub fn build(self) -> ScreenshotAssertionOptions {
1209 ScreenshotAssertionOptions {
1210 max_diff_pixels: self.max_diff_pixels,
1211 max_diff_pixel_ratio: self.max_diff_pixel_ratio,
1212 threshold: self.threshold,
1213 animations: self.animations,
1214 mask: self.mask,
1215 update_snapshots: self.update_snapshots,
1216 }
1217 }
1218}
1219
1220pub fn expect_page(page: &Page) -> PageExpectation {
1224 PageExpectation::new(page.clone())
1225}
1226
1227#[allow(clippy::wrong_self_convention)]
1229pub struct PageExpectation {
1230 page: Page,
1231 timeout: Duration,
1232 poll_interval: Duration,
1233 negate: bool,
1234}
1235
1236impl PageExpectation {
1237 fn new(page: Page) -> Self {
1238 Self {
1239 page,
1240 timeout: DEFAULT_ASSERTION_TIMEOUT,
1241 poll_interval: DEFAULT_POLL_INTERVAL,
1242 negate: false,
1243 }
1244 }
1245
1246 pub fn with_timeout(mut self, timeout: Duration) -> Self {
1248 self.timeout = timeout;
1249 self
1250 }
1251
1252 #[allow(clippy::should_implement_trait)]
1254 pub fn not(mut self) -> Self {
1255 self.negate = true;
1256 self
1257 }
1258
1259 pub async fn to_have_title(self, expected: &str) -> Result<()> {
1265 let start = std::time::Instant::now();
1266 let expected = expected.trim();
1267
1268 loop {
1269 let actual = self.page.title().await?;
1270 let actual = actual.trim();
1271
1272 let matches = if self.negate {
1273 actual != expected
1274 } else {
1275 actual == expected
1276 };
1277
1278 if matches {
1279 return Ok(());
1280 }
1281
1282 if start.elapsed() >= self.timeout {
1283 let message = if self.negate {
1284 format!(
1285 "Expected page NOT to have title '{}', but it did after {:?}",
1286 expected, self.timeout,
1287 )
1288 } else {
1289 format!(
1290 "Expected page to have title '{}', but got '{}' after {:?}",
1291 expected, actual, self.timeout,
1292 )
1293 };
1294 return Err(crate::error::Error::AssertionTimeout(message));
1295 }
1296
1297 tokio::time::sleep(self.poll_interval).await;
1298 }
1299 }
1300
1301 pub async fn to_have_title_regex(self, pattern: &str) -> Result<()> {
1307 let start = std::time::Instant::now();
1308 let re = regex::Regex::new(pattern)
1309 .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
1310
1311 loop {
1312 let actual = self.page.title().await?;
1313
1314 let matches = if self.negate {
1315 !re.is_match(&actual)
1316 } else {
1317 re.is_match(&actual)
1318 };
1319
1320 if matches {
1321 return Ok(());
1322 }
1323
1324 if start.elapsed() >= self.timeout {
1325 let message = if self.negate {
1326 format!(
1327 "Expected page title NOT to match '{}', but '{}' matched after {:?}",
1328 pattern, actual, self.timeout,
1329 )
1330 } else {
1331 format!(
1332 "Expected page title to match '{}', but got '{}' after {:?}",
1333 pattern, actual, self.timeout,
1334 )
1335 };
1336 return Err(crate::error::Error::AssertionTimeout(message));
1337 }
1338
1339 tokio::time::sleep(self.poll_interval).await;
1340 }
1341 }
1342
1343 pub async fn to_match_aria_snapshot(self, expected: &str) -> Result<()> {
1368 use crate::protocol::serialize_argument;
1369
1370 let timeout_ms = self.timeout.as_millis() as f64;
1371 let expected_value = serialize_argument(&serde_json::Value::String(expected.to_string()));
1372
1373 let frame = self.page.main_frame().await?;
1374 frame
1375 .frame_expect(
1376 ":root",
1377 "to.match.aria",
1378 expected_value,
1379 self.negate,
1380 timeout_ms,
1381 )
1382 .await
1383 }
1384
1385 pub async fn to_have_url(self, expected: &str) -> Result<()> {
1391 let start = std::time::Instant::now();
1392
1393 loop {
1394 let actual = self.page.url();
1395
1396 let matches = if self.negate {
1397 actual != expected
1398 } else {
1399 actual == expected
1400 };
1401
1402 if matches {
1403 return Ok(());
1404 }
1405
1406 if start.elapsed() >= self.timeout {
1407 let message = if self.negate {
1408 format!(
1409 "Expected page NOT to have URL '{}', but it did after {:?}",
1410 expected, self.timeout,
1411 )
1412 } else {
1413 format!(
1414 "Expected page to have URL '{}', but got '{}' after {:?}",
1415 expected, actual, self.timeout,
1416 )
1417 };
1418 return Err(crate::error::Error::AssertionTimeout(message));
1419 }
1420
1421 tokio::time::sleep(self.poll_interval).await;
1422 }
1423 }
1424
1425 pub async fn to_have_url_regex(self, pattern: &str) -> Result<()> {
1431 let start = std::time::Instant::now();
1432 let re = regex::Regex::new(pattern)
1433 .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
1434
1435 loop {
1436 let actual = self.page.url();
1437
1438 let matches = if self.negate {
1439 !re.is_match(&actual)
1440 } else {
1441 re.is_match(&actual)
1442 };
1443
1444 if matches {
1445 return Ok(());
1446 }
1447
1448 if start.elapsed() >= self.timeout {
1449 let message = if self.negate {
1450 format!(
1451 "Expected page URL NOT to match '{}', but '{}' matched after {:?}",
1452 pattern, actual, self.timeout,
1453 )
1454 } else {
1455 format!(
1456 "Expected page URL to match '{}', but got '{}' after {:?}",
1457 pattern, actual, self.timeout,
1458 )
1459 };
1460 return Err(crate::error::Error::AssertionTimeout(message));
1461 }
1462
1463 tokio::time::sleep(self.poll_interval).await;
1464 }
1465 }
1466
1467 #[cfg(feature = "screenshot-diff")]
1473 pub async fn to_have_screenshot(
1474 self,
1475 baseline_path: impl AsRef<Path>,
1476 options: Option<ScreenshotAssertionOptions>,
1477 ) -> Result<()> {
1478 let opts = options.unwrap_or_default();
1479 let baseline_path = baseline_path.as_ref();
1480
1481 if opts.animations == Some(Animations::Disabled) {
1483 let _ = self.page.evaluate_expression(DISABLE_ANIMATIONS_JS).await;
1484 }
1485
1486 if let Some(ref mask_locators) = opts.mask {
1488 let mask_js = build_mask_js(mask_locators);
1489 let _ = self.page.evaluate_expression(&mask_js).await;
1490 }
1491
1492 compare_screenshot(
1493 &opts,
1494 baseline_path,
1495 self.timeout,
1496 self.poll_interval,
1497 self.negate,
1498 || async { self.page.screenshot(None).await },
1499 )
1500 .await
1501 }
1502}
1503
1504#[cfg(feature = "screenshot-diff")]
1506async fn compare_screenshot<F, Fut>(
1507 opts: &ScreenshotAssertionOptions,
1508 baseline_path: &Path,
1509 timeout: Duration,
1510 poll_interval: Duration,
1511 negate: bool,
1512 take_screenshot: F,
1513) -> Result<()>
1514where
1515 F: Fn() -> Fut,
1516 Fut: std::future::Future<Output = Result<Vec<u8>>>,
1517{
1518 let threshold = opts.threshold.unwrap_or(0.2);
1519 let max_diff_pixels = opts.max_diff_pixels;
1520 let max_diff_pixel_ratio = opts.max_diff_pixel_ratio;
1521 let update_snapshots = opts.update_snapshots.unwrap_or(false);
1522
1523 let actual_bytes = take_screenshot().await?;
1525
1526 if !baseline_path.exists() || update_snapshots {
1528 if let Some(parent) = baseline_path.parent() {
1529 tokio::fs::create_dir_all(parent).await.map_err(|e| {
1530 crate::error::Error::ProtocolError(format!(
1531 "Failed to create baseline directory: {}",
1532 e
1533 ))
1534 })?;
1535 }
1536 tokio::fs::write(baseline_path, &actual_bytes)
1537 .await
1538 .map_err(|e| {
1539 crate::error::Error::ProtocolError(format!(
1540 "Failed to write baseline screenshot: {}",
1541 e
1542 ))
1543 })?;
1544 return Ok(());
1545 }
1546
1547 let baseline_bytes = tokio::fs::read(baseline_path).await.map_err(|e| {
1549 crate::error::Error::ProtocolError(format!("Failed to read baseline screenshot: {}", e))
1550 })?;
1551
1552 let start = std::time::Instant::now();
1553
1554 loop {
1555 let screenshot_bytes = if start.elapsed().is_zero() {
1556 actual_bytes.clone()
1557 } else {
1558 take_screenshot().await?
1559 };
1560
1561 let comparison = compare_images(&baseline_bytes, &screenshot_bytes, threshold)?;
1562
1563 let within_tolerance =
1564 is_within_tolerance(&comparison, max_diff_pixels, max_diff_pixel_ratio);
1565
1566 let matches = if negate {
1567 !within_tolerance
1568 } else {
1569 within_tolerance
1570 };
1571
1572 if matches {
1573 return Ok(());
1574 }
1575
1576 if start.elapsed() >= timeout {
1577 if negate {
1578 return Err(crate::error::Error::AssertionTimeout(format!(
1579 "Expected screenshots NOT to match, but they matched after {:?}",
1580 timeout
1581 )));
1582 }
1583
1584 let baseline_stem = baseline_path
1586 .file_stem()
1587 .and_then(|s| s.to_str())
1588 .unwrap_or("screenshot");
1589 let baseline_ext = baseline_path
1590 .extension()
1591 .and_then(|s| s.to_str())
1592 .unwrap_or("png");
1593 let baseline_dir = baseline_path.parent().unwrap_or(Path::new("."));
1594
1595 let actual_path =
1596 baseline_dir.join(format!("{}-actual.{}", baseline_stem, baseline_ext));
1597 let diff_path = baseline_dir.join(format!("{}-diff.{}", baseline_stem, baseline_ext));
1598
1599 let _ = tokio::fs::write(&actual_path, &screenshot_bytes).await;
1600
1601 if let Ok(diff_bytes) =
1602 generate_diff_image(&baseline_bytes, &screenshot_bytes, threshold)
1603 {
1604 let _ = tokio::fs::write(&diff_path, diff_bytes).await;
1605 }
1606
1607 return Err(crate::error::Error::AssertionTimeout(format!(
1608 "Screenshot mismatch: {} pixels differ ({:.2}% of total). \
1609 Max allowed: {}. Threshold: {:.2}. \
1610 Actual saved to: {}. Diff saved to: {}. \
1611 Timed out after {:?}",
1612 comparison.diff_count,
1613 comparison.diff_ratio * 100.0,
1614 max_diff_pixels
1615 .map(|p| p.to_string())
1616 .or_else(|| max_diff_pixel_ratio.map(|r| format!("{:.2}%", r * 100.0)))
1617 .unwrap_or_else(|| "0".to_string()),
1618 threshold,
1619 actual_path.display(),
1620 diff_path.display(),
1621 timeout,
1622 )));
1623 }
1624
1625 tokio::time::sleep(poll_interval).await;
1626 }
1627}
1628
1629#[cfg(feature = "screenshot-diff")]
1631struct ImageComparison {
1632 diff_count: u32,
1633 diff_ratio: f64,
1634}
1635
1636#[cfg(feature = "screenshot-diff")]
1637fn is_within_tolerance(
1638 comparison: &ImageComparison,
1639 max_diff_pixels: Option<u32>,
1640 max_diff_pixel_ratio: Option<f64>,
1641) -> bool {
1642 if let Some(max_pixels) = max_diff_pixels {
1643 if comparison.diff_count > max_pixels {
1644 return false;
1645 }
1646 } else if let Some(max_ratio) = max_diff_pixel_ratio {
1647 if comparison.diff_ratio > max_ratio {
1648 return false;
1649 }
1650 } else {
1651 if comparison.diff_count > 0 {
1653 return false;
1654 }
1655 }
1656 true
1657}
1658
1659#[cfg(feature = "screenshot-diff")]
1661fn compare_images(
1662 baseline_bytes: &[u8],
1663 actual_bytes: &[u8],
1664 threshold: f64,
1665) -> Result<ImageComparison> {
1666 use image::GenericImageView;
1667
1668 let baseline_img = image::load_from_memory(baseline_bytes).map_err(|e| {
1669 crate::error::Error::ProtocolError(format!("Failed to decode baseline image: {}", e))
1670 })?;
1671 let actual_img = image::load_from_memory(actual_bytes).map_err(|e| {
1672 crate::error::Error::ProtocolError(format!("Failed to decode actual image: {}", e))
1673 })?;
1674
1675 let (bw, bh) = baseline_img.dimensions();
1676 let (aw, ah) = actual_img.dimensions();
1677
1678 if bw != aw || bh != ah {
1680 let total = bw.max(aw) * bh.max(ah);
1681 return Ok(ImageComparison {
1682 diff_count: total,
1683 diff_ratio: 1.0,
1684 });
1685 }
1686
1687 let total_pixels = bw * bh;
1688 if total_pixels == 0 {
1689 return Ok(ImageComparison {
1690 diff_count: 0,
1691 diff_ratio: 0.0,
1692 });
1693 }
1694
1695 let threshold_sq = threshold * threshold;
1696 let mut diff_count: u32 = 0;
1697
1698 for y in 0..bh {
1699 for x in 0..bw {
1700 let bp = baseline_img.get_pixel(x, y);
1701 let ap = actual_img.get_pixel(x, y);
1702
1703 let dr = (bp[0] as f64 - ap[0] as f64) / 255.0;
1705 let dg = (bp[1] as f64 - ap[1] as f64) / 255.0;
1706 let db = (bp[2] as f64 - ap[2] as f64) / 255.0;
1707 let da = (bp[3] as f64 - ap[3] as f64) / 255.0;
1708
1709 let dist_sq = (dr * dr + dg * dg + db * db + da * da) / 4.0;
1710
1711 if dist_sq > threshold_sq {
1712 diff_count += 1;
1713 }
1714 }
1715 }
1716
1717 Ok(ImageComparison {
1718 diff_count,
1719 diff_ratio: diff_count as f64 / total_pixels as f64,
1720 })
1721}
1722
1723#[cfg(feature = "screenshot-diff")]
1725fn generate_diff_image(
1726 baseline_bytes: &[u8],
1727 actual_bytes: &[u8],
1728 threshold: f64,
1729) -> Result<Vec<u8>> {
1730 use image::{GenericImageView, ImageBuffer, Rgba};
1731
1732 let baseline_img = image::load_from_memory(baseline_bytes).map_err(|e| {
1733 crate::error::Error::ProtocolError(format!("Failed to decode baseline image: {}", e))
1734 })?;
1735 let actual_img = image::load_from_memory(actual_bytes).map_err(|e| {
1736 crate::error::Error::ProtocolError(format!("Failed to decode actual image: {}", e))
1737 })?;
1738
1739 let (bw, bh) = baseline_img.dimensions();
1740 let (aw, ah) = actual_img.dimensions();
1741 let width = bw.max(aw);
1742 let height = bh.max(ah);
1743
1744 let threshold_sq = threshold * threshold;
1745
1746 let mut diff_img: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::new(width, height);
1747
1748 for y in 0..height {
1749 for x in 0..width {
1750 if x >= bw || y >= bh || x >= aw || y >= ah {
1751 diff_img.put_pixel(x, y, Rgba([255, 0, 0, 255]));
1753 continue;
1754 }
1755
1756 let bp = baseline_img.get_pixel(x, y);
1757 let ap = actual_img.get_pixel(x, y);
1758
1759 let dr = (bp[0] as f64 - ap[0] as f64) / 255.0;
1760 let dg = (bp[1] as f64 - ap[1] as f64) / 255.0;
1761 let db = (bp[2] as f64 - ap[2] as f64) / 255.0;
1762 let da = (bp[3] as f64 - ap[3] as f64) / 255.0;
1763
1764 let dist_sq = (dr * dr + dg * dg + db * db + da * da) / 4.0;
1765
1766 if dist_sq > threshold_sq {
1767 diff_img.put_pixel(x, y, Rgba([255, 0, 0, 255]));
1769 } else {
1770 let gray = ((ap[0] as u16 + ap[1] as u16 + ap[2] as u16) / 3) as u8;
1772 diff_img.put_pixel(x, y, Rgba([gray, gray, gray, 100]));
1773 }
1774 }
1775 }
1776
1777 let mut output = std::io::Cursor::new(Vec::new());
1778 diff_img
1779 .write_to(&mut output, image::ImageFormat::Png)
1780 .map_err(|e| {
1781 crate::error::Error::ProtocolError(format!("Failed to encode diff image: {}", e))
1782 })?;
1783
1784 Ok(output.into_inner())
1785}
1786
1787#[cfg(test)]
1788mod tests {
1789 use super::*;
1790
1791 #[test]
1792 fn test_expectation_defaults() {
1793 assert_eq!(DEFAULT_ASSERTION_TIMEOUT, Duration::from_secs(5));
1795 assert_eq!(DEFAULT_POLL_INTERVAL, Duration::from_millis(100));
1796 }
1797
1798 #[test]
1799 fn test_normalize_whitespace_collapses_runs_and_trims() {
1800 assert_eq!(
1801 normalize_whitespace("Scan\n→\nGroup\n→\nName"),
1802 "Scan → Group → Name"
1803 );
1804 assert_eq!(normalize_whitespace(" Hello \t world \n"), "Hello world");
1805 assert_eq!(normalize_whitespace("already normal"), "already normal");
1806 assert_eq!(normalize_whitespace(" "), "");
1807 assert_eq!(normalize_whitespace(""), "");
1808 }
1809}