1use crate::error::Result;
8use crate::protocol::{Locator, Page};
9use std::path::Path;
10use std::time::Duration;
11
12const DEFAULT_ASSERTION_TIMEOUT: Duration = Duration::from_secs(5);
14
15const DEFAULT_POLL_INTERVAL: Duration = Duration::from_millis(100);
17
18pub fn expect(locator: Locator) -> Expectation {
98 Expectation::new(locator)
99}
100
101pub struct Expectation {
103 locator: Locator,
104 timeout: Duration,
105 poll_interval: Duration,
106 negate: bool,
107}
108
109#[allow(clippy::wrong_self_convention)]
112impl Expectation {
113 pub(crate) fn new(locator: Locator) -> Self {
115 Self {
116 locator,
117 timeout: DEFAULT_ASSERTION_TIMEOUT,
118 poll_interval: DEFAULT_POLL_INTERVAL,
119 negate: false,
120 }
121 }
122
123 pub fn with_timeout(mut self, timeout: Duration) -> Self {
126 self.timeout = timeout;
127 self
128 }
129
130 pub fn with_poll_interval(mut self, interval: Duration) -> Self {
134 self.poll_interval = interval;
135 self
136 }
137
138 #[allow(clippy::should_implement_trait)]
143 pub fn not(mut self) -> Self {
144 self.negate = true;
145 self
146 }
147
148 pub async fn to_be_visible(self) -> Result<()> {
154 let start = std::time::Instant::now();
155 let selector = self.locator.selector().to_string();
156
157 loop {
158 let is_visible = self.locator.is_visible().await?;
159
160 let matches = if self.negate { !is_visible } else { is_visible };
162
163 if matches {
164 return Ok(());
165 }
166
167 if start.elapsed() >= self.timeout {
169 let message = if self.negate {
170 format!(
171 "Expected element '{}' NOT to be visible, but it was visible after {:?}",
172 selector, self.timeout
173 )
174 } else {
175 format!(
176 "Expected element '{}' to be visible, but it was not visible after {:?}",
177 selector, self.timeout
178 )
179 };
180 return Err(crate::error::Error::AssertionTimeout(message));
181 }
182
183 tokio::time::sleep(self.poll_interval).await;
185 }
186 }
187
188 pub async fn to_be_hidden(self) -> Result<()> {
194 let negated = Expectation {
197 negate: !self.negate, ..self
199 };
200 negated.to_be_visible().await
201 }
202
203 pub async fn to_have_text(self, expected: &str) -> Result<()> {
210 let start = std::time::Instant::now();
211 let selector = self.locator.selector().to_string();
212 let expected = expected.trim();
213
214 loop {
215 let actual_text = self.locator.inner_text().await?;
217 let actual = actual_text.trim();
218
219 let matches = if self.negate {
221 actual != expected
222 } else {
223 actual == expected
224 };
225
226 if matches {
227 return Ok(());
228 }
229
230 if start.elapsed() >= self.timeout {
232 let message = if self.negate {
233 format!(
234 "Expected element '{}' NOT to have text '{}', but it did after {:?}",
235 selector, expected, self.timeout
236 )
237 } else {
238 format!(
239 "Expected element '{}' to have text '{}', but had '{}' after {:?}",
240 selector, expected, actual, self.timeout
241 )
242 };
243 return Err(crate::error::Error::AssertionTimeout(message));
244 }
245
246 tokio::time::sleep(self.poll_interval).await;
248 }
249 }
250
251 pub async fn to_have_text_regex(self, pattern: &str) -> Result<()> {
255 let start = std::time::Instant::now();
256 let selector = self.locator.selector().to_string();
257 let re = regex::Regex::new(pattern)
258 .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
259
260 loop {
261 let actual_text = self.locator.inner_text().await?;
262 let actual = actual_text.trim();
263
264 let matches = if self.negate {
266 !re.is_match(actual)
267 } else {
268 re.is_match(actual)
269 };
270
271 if matches {
272 return Ok(());
273 }
274
275 if start.elapsed() >= self.timeout {
277 let message = if self.negate {
278 format!(
279 "Expected element '{}' NOT to match pattern '{}', but it did after {:?}",
280 selector, pattern, self.timeout
281 )
282 } else {
283 format!(
284 "Expected element '{}' to match pattern '{}', but had '{}' after {:?}",
285 selector, pattern, actual, self.timeout
286 )
287 };
288 return Err(crate::error::Error::AssertionTimeout(message));
289 }
290
291 tokio::time::sleep(self.poll_interval).await;
293 }
294 }
295
296 pub async fn to_contain_text(self, expected: &str) -> Result<()> {
302 let start = std::time::Instant::now();
303 let selector = self.locator.selector().to_string();
304
305 loop {
306 let actual_text = self.locator.inner_text().await?;
307 let actual = actual_text.trim();
308
309 let matches = if self.negate {
311 !actual.contains(expected)
312 } else {
313 actual.contains(expected)
314 };
315
316 if matches {
317 return Ok(());
318 }
319
320 if start.elapsed() >= self.timeout {
322 let message = if self.negate {
323 format!(
324 "Expected element '{}' NOT to contain text '{}', but it did after {:?}",
325 selector, expected, self.timeout
326 )
327 } else {
328 format!(
329 "Expected element '{}' to contain text '{}', but had '{}' after {:?}",
330 selector, expected, actual, self.timeout
331 )
332 };
333 return Err(crate::error::Error::AssertionTimeout(message));
334 }
335
336 tokio::time::sleep(self.poll_interval).await;
338 }
339 }
340
341 pub async fn to_contain_text_regex(self, pattern: &str) -> Result<()> {
345 let start = std::time::Instant::now();
346 let selector = self.locator.selector().to_string();
347 let re = regex::Regex::new(pattern)
348 .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
349
350 loop {
351 let actual_text = self.locator.inner_text().await?;
352 let actual = actual_text.trim();
353
354 let matches = if self.negate {
356 !re.is_match(actual)
357 } else {
358 re.is_match(actual)
359 };
360
361 if matches {
362 return Ok(());
363 }
364
365 if start.elapsed() >= self.timeout {
367 let message = if self.negate {
368 format!(
369 "Expected element '{}' NOT to contain pattern '{}', but it did after {:?}",
370 selector, pattern, self.timeout
371 )
372 } else {
373 format!(
374 "Expected element '{}' to contain pattern '{}', but had '{}' after {:?}",
375 selector, pattern, actual, self.timeout
376 )
377 };
378 return Err(crate::error::Error::AssertionTimeout(message));
379 }
380
381 tokio::time::sleep(self.poll_interval).await;
383 }
384 }
385
386 pub async fn to_have_value(self, expected: &str) -> Result<()> {
392 let start = std::time::Instant::now();
393 let selector = self.locator.selector().to_string();
394
395 loop {
396 let actual = self.locator.input_value(None).await?;
397
398 let matches = if self.negate {
400 actual != expected
401 } else {
402 actual == expected
403 };
404
405 if matches {
406 return Ok(());
407 }
408
409 if start.elapsed() >= self.timeout {
411 let message = if self.negate {
412 format!(
413 "Expected input '{}' NOT to have value '{}', but it did after {:?}",
414 selector, expected, self.timeout
415 )
416 } else {
417 format!(
418 "Expected input '{}' to have value '{}', but had '{}' after {:?}",
419 selector, expected, actual, self.timeout
420 )
421 };
422 return Err(crate::error::Error::AssertionTimeout(message));
423 }
424
425 tokio::time::sleep(self.poll_interval).await;
427 }
428 }
429
430 pub async fn to_have_value_regex(self, pattern: &str) -> Result<()> {
434 let start = std::time::Instant::now();
435 let selector = self.locator.selector().to_string();
436 let re = regex::Regex::new(pattern)
437 .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
438
439 loop {
440 let actual = self.locator.input_value(None).await?;
441
442 let matches = if self.negate {
444 !re.is_match(&actual)
445 } else {
446 re.is_match(&actual)
447 };
448
449 if matches {
450 return Ok(());
451 }
452
453 if start.elapsed() >= self.timeout {
455 let message = if self.negate {
456 format!(
457 "Expected input '{}' NOT to match pattern '{}', but it did after {:?}",
458 selector, pattern, self.timeout
459 )
460 } else {
461 format!(
462 "Expected input '{}' to match pattern '{}', but had '{}' after {:?}",
463 selector, pattern, actual, self.timeout
464 )
465 };
466 return Err(crate::error::Error::AssertionTimeout(message));
467 }
468
469 tokio::time::sleep(self.poll_interval).await;
471 }
472 }
473
474 pub async fn to_be_enabled(self) -> Result<()> {
481 let start = std::time::Instant::now();
482 let selector = self.locator.selector().to_string();
483
484 loop {
485 let is_enabled = self.locator.is_enabled().await?;
486
487 let matches = if self.negate { !is_enabled } else { is_enabled };
489
490 if matches {
491 return Ok(());
492 }
493
494 if start.elapsed() >= self.timeout {
496 let message = if self.negate {
497 format!(
498 "Expected element '{}' NOT to be enabled, but it was enabled after {:?}",
499 selector, self.timeout
500 )
501 } else {
502 format!(
503 "Expected element '{}' to be enabled, but it was not enabled after {:?}",
504 selector, self.timeout
505 )
506 };
507 return Err(crate::error::Error::AssertionTimeout(message));
508 }
509
510 tokio::time::sleep(self.poll_interval).await;
512 }
513 }
514
515 pub async fn to_be_disabled(self) -> Result<()> {
522 let negated = Expectation {
525 negate: !self.negate, ..self
527 };
528 negated.to_be_enabled().await
529 }
530
531 pub async fn to_be_checked(self) -> Result<()> {
537 let start = std::time::Instant::now();
538 let selector = self.locator.selector().to_string();
539
540 loop {
541 let is_checked = self.locator.is_checked().await?;
542
543 let matches = if self.negate { !is_checked } else { is_checked };
545
546 if matches {
547 return Ok(());
548 }
549
550 if start.elapsed() >= self.timeout {
552 let message = if self.negate {
553 format!(
554 "Expected element '{}' NOT to be checked, but it was checked after {:?}",
555 selector, self.timeout
556 )
557 } else {
558 format!(
559 "Expected element '{}' to be checked, but it was not checked after {:?}",
560 selector, self.timeout
561 )
562 };
563 return Err(crate::error::Error::AssertionTimeout(message));
564 }
565
566 tokio::time::sleep(self.poll_interval).await;
568 }
569 }
570
571 pub async fn to_be_unchecked(self) -> Result<()> {
577 let negated = Expectation {
580 negate: !self.negate, ..self
582 };
583 negated.to_be_checked().await
584 }
585
586 pub async fn to_be_editable(self) -> Result<()> {
593 let start = std::time::Instant::now();
594 let selector = self.locator.selector().to_string();
595
596 loop {
597 let is_editable = self.locator.is_editable().await?;
598
599 let matches = if self.negate {
601 !is_editable
602 } else {
603 is_editable
604 };
605
606 if matches {
607 return Ok(());
608 }
609
610 if start.elapsed() >= self.timeout {
612 let message = if self.negate {
613 format!(
614 "Expected element '{}' NOT to be editable, but it was editable after {:?}",
615 selector, self.timeout
616 )
617 } else {
618 format!(
619 "Expected element '{}' to be editable, but it was not editable after {:?}",
620 selector, self.timeout
621 )
622 };
623 return Err(crate::error::Error::AssertionTimeout(message));
624 }
625
626 tokio::time::sleep(self.poll_interval).await;
628 }
629 }
630
631 pub async fn to_be_focused(self) -> Result<()> {
637 let start = std::time::Instant::now();
638 let selector = self.locator.selector().to_string();
639
640 loop {
641 let is_focused = self.locator.is_focused().await?;
642
643 let matches = if self.negate { !is_focused } else { is_focused };
645
646 if matches {
647 return Ok(());
648 }
649
650 if start.elapsed() >= self.timeout {
652 let message = if self.negate {
653 format!(
654 "Expected element '{}' NOT to be focused, but it was focused after {:?}",
655 selector, self.timeout
656 )
657 } else {
658 format!(
659 "Expected element '{}' to be focused, but it was not focused after {:?}",
660 selector, self.timeout
661 )
662 };
663 return Err(crate::error::Error::AssertionTimeout(message));
664 }
665
666 tokio::time::sleep(self.poll_interval).await;
668 }
669 }
670
671 pub async fn to_have_attribute(self, name: &str, value: &str) -> Result<()> {
677 let start = std::time::Instant::now();
678 let selector = self.locator.selector().to_string();
679
680 loop {
681 let actual = self.locator.get_attribute(name).await?;
682
683 let matched = actual.as_deref() == Some(value);
684 let matches = if self.negate { !matched } else { matched };
685
686 if matches {
687 return Ok(());
688 }
689
690 if start.elapsed() >= self.timeout {
691 let actual_display = actual.as_deref().unwrap_or("<missing>");
692 let message = if self.negate {
693 format!(
694 "Expected element '{}' NOT to have attribute '{}'='{}', but it did after {:?}",
695 selector, name, value, self.timeout
696 )
697 } else {
698 format!(
699 "Expected element '{}' to have attribute '{}'='{}', but had '{}' after {:?}",
700 selector, name, value, actual_display, self.timeout
701 )
702 };
703 return Err(crate::error::Error::AssertionTimeout(message));
704 }
705
706 tokio::time::sleep(self.poll_interval).await;
707 }
708 }
709
710 pub async fn to_have_attribute_regex(self, name: &str, pattern: &str) -> Result<()> {
714 let start = std::time::Instant::now();
715 let selector = self.locator.selector().to_string();
716 let re = regex::Regex::new(pattern)
717 .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
718
719 loop {
720 let actual = self.locator.get_attribute(name).await?;
721
722 let matched = actual.as_deref().is_some_and(|v| re.is_match(v));
723 let matches = if self.negate { !matched } else { matched };
724
725 if matches {
726 return Ok(());
727 }
728
729 if start.elapsed() >= self.timeout {
730 let actual_display = actual.as_deref().unwrap_or("<missing>");
731 let message = if self.negate {
732 format!(
733 "Expected element '{}' attribute '{}' NOT to match pattern '{}', but it did after {:?}",
734 selector, name, pattern, self.timeout
735 )
736 } else {
737 format!(
738 "Expected element '{}' attribute '{}' to match pattern '{}', but had '{}' after {:?}",
739 selector, name, pattern, actual_display, self.timeout
740 )
741 };
742 return Err(crate::error::Error::AssertionTimeout(message));
743 }
744
745 tokio::time::sleep(self.poll_interval).await;
746 }
747 }
748
749 pub async fn to_have_class(self, expected: &str) -> Result<()> {
757 let start = std::time::Instant::now();
758 let selector = self.locator.selector().to_string();
759
760 loop {
761 let actual = self
762 .locator
763 .get_attribute("class")
764 .await?
765 .unwrap_or_default();
766 let actual_trimmed = actual.trim();
767
768 let matched = actual_trimmed == expected;
769 let matches = if self.negate { !matched } else { matched };
770
771 if matches {
772 return Ok(());
773 }
774
775 if start.elapsed() >= self.timeout {
776 let message = if self.negate {
777 format!(
778 "Expected element '{}' NOT to have class '{}', but it did after {:?}",
779 selector, expected, self.timeout
780 )
781 } else {
782 format!(
783 "Expected element '{}' to have class '{}', but had '{}' after {:?}",
784 selector, expected, actual_trimmed, self.timeout
785 )
786 };
787 return Err(crate::error::Error::AssertionTimeout(message));
788 }
789
790 tokio::time::sleep(self.poll_interval).await;
791 }
792 }
793
794 pub async fn to_have_class_regex(self, pattern: &str) -> Result<()> {
796 let start = std::time::Instant::now();
797 let selector = self.locator.selector().to_string();
798 let re = regex::Regex::new(pattern)
799 .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
800
801 loop {
802 let actual = self
803 .locator
804 .get_attribute("class")
805 .await?
806 .unwrap_or_default();
807
808 let matched = re.is_match(&actual);
809 let matches = if self.negate { !matched } else { matched };
810
811 if matches {
812 return Ok(());
813 }
814
815 if start.elapsed() >= self.timeout {
816 let message = if self.negate {
817 format!(
818 "Expected element '{}' class NOT to match pattern '{}', but it did after {:?}",
819 selector, pattern, self.timeout
820 )
821 } else {
822 format!(
823 "Expected element '{}' class to match pattern '{}', but had '{}' after {:?}",
824 selector, pattern, actual, self.timeout
825 )
826 };
827 return Err(crate::error::Error::AssertionTimeout(message));
828 }
829
830 tokio::time::sleep(self.poll_interval).await;
831 }
832 }
833
834 pub async fn to_have_css(self, name: &str, value: &str) -> Result<()> {
842 let start = std::time::Instant::now();
843 let selector = self.locator.selector().to_string();
844 let expr = format!(
845 "(el) => getComputedStyle(el).getPropertyValue({})",
846 serde_json::to_string(name).unwrap()
847 );
848
849 loop {
850 let actual: String = self.locator.evaluate(&expr, None::<()>).await?;
851
852 let matched = actual == value;
853 let matches = if self.negate { !matched } else { matched };
854
855 if matches {
856 return Ok(());
857 }
858
859 if start.elapsed() >= self.timeout {
860 let message = if self.negate {
861 format!(
862 "Expected element '{}' NOT to have CSS '{}'='{}', but it did after {:?}",
863 selector, name, value, self.timeout
864 )
865 } else {
866 format!(
867 "Expected element '{}' to have CSS '{}'='{}', but had '{}' after {:?}",
868 selector, name, value, actual, self.timeout
869 )
870 };
871 return Err(crate::error::Error::AssertionTimeout(message));
872 }
873
874 tokio::time::sleep(self.poll_interval).await;
875 }
876 }
877
878 pub async fn to_have_css_regex(self, name: &str, pattern: &str) -> Result<()> {
880 let start = std::time::Instant::now();
881 let selector = self.locator.selector().to_string();
882 let re = regex::Regex::new(pattern)
883 .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
884 let expr = format!(
885 "(el) => getComputedStyle(el).getPropertyValue({})",
886 serde_json::to_string(name).unwrap()
887 );
888
889 loop {
890 let actual: String = self.locator.evaluate(&expr, None::<()>).await?;
891
892 let matched = re.is_match(&actual);
893 let matches = if self.negate { !matched } else { matched };
894
895 if matches {
896 return Ok(());
897 }
898
899 if start.elapsed() >= self.timeout {
900 let message = if self.negate {
901 format!(
902 "Expected element '{}' CSS '{}' NOT to match pattern '{}', but it did after {:?}",
903 selector, name, pattern, self.timeout
904 )
905 } else {
906 format!(
907 "Expected element '{}' CSS '{}' to match pattern '{}', but had '{}' after {:?}",
908 selector, name, pattern, actual, self.timeout
909 )
910 };
911 return Err(crate::error::Error::AssertionTimeout(message));
912 }
913
914 tokio::time::sleep(self.poll_interval).await;
915 }
916 }
917
918 pub async fn to_have_count(self, count: usize) -> Result<()> {
922 let start = std::time::Instant::now();
923 let selector = self.locator.selector().to_string();
924
925 loop {
926 let actual = self.locator.count().await?;
927
928 let matched = actual == count;
929 let matches = if self.negate { !matched } else { matched };
930
931 if matches {
932 return Ok(());
933 }
934
935 if start.elapsed() >= self.timeout {
936 let message = if self.negate {
937 format!(
938 "Expected locator '{}' NOT to have count {}, but it did after {:?}",
939 selector, count, self.timeout
940 )
941 } else {
942 format!(
943 "Expected locator '{}' to have count {}, but had {} after {:?}",
944 selector, count, actual, self.timeout
945 )
946 };
947 return Err(crate::error::Error::AssertionTimeout(message));
948 }
949
950 tokio::time::sleep(self.poll_interval).await;
951 }
952 }
953
954 pub async fn to_match_aria_snapshot(self, expected: &str) -> Result<()> {
969 use crate::protocol::serialize_argument;
970
971 let selector = self.locator.selector().to_string();
972 let timeout_ms = self.timeout.as_millis() as f64;
973 let expected_value = serialize_argument(&serde_json::Value::String(expected.to_string()));
974
975 self.locator
976 .frame()
977 .frame_expect(
978 &selector,
979 "to.match.aria",
980 expected_value,
981 self.negate,
982 timeout_ms,
983 )
984 .await
985 }
986
987 pub async fn to_have_screenshot(
994 self,
995 baseline_path: impl AsRef<Path>,
996 options: Option<ScreenshotAssertionOptions>,
997 ) -> Result<()> {
998 let opts = options.unwrap_or_default();
999 let baseline_path = baseline_path.as_ref();
1000
1001 if opts.animations == Some(Animations::Disabled) {
1003 let _ = self
1004 .locator
1005 .evaluate_js(DISABLE_ANIMATIONS_JS, None::<&()>)
1006 .await;
1007 }
1008
1009 let screenshot_opts = if let Some(ref mask_locators) = opts.mask {
1011 let mask_js = build_mask_js(mask_locators);
1013 let _ = self.locator.evaluate_js(&mask_js, None::<&()>).await;
1014 None
1015 } else {
1016 None
1017 };
1018
1019 compare_screenshot(
1020 &opts,
1021 baseline_path,
1022 self.timeout,
1023 self.poll_interval,
1024 self.negate,
1025 || async { self.locator.screenshot(screenshot_opts.clone()).await },
1026 )
1027 .await
1028 }
1029}
1030
1031const DISABLE_ANIMATIONS_JS: &str = r#"
1033(() => {
1034 const style = document.createElement('style');
1035 style.textContent = '*, *::before, *::after { animation-duration: 0s !important; animation-delay: 0s !important; transition-duration: 0s !important; transition-delay: 0s !important; }';
1036 style.setAttribute('data-playwright-no-animations', '');
1037 document.head.appendChild(style);
1038})()
1039"#;
1040
1041fn build_mask_js(locators: &[Locator]) -> String {
1043 let selectors: Vec<String> = locators
1044 .iter()
1045 .map(|l| {
1046 let sel = l.selector().replace('\'', "\\'");
1047 format!(
1048 r#"
1049 (function() {{
1050 var els = document.querySelectorAll('{}');
1051 els.forEach(function(el) {{
1052 var rect = el.getBoundingClientRect();
1053 var overlay = document.createElement('div');
1054 overlay.setAttribute('data-playwright-mask', '');
1055 overlay.style.cssText = 'position:fixed;z-index:2147483647;background:#FF00FF;pointer-events:none;'
1056 + 'left:' + rect.left + 'px;top:' + rect.top + 'px;width:' + rect.width + 'px;height:' + rect.height + 'px;';
1057 document.body.appendChild(overlay);
1058 }});
1059 }})();
1060 "#,
1061 sel
1062 )
1063 })
1064 .collect();
1065 selectors.join("\n")
1066}
1067
1068#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1072pub enum Animations {
1073 Allow,
1075 Disabled,
1077}
1078
1079#[derive(Debug, Clone, Default)]
1083pub struct ScreenshotAssertionOptions {
1084 pub max_diff_pixels: Option<u32>,
1086 pub max_diff_pixel_ratio: Option<f64>,
1088 pub threshold: Option<f64>,
1090 pub animations: Option<Animations>,
1092 pub mask: Option<Vec<Locator>>,
1094 pub update_snapshots: Option<bool>,
1096}
1097
1098impl ScreenshotAssertionOptions {
1099 pub fn builder() -> ScreenshotAssertionOptionsBuilder {
1101 ScreenshotAssertionOptionsBuilder::default()
1102 }
1103}
1104
1105#[derive(Debug, Clone, Default)]
1107pub struct ScreenshotAssertionOptionsBuilder {
1108 max_diff_pixels: Option<u32>,
1109 max_diff_pixel_ratio: Option<f64>,
1110 threshold: Option<f64>,
1111 animations: Option<Animations>,
1112 mask: Option<Vec<Locator>>,
1113 update_snapshots: Option<bool>,
1114}
1115
1116impl ScreenshotAssertionOptionsBuilder {
1117 pub fn max_diff_pixels(mut self, pixels: u32) -> Self {
1119 self.max_diff_pixels = Some(pixels);
1120 self
1121 }
1122
1123 pub fn max_diff_pixel_ratio(mut self, ratio: f64) -> Self {
1125 self.max_diff_pixel_ratio = Some(ratio);
1126 self
1127 }
1128
1129 pub fn threshold(mut self, threshold: f64) -> Self {
1131 self.threshold = Some(threshold);
1132 self
1133 }
1134
1135 pub fn animations(mut self, animations: Animations) -> Self {
1137 self.animations = Some(animations);
1138 self
1139 }
1140
1141 pub fn mask(mut self, locators: Vec<Locator>) -> Self {
1143 self.mask = Some(locators);
1144 self
1145 }
1146
1147 pub fn update_snapshots(mut self, update: bool) -> Self {
1149 self.update_snapshots = Some(update);
1150 self
1151 }
1152
1153 pub fn build(self) -> ScreenshotAssertionOptions {
1155 ScreenshotAssertionOptions {
1156 max_diff_pixels: self.max_diff_pixels,
1157 max_diff_pixel_ratio: self.max_diff_pixel_ratio,
1158 threshold: self.threshold,
1159 animations: self.animations,
1160 mask: self.mask,
1161 update_snapshots: self.update_snapshots,
1162 }
1163 }
1164}
1165
1166pub fn expect_page(page: &Page) -> PageExpectation {
1170 PageExpectation::new(page.clone())
1171}
1172
1173#[allow(clippy::wrong_self_convention)]
1175pub struct PageExpectation {
1176 page: Page,
1177 timeout: Duration,
1178 poll_interval: Duration,
1179 negate: bool,
1180}
1181
1182impl PageExpectation {
1183 fn new(page: Page) -> Self {
1184 Self {
1185 page,
1186 timeout: DEFAULT_ASSERTION_TIMEOUT,
1187 poll_interval: DEFAULT_POLL_INTERVAL,
1188 negate: false,
1189 }
1190 }
1191
1192 pub fn with_timeout(mut self, timeout: Duration) -> Self {
1194 self.timeout = timeout;
1195 self
1196 }
1197
1198 #[allow(clippy::should_implement_trait)]
1200 pub fn not(mut self) -> Self {
1201 self.negate = true;
1202 self
1203 }
1204
1205 pub async fn to_have_title(self, expected: &str) -> Result<()> {
1211 let start = std::time::Instant::now();
1212 let expected = expected.trim();
1213
1214 loop {
1215 let actual = self.page.title().await?;
1216 let actual = actual.trim();
1217
1218 let matches = if self.negate {
1219 actual != expected
1220 } else {
1221 actual == expected
1222 };
1223
1224 if matches {
1225 return Ok(());
1226 }
1227
1228 if start.elapsed() >= self.timeout {
1229 let message = if self.negate {
1230 format!(
1231 "Expected page NOT to have title '{}', but it did after {:?}",
1232 expected, self.timeout,
1233 )
1234 } else {
1235 format!(
1236 "Expected page to have title '{}', but got '{}' after {:?}",
1237 expected, actual, self.timeout,
1238 )
1239 };
1240 return Err(crate::error::Error::AssertionTimeout(message));
1241 }
1242
1243 tokio::time::sleep(self.poll_interval).await;
1244 }
1245 }
1246
1247 pub async fn to_have_title_regex(self, pattern: &str) -> Result<()> {
1253 let start = std::time::Instant::now();
1254 let re = regex::Regex::new(pattern)
1255 .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
1256
1257 loop {
1258 let actual = self.page.title().await?;
1259
1260 let matches = if self.negate {
1261 !re.is_match(&actual)
1262 } else {
1263 re.is_match(&actual)
1264 };
1265
1266 if matches {
1267 return Ok(());
1268 }
1269
1270 if start.elapsed() >= self.timeout {
1271 let message = if self.negate {
1272 format!(
1273 "Expected page title NOT to match '{}', but '{}' matched after {:?}",
1274 pattern, actual, self.timeout,
1275 )
1276 } else {
1277 format!(
1278 "Expected page title to match '{}', but got '{}' after {:?}",
1279 pattern, actual, self.timeout,
1280 )
1281 };
1282 return Err(crate::error::Error::AssertionTimeout(message));
1283 }
1284
1285 tokio::time::sleep(self.poll_interval).await;
1286 }
1287 }
1288
1289 pub async fn to_have_url(self, expected: &str) -> Result<()> {
1295 let start = std::time::Instant::now();
1296
1297 loop {
1298 let actual = self.page.url();
1299
1300 let matches = if self.negate {
1301 actual != expected
1302 } else {
1303 actual == expected
1304 };
1305
1306 if matches {
1307 return Ok(());
1308 }
1309
1310 if start.elapsed() >= self.timeout {
1311 let message = if self.negate {
1312 format!(
1313 "Expected page NOT to have URL '{}', but it did after {:?}",
1314 expected, self.timeout,
1315 )
1316 } else {
1317 format!(
1318 "Expected page to have URL '{}', but got '{}' after {:?}",
1319 expected, actual, self.timeout,
1320 )
1321 };
1322 return Err(crate::error::Error::AssertionTimeout(message));
1323 }
1324
1325 tokio::time::sleep(self.poll_interval).await;
1326 }
1327 }
1328
1329 pub async fn to_have_url_regex(self, pattern: &str) -> Result<()> {
1335 let start = std::time::Instant::now();
1336 let re = regex::Regex::new(pattern)
1337 .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
1338
1339 loop {
1340 let actual = self.page.url();
1341
1342 let matches = if self.negate {
1343 !re.is_match(&actual)
1344 } else {
1345 re.is_match(&actual)
1346 };
1347
1348 if matches {
1349 return Ok(());
1350 }
1351
1352 if start.elapsed() >= self.timeout {
1353 let message = if self.negate {
1354 format!(
1355 "Expected page URL NOT to match '{}', but '{}' matched after {:?}",
1356 pattern, actual, self.timeout,
1357 )
1358 } else {
1359 format!(
1360 "Expected page URL to match '{}', but got '{}' after {:?}",
1361 pattern, actual, self.timeout,
1362 )
1363 };
1364 return Err(crate::error::Error::AssertionTimeout(message));
1365 }
1366
1367 tokio::time::sleep(self.poll_interval).await;
1368 }
1369 }
1370
1371 pub async fn to_have_screenshot(
1375 self,
1376 baseline_path: impl AsRef<Path>,
1377 options: Option<ScreenshotAssertionOptions>,
1378 ) -> Result<()> {
1379 let opts = options.unwrap_or_default();
1380 let baseline_path = baseline_path.as_ref();
1381
1382 if opts.animations == Some(Animations::Disabled) {
1384 let _ = self.page.evaluate_expression(DISABLE_ANIMATIONS_JS).await;
1385 }
1386
1387 if let Some(ref mask_locators) = opts.mask {
1389 let mask_js = build_mask_js(mask_locators);
1390 let _ = self.page.evaluate_expression(&mask_js).await;
1391 }
1392
1393 compare_screenshot(
1394 &opts,
1395 baseline_path,
1396 self.timeout,
1397 self.poll_interval,
1398 self.negate,
1399 || async { self.page.screenshot(None).await },
1400 )
1401 .await
1402 }
1403}
1404
1405async fn compare_screenshot<F, Fut>(
1407 opts: &ScreenshotAssertionOptions,
1408 baseline_path: &Path,
1409 timeout: Duration,
1410 poll_interval: Duration,
1411 negate: bool,
1412 take_screenshot: F,
1413) -> Result<()>
1414where
1415 F: Fn() -> Fut,
1416 Fut: std::future::Future<Output = Result<Vec<u8>>>,
1417{
1418 let threshold = opts.threshold.unwrap_or(0.2);
1419 let max_diff_pixels = opts.max_diff_pixels;
1420 let max_diff_pixel_ratio = opts.max_diff_pixel_ratio;
1421 let update_snapshots = opts.update_snapshots.unwrap_or(false);
1422
1423 let actual_bytes = take_screenshot().await?;
1425
1426 if !baseline_path.exists() || update_snapshots {
1428 if let Some(parent) = baseline_path.parent() {
1429 tokio::fs::create_dir_all(parent).await.map_err(|e| {
1430 crate::error::Error::ProtocolError(format!(
1431 "Failed to create baseline directory: {}",
1432 e
1433 ))
1434 })?;
1435 }
1436 tokio::fs::write(baseline_path, &actual_bytes)
1437 .await
1438 .map_err(|e| {
1439 crate::error::Error::ProtocolError(format!(
1440 "Failed to write baseline screenshot: {}",
1441 e
1442 ))
1443 })?;
1444 return Ok(());
1445 }
1446
1447 let baseline_bytes = tokio::fs::read(baseline_path).await.map_err(|e| {
1449 crate::error::Error::ProtocolError(format!("Failed to read baseline screenshot: {}", e))
1450 })?;
1451
1452 let start = std::time::Instant::now();
1453
1454 loop {
1455 let screenshot_bytes = if start.elapsed().is_zero() {
1456 actual_bytes.clone()
1457 } else {
1458 take_screenshot().await?
1459 };
1460
1461 let comparison = compare_images(&baseline_bytes, &screenshot_bytes, threshold)?;
1462
1463 let within_tolerance =
1464 is_within_tolerance(&comparison, max_diff_pixels, max_diff_pixel_ratio);
1465
1466 let matches = if negate {
1467 !within_tolerance
1468 } else {
1469 within_tolerance
1470 };
1471
1472 if matches {
1473 return Ok(());
1474 }
1475
1476 if start.elapsed() >= timeout {
1477 if negate {
1478 return Err(crate::error::Error::AssertionTimeout(format!(
1479 "Expected screenshots NOT to match, but they matched after {:?}",
1480 timeout
1481 )));
1482 }
1483
1484 let baseline_stem = baseline_path
1486 .file_stem()
1487 .and_then(|s| s.to_str())
1488 .unwrap_or("screenshot");
1489 let baseline_ext = baseline_path
1490 .extension()
1491 .and_then(|s| s.to_str())
1492 .unwrap_or("png");
1493 let baseline_dir = baseline_path.parent().unwrap_or(Path::new("."));
1494
1495 let actual_path =
1496 baseline_dir.join(format!("{}-actual.{}", baseline_stem, baseline_ext));
1497 let diff_path = baseline_dir.join(format!("{}-diff.{}", baseline_stem, baseline_ext));
1498
1499 let _ = tokio::fs::write(&actual_path, &screenshot_bytes).await;
1500
1501 if let Ok(diff_bytes) =
1502 generate_diff_image(&baseline_bytes, &screenshot_bytes, threshold)
1503 {
1504 let _ = tokio::fs::write(&diff_path, diff_bytes).await;
1505 }
1506
1507 return Err(crate::error::Error::AssertionTimeout(format!(
1508 "Screenshot mismatch: {} pixels differ ({:.2}% of total). \
1509 Max allowed: {}. Threshold: {:.2}. \
1510 Actual saved to: {}. Diff saved to: {}. \
1511 Timed out after {:?}",
1512 comparison.diff_count,
1513 comparison.diff_ratio * 100.0,
1514 max_diff_pixels
1515 .map(|p| p.to_string())
1516 .or_else(|| max_diff_pixel_ratio.map(|r| format!("{:.2}%", r * 100.0)))
1517 .unwrap_or_else(|| "0".to_string()),
1518 threshold,
1519 actual_path.display(),
1520 diff_path.display(),
1521 timeout,
1522 )));
1523 }
1524
1525 tokio::time::sleep(poll_interval).await;
1526 }
1527}
1528
1529struct ImageComparison {
1531 diff_count: u32,
1532 diff_ratio: f64,
1533}
1534
1535fn is_within_tolerance(
1536 comparison: &ImageComparison,
1537 max_diff_pixels: Option<u32>,
1538 max_diff_pixel_ratio: Option<f64>,
1539) -> bool {
1540 if let Some(max_pixels) = max_diff_pixels {
1541 if comparison.diff_count > max_pixels {
1542 return false;
1543 }
1544 } else if let Some(max_ratio) = max_diff_pixel_ratio {
1545 if comparison.diff_ratio > max_ratio {
1546 return false;
1547 }
1548 } else {
1549 if comparison.diff_count > 0 {
1551 return false;
1552 }
1553 }
1554 true
1555}
1556
1557fn compare_images(
1559 baseline_bytes: &[u8],
1560 actual_bytes: &[u8],
1561 threshold: f64,
1562) -> Result<ImageComparison> {
1563 use image::GenericImageView;
1564
1565 let baseline_img = image::load_from_memory(baseline_bytes).map_err(|e| {
1566 crate::error::Error::ProtocolError(format!("Failed to decode baseline image: {}", e))
1567 })?;
1568 let actual_img = image::load_from_memory(actual_bytes).map_err(|e| {
1569 crate::error::Error::ProtocolError(format!("Failed to decode actual image: {}", e))
1570 })?;
1571
1572 let (bw, bh) = baseline_img.dimensions();
1573 let (aw, ah) = actual_img.dimensions();
1574
1575 if bw != aw || bh != ah {
1577 let total = bw.max(aw) * bh.max(ah);
1578 return Ok(ImageComparison {
1579 diff_count: total,
1580 diff_ratio: 1.0,
1581 });
1582 }
1583
1584 let total_pixels = bw * bh;
1585 if total_pixels == 0 {
1586 return Ok(ImageComparison {
1587 diff_count: 0,
1588 diff_ratio: 0.0,
1589 });
1590 }
1591
1592 let threshold_sq = threshold * threshold;
1593 let mut diff_count: u32 = 0;
1594
1595 for y in 0..bh {
1596 for x in 0..bw {
1597 let bp = baseline_img.get_pixel(x, y);
1598 let ap = actual_img.get_pixel(x, y);
1599
1600 let dr = (bp[0] as f64 - ap[0] as f64) / 255.0;
1602 let dg = (bp[1] as f64 - ap[1] as f64) / 255.0;
1603 let db = (bp[2] as f64 - ap[2] as f64) / 255.0;
1604 let da = (bp[3] as f64 - ap[3] as f64) / 255.0;
1605
1606 let dist_sq = (dr * dr + dg * dg + db * db + da * da) / 4.0;
1607
1608 if dist_sq > threshold_sq {
1609 diff_count += 1;
1610 }
1611 }
1612 }
1613
1614 Ok(ImageComparison {
1615 diff_count,
1616 diff_ratio: diff_count as f64 / total_pixels as f64,
1617 })
1618}
1619
1620fn generate_diff_image(
1622 baseline_bytes: &[u8],
1623 actual_bytes: &[u8],
1624 threshold: f64,
1625) -> Result<Vec<u8>> {
1626 use image::{GenericImageView, ImageBuffer, Rgba};
1627
1628 let baseline_img = image::load_from_memory(baseline_bytes).map_err(|e| {
1629 crate::error::Error::ProtocolError(format!("Failed to decode baseline image: {}", e))
1630 })?;
1631 let actual_img = image::load_from_memory(actual_bytes).map_err(|e| {
1632 crate::error::Error::ProtocolError(format!("Failed to decode actual image: {}", e))
1633 })?;
1634
1635 let (bw, bh) = baseline_img.dimensions();
1636 let (aw, ah) = actual_img.dimensions();
1637 let width = bw.max(aw);
1638 let height = bh.max(ah);
1639
1640 let threshold_sq = threshold * threshold;
1641
1642 let mut diff_img: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::new(width, height);
1643
1644 for y in 0..height {
1645 for x in 0..width {
1646 if x >= bw || y >= bh || x >= aw || y >= ah {
1647 diff_img.put_pixel(x, y, Rgba([255, 0, 0, 255]));
1649 continue;
1650 }
1651
1652 let bp = baseline_img.get_pixel(x, y);
1653 let ap = actual_img.get_pixel(x, y);
1654
1655 let dr = (bp[0] as f64 - ap[0] as f64) / 255.0;
1656 let dg = (bp[1] as f64 - ap[1] as f64) / 255.0;
1657 let db = (bp[2] as f64 - ap[2] as f64) / 255.0;
1658 let da = (bp[3] as f64 - ap[3] as f64) / 255.0;
1659
1660 let dist_sq = (dr * dr + dg * dg + db * db + da * da) / 4.0;
1661
1662 if dist_sq > threshold_sq {
1663 diff_img.put_pixel(x, y, Rgba([255, 0, 0, 255]));
1665 } else {
1666 let gray = ((ap[0] as u16 + ap[1] as u16 + ap[2] as u16) / 3) as u8;
1668 diff_img.put_pixel(x, y, Rgba([gray, gray, gray, 100]));
1669 }
1670 }
1671 }
1672
1673 let mut output = std::io::Cursor::new(Vec::new());
1674 diff_img
1675 .write_to(&mut output, image::ImageFormat::Png)
1676 .map_err(|e| {
1677 crate::error::Error::ProtocolError(format!("Failed to encode diff image: {}", e))
1678 })?;
1679
1680 Ok(output.into_inner())
1681}
1682
1683#[cfg(test)]
1684mod tests {
1685 use super::*;
1686
1687 #[test]
1688 fn test_expectation_defaults() {
1689 assert_eq!(DEFAULT_ASSERTION_TIMEOUT, Duration::from_secs(5));
1691 assert_eq!(DEFAULT_POLL_INTERVAL, Duration::from_millis(100));
1692 }
1693}