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