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 self.to_have_css_inner(name, value, None).await
844 }
845
846 pub async fn to_have_css_pseudo(self, name: &str, value: &str, pseudo: &str) -> Result<()> {
852 self.to_have_css_inner(name, value, Some(pseudo)).await
853 }
854
855 async fn to_have_css_inner(self, name: &str, value: &str, pseudo: Option<&str>) -> Result<()> {
856 let start = std::time::Instant::now();
857 let selector = self.locator.selector().to_string();
858 let getter = match pseudo {
859 Some(p) => format!(
860 "getComputedStyle(el, {})",
861 serde_json::to_string(p).unwrap()
862 ),
863 None => "getComputedStyle(el)".to_string(),
864 };
865 let expr = format!(
866 "(el) => {}.getPropertyValue({})",
867 getter,
868 serde_json::to_string(name).unwrap()
869 );
870
871 loop {
872 let actual: String = self.locator.evaluate(&expr, None::<()>).await?;
873
874 let matched = actual == value;
875 let matches = if self.negate { !matched } else { matched };
876
877 if matches {
878 return Ok(());
879 }
880
881 if start.elapsed() >= self.timeout {
882 let message = if self.negate {
883 format!(
884 "Expected element '{}' NOT to have CSS '{}'='{}', but it did after {:?}",
885 selector, name, value, self.timeout
886 )
887 } else {
888 format!(
889 "Expected element '{}' to have CSS '{}'='{}', but had '{}' after {:?}",
890 selector, name, value, actual, self.timeout
891 )
892 };
893 return Err(crate::error::Error::AssertionTimeout(message));
894 }
895
896 tokio::time::sleep(self.poll_interval).await;
897 }
898 }
899
900 pub async fn to_have_css_regex(self, name: &str, pattern: &str) -> Result<()> {
902 let start = std::time::Instant::now();
903 let selector = self.locator.selector().to_string();
904 let re = regex::Regex::new(pattern)
905 .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
906 let expr = format!(
907 "(el) => getComputedStyle(el).getPropertyValue({})",
908 serde_json::to_string(name).unwrap()
909 );
910
911 loop {
912 let actual: String = self.locator.evaluate(&expr, None::<()>).await?;
913
914 let matched = re.is_match(&actual);
915 let matches = if self.negate { !matched } else { matched };
916
917 if matches {
918 return Ok(());
919 }
920
921 if start.elapsed() >= self.timeout {
922 let message = if self.negate {
923 format!(
924 "Expected element '{}' CSS '{}' NOT to match pattern '{}', but it did after {:?}",
925 selector, name, pattern, self.timeout
926 )
927 } else {
928 format!(
929 "Expected element '{}' CSS '{}' to match pattern '{}', but had '{}' after {:?}",
930 selector, name, pattern, actual, self.timeout
931 )
932 };
933 return Err(crate::error::Error::AssertionTimeout(message));
934 }
935
936 tokio::time::sleep(self.poll_interval).await;
937 }
938 }
939
940 pub async fn to_have_count(self, count: usize) -> Result<()> {
944 let start = std::time::Instant::now();
945 let selector = self.locator.selector().to_string();
946
947 loop {
948 let actual = self.locator.count().await?;
949
950 let matched = actual == count;
951 let matches = if self.negate { !matched } else { matched };
952
953 if matches {
954 return Ok(());
955 }
956
957 if start.elapsed() >= self.timeout {
958 let message = if self.negate {
959 format!(
960 "Expected locator '{}' NOT to have count {}, but it did after {:?}",
961 selector, count, self.timeout
962 )
963 } else {
964 format!(
965 "Expected locator '{}' to have count {}, but had {} after {:?}",
966 selector, count, actual, self.timeout
967 )
968 };
969 return Err(crate::error::Error::AssertionTimeout(message));
970 }
971
972 tokio::time::sleep(self.poll_interval).await;
973 }
974 }
975
976 pub async fn to_match_aria_snapshot(self, expected: &str) -> Result<()> {
998 use crate::protocol::serialize_argument;
999
1000 let selector = self.locator.selector().to_string();
1001 let timeout_ms = self.timeout.as_millis() as f64;
1002 let expected_value = serialize_argument(&serde_json::Value::String(expected.to_string()));
1003
1004 self.locator
1005 .frame()
1006 .frame_expect(
1007 &selector,
1008 "to.match.aria",
1009 expected_value,
1010 self.negate,
1011 timeout_ms,
1012 )
1013 .await
1014 }
1015
1016 #[cfg(feature = "screenshot-diff")]
1027 pub async fn to_have_screenshot(
1028 self,
1029 baseline_path: impl AsRef<Path>,
1030 options: Option<ScreenshotAssertionOptions>,
1031 ) -> Result<()> {
1032 let opts = options.unwrap_or_default();
1033 let baseline_path = baseline_path.as_ref();
1034
1035 if opts.animations == Some(Animations::Disabled) {
1037 let _ = self
1038 .locator
1039 .evaluate_js(DISABLE_ANIMATIONS_JS, None::<&()>)
1040 .await;
1041 }
1042
1043 let screenshot_opts = if let Some(ref mask_locators) = opts.mask {
1045 let mask_js = build_mask_js(mask_locators);
1047 let _ = self.locator.evaluate_js(&mask_js, None::<&()>).await;
1048 None
1049 } else {
1050 None
1051 };
1052
1053 compare_screenshot(
1054 &opts,
1055 baseline_path,
1056 self.timeout,
1057 self.poll_interval,
1058 self.negate,
1059 || async { self.locator.screenshot(screenshot_opts.clone()).await },
1060 )
1061 .await
1062 }
1063}
1064
1065#[cfg(feature = "screenshot-diff")]
1067const DISABLE_ANIMATIONS_JS: &str = r#"
1068(() => {
1069 const style = document.createElement('style');
1070 style.textContent = '*, *::before, *::after { animation-duration: 0s !important; animation-delay: 0s !important; transition-duration: 0s !important; transition-delay: 0s !important; }';
1071 style.setAttribute('data-playwright-no-animations', '');
1072 document.head.appendChild(style);
1073})()
1074"#;
1075
1076#[cfg(feature = "screenshot-diff")]
1078fn build_mask_js(locators: &[Locator]) -> String {
1079 let selectors: Vec<String> = locators
1080 .iter()
1081 .map(|l| {
1082 let sel = l.selector().replace('\'', "\\'");
1083 format!(
1084 r#"
1085 (function() {{
1086 var els = document.querySelectorAll('{}');
1087 els.forEach(function(el) {{
1088 var rect = el.getBoundingClientRect();
1089 var overlay = document.createElement('div');
1090 overlay.setAttribute('data-playwright-mask', '');
1091 overlay.style.cssText = 'position:fixed;z-index:2147483647;background:#FF00FF;pointer-events:none;'
1092 + 'left:' + rect.left + 'px;top:' + rect.top + 'px;width:' + rect.width + 'px;height:' + rect.height + 'px;';
1093 document.body.appendChild(overlay);
1094 }});
1095 }})();
1096 "#,
1097 sel
1098 )
1099 })
1100 .collect();
1101 selectors.join("\n")
1102}
1103
1104#[cfg(feature = "screenshot-diff")]
1107use crate::protocol::Animations;
1108
1109#[cfg(feature = "screenshot-diff")]
1113#[derive(Debug, Clone, Default)]
1114#[non_exhaustive]
1115pub struct ScreenshotAssertionOptions {
1116 pub max_diff_pixels: Option<u32>,
1118 pub max_diff_pixel_ratio: Option<f64>,
1120 pub threshold: Option<f64>,
1122 pub animations: Option<Animations>,
1124 pub mask: Option<Vec<Locator>>,
1126 pub update_snapshots: Option<bool>,
1128}
1129
1130#[cfg(feature = "screenshot-diff")]
1131impl ScreenshotAssertionOptions {
1132 pub fn builder() -> ScreenshotAssertionOptionsBuilder {
1134 ScreenshotAssertionOptionsBuilder::default()
1135 }
1136}
1137
1138#[cfg(feature = "screenshot-diff")]
1140#[derive(Debug, Clone, Default)]
1141pub struct ScreenshotAssertionOptionsBuilder {
1142 max_diff_pixels: Option<u32>,
1143 max_diff_pixel_ratio: Option<f64>,
1144 threshold: Option<f64>,
1145 animations: Option<Animations>,
1146 mask: Option<Vec<Locator>>,
1147 update_snapshots: Option<bool>,
1148}
1149
1150#[cfg(feature = "screenshot-diff")]
1151impl ScreenshotAssertionOptionsBuilder {
1152 pub fn max_diff_pixels(mut self, pixels: u32) -> Self {
1154 self.max_diff_pixels = Some(pixels);
1155 self
1156 }
1157
1158 pub fn max_diff_pixel_ratio(mut self, ratio: f64) -> Self {
1160 self.max_diff_pixel_ratio = Some(ratio);
1161 self
1162 }
1163
1164 pub fn threshold(mut self, threshold: f64) -> Self {
1166 self.threshold = Some(threshold);
1167 self
1168 }
1169
1170 pub fn animations(mut self, animations: Animations) -> Self {
1172 self.animations = Some(animations);
1173 self
1174 }
1175
1176 pub fn mask(mut self, locators: Vec<Locator>) -> Self {
1178 self.mask = Some(locators);
1179 self
1180 }
1181
1182 pub fn update_snapshots(mut self, update: bool) -> Self {
1184 self.update_snapshots = Some(update);
1185 self
1186 }
1187
1188 pub fn build(self) -> ScreenshotAssertionOptions {
1190 ScreenshotAssertionOptions {
1191 max_diff_pixels: self.max_diff_pixels,
1192 max_diff_pixel_ratio: self.max_diff_pixel_ratio,
1193 threshold: self.threshold,
1194 animations: self.animations,
1195 mask: self.mask,
1196 update_snapshots: self.update_snapshots,
1197 }
1198 }
1199}
1200
1201pub fn expect_page(page: &Page) -> PageExpectation {
1205 PageExpectation::new(page.clone())
1206}
1207
1208#[allow(clippy::wrong_self_convention)]
1210pub struct PageExpectation {
1211 page: Page,
1212 timeout: Duration,
1213 poll_interval: Duration,
1214 negate: bool,
1215}
1216
1217impl PageExpectation {
1218 fn new(page: Page) -> Self {
1219 Self {
1220 page,
1221 timeout: DEFAULT_ASSERTION_TIMEOUT,
1222 poll_interval: DEFAULT_POLL_INTERVAL,
1223 negate: false,
1224 }
1225 }
1226
1227 pub fn with_timeout(mut self, timeout: Duration) -> Self {
1229 self.timeout = timeout;
1230 self
1231 }
1232
1233 #[allow(clippy::should_implement_trait)]
1235 pub fn not(mut self) -> Self {
1236 self.negate = true;
1237 self
1238 }
1239
1240 pub async fn to_have_title(self, expected: &str) -> Result<()> {
1246 let start = std::time::Instant::now();
1247 let expected = expected.trim();
1248
1249 loop {
1250 let actual = self.page.title().await?;
1251 let actual = actual.trim();
1252
1253 let matches = if self.negate {
1254 actual != expected
1255 } else {
1256 actual == expected
1257 };
1258
1259 if matches {
1260 return Ok(());
1261 }
1262
1263 if start.elapsed() >= self.timeout {
1264 let message = if self.negate {
1265 format!(
1266 "Expected page NOT to have title '{}', but it did after {:?}",
1267 expected, self.timeout,
1268 )
1269 } else {
1270 format!(
1271 "Expected page to have title '{}', but got '{}' after {:?}",
1272 expected, actual, self.timeout,
1273 )
1274 };
1275 return Err(crate::error::Error::AssertionTimeout(message));
1276 }
1277
1278 tokio::time::sleep(self.poll_interval).await;
1279 }
1280 }
1281
1282 pub async fn to_have_title_regex(self, pattern: &str) -> Result<()> {
1288 let start = std::time::Instant::now();
1289 let re = regex::Regex::new(pattern)
1290 .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
1291
1292 loop {
1293 let actual = self.page.title().await?;
1294
1295 let matches = if self.negate {
1296 !re.is_match(&actual)
1297 } else {
1298 re.is_match(&actual)
1299 };
1300
1301 if matches {
1302 return Ok(());
1303 }
1304
1305 if start.elapsed() >= self.timeout {
1306 let message = if self.negate {
1307 format!(
1308 "Expected page title NOT to match '{}', but '{}' matched after {:?}",
1309 pattern, actual, self.timeout,
1310 )
1311 } else {
1312 format!(
1313 "Expected page title to match '{}', but got '{}' after {:?}",
1314 pattern, actual, self.timeout,
1315 )
1316 };
1317 return Err(crate::error::Error::AssertionTimeout(message));
1318 }
1319
1320 tokio::time::sleep(self.poll_interval).await;
1321 }
1322 }
1323
1324 pub async fn to_match_aria_snapshot(self, expected: &str) -> Result<()> {
1349 use crate::protocol::serialize_argument;
1350
1351 let timeout_ms = self.timeout.as_millis() as f64;
1352 let expected_value = serialize_argument(&serde_json::Value::String(expected.to_string()));
1353
1354 let frame = self.page.main_frame().await?;
1355 frame
1356 .frame_expect(
1357 ":root",
1358 "to.match.aria",
1359 expected_value,
1360 self.negate,
1361 timeout_ms,
1362 )
1363 .await
1364 }
1365
1366 pub async fn to_have_url(self, expected: &str) -> Result<()> {
1372 let start = std::time::Instant::now();
1373
1374 loop {
1375 let actual = self.page.url();
1376
1377 let matches = if self.negate {
1378 actual != expected
1379 } else {
1380 actual == expected
1381 };
1382
1383 if matches {
1384 return Ok(());
1385 }
1386
1387 if start.elapsed() >= self.timeout {
1388 let message = if self.negate {
1389 format!(
1390 "Expected page NOT to have URL '{}', but it did after {:?}",
1391 expected, self.timeout,
1392 )
1393 } else {
1394 format!(
1395 "Expected page to have URL '{}', but got '{}' after {:?}",
1396 expected, actual, self.timeout,
1397 )
1398 };
1399 return Err(crate::error::Error::AssertionTimeout(message));
1400 }
1401
1402 tokio::time::sleep(self.poll_interval).await;
1403 }
1404 }
1405
1406 pub async fn to_have_url_regex(self, pattern: &str) -> Result<()> {
1412 let start = std::time::Instant::now();
1413 let re = regex::Regex::new(pattern)
1414 .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
1415
1416 loop {
1417 let actual = self.page.url();
1418
1419 let matches = if self.negate {
1420 !re.is_match(&actual)
1421 } else {
1422 re.is_match(&actual)
1423 };
1424
1425 if matches {
1426 return Ok(());
1427 }
1428
1429 if start.elapsed() >= self.timeout {
1430 let message = if self.negate {
1431 format!(
1432 "Expected page URL NOT to match '{}', but '{}' matched after {:?}",
1433 pattern, actual, self.timeout,
1434 )
1435 } else {
1436 format!(
1437 "Expected page URL to match '{}', but got '{}' after {:?}",
1438 pattern, actual, self.timeout,
1439 )
1440 };
1441 return Err(crate::error::Error::AssertionTimeout(message));
1442 }
1443
1444 tokio::time::sleep(self.poll_interval).await;
1445 }
1446 }
1447
1448 #[cfg(feature = "screenshot-diff")]
1454 pub async fn to_have_screenshot(
1455 self,
1456 baseline_path: impl AsRef<Path>,
1457 options: Option<ScreenshotAssertionOptions>,
1458 ) -> Result<()> {
1459 let opts = options.unwrap_or_default();
1460 let baseline_path = baseline_path.as_ref();
1461
1462 if opts.animations == Some(Animations::Disabled) {
1464 let _ = self.page.evaluate_expression(DISABLE_ANIMATIONS_JS).await;
1465 }
1466
1467 if let Some(ref mask_locators) = opts.mask {
1469 let mask_js = build_mask_js(mask_locators);
1470 let _ = self.page.evaluate_expression(&mask_js).await;
1471 }
1472
1473 compare_screenshot(
1474 &opts,
1475 baseline_path,
1476 self.timeout,
1477 self.poll_interval,
1478 self.negate,
1479 || async { self.page.screenshot(None).await },
1480 )
1481 .await
1482 }
1483}
1484
1485#[cfg(feature = "screenshot-diff")]
1487async fn compare_screenshot<F, Fut>(
1488 opts: &ScreenshotAssertionOptions,
1489 baseline_path: &Path,
1490 timeout: Duration,
1491 poll_interval: Duration,
1492 negate: bool,
1493 take_screenshot: F,
1494) -> Result<()>
1495where
1496 F: Fn() -> Fut,
1497 Fut: std::future::Future<Output = Result<Vec<u8>>>,
1498{
1499 let threshold = opts.threshold.unwrap_or(0.2);
1500 let max_diff_pixels = opts.max_diff_pixels;
1501 let max_diff_pixel_ratio = opts.max_diff_pixel_ratio;
1502 let update_snapshots = opts.update_snapshots.unwrap_or(false);
1503
1504 let actual_bytes = take_screenshot().await?;
1506
1507 if !baseline_path.exists() || update_snapshots {
1509 if let Some(parent) = baseline_path.parent() {
1510 tokio::fs::create_dir_all(parent).await.map_err(|e| {
1511 crate::error::Error::ProtocolError(format!(
1512 "Failed to create baseline directory: {}",
1513 e
1514 ))
1515 })?;
1516 }
1517 tokio::fs::write(baseline_path, &actual_bytes)
1518 .await
1519 .map_err(|e| {
1520 crate::error::Error::ProtocolError(format!(
1521 "Failed to write baseline screenshot: {}",
1522 e
1523 ))
1524 })?;
1525 return Ok(());
1526 }
1527
1528 let baseline_bytes = tokio::fs::read(baseline_path).await.map_err(|e| {
1530 crate::error::Error::ProtocolError(format!("Failed to read baseline screenshot: {}", e))
1531 })?;
1532
1533 let start = std::time::Instant::now();
1534
1535 loop {
1536 let screenshot_bytes = if start.elapsed().is_zero() {
1537 actual_bytes.clone()
1538 } else {
1539 take_screenshot().await?
1540 };
1541
1542 let comparison = compare_images(&baseline_bytes, &screenshot_bytes, threshold)?;
1543
1544 let within_tolerance =
1545 is_within_tolerance(&comparison, max_diff_pixels, max_diff_pixel_ratio);
1546
1547 let matches = if negate {
1548 !within_tolerance
1549 } else {
1550 within_tolerance
1551 };
1552
1553 if matches {
1554 return Ok(());
1555 }
1556
1557 if start.elapsed() >= timeout {
1558 if negate {
1559 return Err(crate::error::Error::AssertionTimeout(format!(
1560 "Expected screenshots NOT to match, but they matched after {:?}",
1561 timeout
1562 )));
1563 }
1564
1565 let baseline_stem = baseline_path
1567 .file_stem()
1568 .and_then(|s| s.to_str())
1569 .unwrap_or("screenshot");
1570 let baseline_ext = baseline_path
1571 .extension()
1572 .and_then(|s| s.to_str())
1573 .unwrap_or("png");
1574 let baseline_dir = baseline_path.parent().unwrap_or(Path::new("."));
1575
1576 let actual_path =
1577 baseline_dir.join(format!("{}-actual.{}", baseline_stem, baseline_ext));
1578 let diff_path = baseline_dir.join(format!("{}-diff.{}", baseline_stem, baseline_ext));
1579
1580 let _ = tokio::fs::write(&actual_path, &screenshot_bytes).await;
1581
1582 if let Ok(diff_bytes) =
1583 generate_diff_image(&baseline_bytes, &screenshot_bytes, threshold)
1584 {
1585 let _ = tokio::fs::write(&diff_path, diff_bytes).await;
1586 }
1587
1588 return Err(crate::error::Error::AssertionTimeout(format!(
1589 "Screenshot mismatch: {} pixels differ ({:.2}% of total). \
1590 Max allowed: {}. Threshold: {:.2}. \
1591 Actual saved to: {}. Diff saved to: {}. \
1592 Timed out after {:?}",
1593 comparison.diff_count,
1594 comparison.diff_ratio * 100.0,
1595 max_diff_pixels
1596 .map(|p| p.to_string())
1597 .or_else(|| max_diff_pixel_ratio.map(|r| format!("{:.2}%", r * 100.0)))
1598 .unwrap_or_else(|| "0".to_string()),
1599 threshold,
1600 actual_path.display(),
1601 diff_path.display(),
1602 timeout,
1603 )));
1604 }
1605
1606 tokio::time::sleep(poll_interval).await;
1607 }
1608}
1609
1610#[cfg(feature = "screenshot-diff")]
1612struct ImageComparison {
1613 diff_count: u32,
1614 diff_ratio: f64,
1615}
1616
1617#[cfg(feature = "screenshot-diff")]
1618fn is_within_tolerance(
1619 comparison: &ImageComparison,
1620 max_diff_pixels: Option<u32>,
1621 max_diff_pixel_ratio: Option<f64>,
1622) -> bool {
1623 if let Some(max_pixels) = max_diff_pixels {
1624 if comparison.diff_count > max_pixels {
1625 return false;
1626 }
1627 } else if let Some(max_ratio) = max_diff_pixel_ratio {
1628 if comparison.diff_ratio > max_ratio {
1629 return false;
1630 }
1631 } else {
1632 if comparison.diff_count > 0 {
1634 return false;
1635 }
1636 }
1637 true
1638}
1639
1640#[cfg(feature = "screenshot-diff")]
1642fn compare_images(
1643 baseline_bytes: &[u8],
1644 actual_bytes: &[u8],
1645 threshold: f64,
1646) -> Result<ImageComparison> {
1647 use image::GenericImageView;
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
1659 if bw != aw || bh != ah {
1661 let total = bw.max(aw) * bh.max(ah);
1662 return Ok(ImageComparison {
1663 diff_count: total,
1664 diff_ratio: 1.0,
1665 });
1666 }
1667
1668 let total_pixels = bw * bh;
1669 if total_pixels == 0 {
1670 return Ok(ImageComparison {
1671 diff_count: 0,
1672 diff_ratio: 0.0,
1673 });
1674 }
1675
1676 let threshold_sq = threshold * threshold;
1677 let mut diff_count: u32 = 0;
1678
1679 for y in 0..bh {
1680 for x in 0..bw {
1681 let bp = baseline_img.get_pixel(x, y);
1682 let ap = actual_img.get_pixel(x, y);
1683
1684 let dr = (bp[0] as f64 - ap[0] as f64) / 255.0;
1686 let dg = (bp[1] as f64 - ap[1] as f64) / 255.0;
1687 let db = (bp[2] as f64 - ap[2] as f64) / 255.0;
1688 let da = (bp[3] as f64 - ap[3] as f64) / 255.0;
1689
1690 let dist_sq = (dr * dr + dg * dg + db * db + da * da) / 4.0;
1691
1692 if dist_sq > threshold_sq {
1693 diff_count += 1;
1694 }
1695 }
1696 }
1697
1698 Ok(ImageComparison {
1699 diff_count,
1700 diff_ratio: diff_count as f64 / total_pixels as f64,
1701 })
1702}
1703
1704#[cfg(feature = "screenshot-diff")]
1706fn generate_diff_image(
1707 baseline_bytes: &[u8],
1708 actual_bytes: &[u8],
1709 threshold: f64,
1710) -> Result<Vec<u8>> {
1711 use image::{GenericImageView, ImageBuffer, Rgba};
1712
1713 let baseline_img = image::load_from_memory(baseline_bytes).map_err(|e| {
1714 crate::error::Error::ProtocolError(format!("Failed to decode baseline image: {}", e))
1715 })?;
1716 let actual_img = image::load_from_memory(actual_bytes).map_err(|e| {
1717 crate::error::Error::ProtocolError(format!("Failed to decode actual image: {}", e))
1718 })?;
1719
1720 let (bw, bh) = baseline_img.dimensions();
1721 let (aw, ah) = actual_img.dimensions();
1722 let width = bw.max(aw);
1723 let height = bh.max(ah);
1724
1725 let threshold_sq = threshold * threshold;
1726
1727 let mut diff_img: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::new(width, height);
1728
1729 for y in 0..height {
1730 for x in 0..width {
1731 if x >= bw || y >= bh || x >= aw || y >= ah {
1732 diff_img.put_pixel(x, y, Rgba([255, 0, 0, 255]));
1734 continue;
1735 }
1736
1737 let bp = baseline_img.get_pixel(x, y);
1738 let ap = actual_img.get_pixel(x, y);
1739
1740 let dr = (bp[0] as f64 - ap[0] as f64) / 255.0;
1741 let dg = (bp[1] as f64 - ap[1] as f64) / 255.0;
1742 let db = (bp[2] as f64 - ap[2] as f64) / 255.0;
1743 let da = (bp[3] as f64 - ap[3] as f64) / 255.0;
1744
1745 let dist_sq = (dr * dr + dg * dg + db * db + da * da) / 4.0;
1746
1747 if dist_sq > threshold_sq {
1748 diff_img.put_pixel(x, y, Rgba([255, 0, 0, 255]));
1750 } else {
1751 let gray = ((ap[0] as u16 + ap[1] as u16 + ap[2] as u16) / 3) as u8;
1753 diff_img.put_pixel(x, y, Rgba([gray, gray, gray, 100]));
1754 }
1755 }
1756 }
1757
1758 let mut output = std::io::Cursor::new(Vec::new());
1759 diff_img
1760 .write_to(&mut output, image::ImageFormat::Png)
1761 .map_err(|e| {
1762 crate::error::Error::ProtocolError(format!("Failed to encode diff image: {}", e))
1763 })?;
1764
1765 Ok(output.into_inner())
1766}
1767
1768#[cfg(test)]
1769mod tests {
1770 use super::*;
1771
1772 #[test]
1773 fn test_expectation_defaults() {
1774 assert_eq!(DEFAULT_ASSERTION_TIMEOUT, Duration::from_secs(5));
1776 assert_eq!(DEFAULT_POLL_INTERVAL, Duration::from_millis(100));
1777 }
1778}