1use std::future::Future;
13use std::path::Path;
14use std::time::Duration;
15
16use crate::error::{Error, Result};
17use crate::locator::Locator;
18use crate::page::Page;
19
20const DEFAULT_TIMEOUT: Duration = Duration::from_millis(5000);
22const POLL_INTERVAL: Duration = Duration::from_millis(100);
24
25#[derive(Debug, Clone, Default)]
39#[non_exhaustive]
40pub struct ScreenshotAssertOptions {
41 pub max_diff_pixels: Option<u64>,
44 pub threshold: Option<f32>,
49}
50
51impl ScreenshotAssertOptions {
52 pub fn new() -> Self {
54 Self::default()
55 }
56
57 pub fn with_max_diff_pixels(mut self, pixels: u64) -> Self {
59 self.max_diff_pixels = Some(pixels);
60 self
61 }
62
63 pub fn with_threshold(mut self, threshold: f32) -> Self {
65 self.threshold = Some(threshold);
66 self
67 }
68}
69
70fn write_actual(path: &str, actual: &[u8]) -> Result<()> {
73 let mut actual_path = path.to_string();
74 actual_path.truncate(actual_path.len() - ".png".len());
75 actual_path.push_str("-actual.png");
76 std::fs::write(&actual_path, actual)?;
77 Ok(())
78}
79
80#[cfg(feature = "screenshot-diff")]
82enum ScreenshotCmp {
83 Match,
85 Mismatch { diff: u64, total: u64 },
88}
89
90#[cfg(feature = "screenshot-diff")]
98fn compare_screenshot_pixels(
99 baseline: &[u8],
100 actual: &[u8],
101 opts: &ScreenshotAssertOptions,
102) -> Result<ScreenshotCmp> {
103 use image::GenericImageView;
104
105 let baseline_img = image::load_from_memory(baseline)
106 .map_err(|e| Error::ProtocolError(format!("failed to decode baseline image: {e}")))?;
107 let actual_img = image::load_from_memory(actual)
108 .map_err(|e| Error::ProtocolError(format!("failed to decode actual image: {e}")))?;
109
110 let (bw, bh) = baseline_img.dimensions();
111 let (aw, ah) = actual_img.dimensions();
112
113 if bw != aw || bh != ah {
115 let total = (bw as u64).max(aw as u64) * (bh as u64).max(ah as u64);
116 return Ok(ScreenshotCmp::Mismatch {
117 diff: total,
118 total,
119 });
120 }
121
122 let total = bw as u64 * bh as u64;
123 if total == 0 {
124 return Ok(ScreenshotCmp::Match);
125 }
126
127 let threshold = opts.threshold.unwrap_or(0.0) as f64;
128 let threshold_sq = threshold * threshold;
129 let mut diff: u64 = 0;
130
131 for y in 0..bh {
132 for x in 0..bw {
133 let bp = baseline_img.get_pixel(x, y);
134 let ap = actual_img.get_pixel(x, y);
135 let dr = (i32::from(bp[0]) - i32::from(ap[0])) as f64 / 255.0;
137 let dg = (i32::from(bp[1]) - i32::from(ap[1])) as f64 / 255.0;
138 let db = (i32::from(bp[2]) - i32::from(ap[2])) as f64 / 255.0;
139 let da = (i32::from(bp[3]) - i32::from(ap[3])) as f64 / 255.0;
140 let dist_sq = (dr * dr + dg * dg + db * db + da * da) / 4.0;
141 if dist_sq > threshold_sq {
142 diff += 1;
143 }
144 }
145 }
146
147 let max_diff = opts.max_diff_pixels.unwrap_or(0);
148 if diff <= max_diff {
149 Ok(ScreenshotCmp::Match)
150 } else {
151 Ok(ScreenshotCmp::Mismatch { diff, total })
152 }
153}
154
155pub fn expect(locator: Locator) -> LocatorAssertions {
160 LocatorAssertions::new(locator)
161}
162
163pub fn expect_page(page: Page) -> PageAssertions {
167 PageAssertions::new(page)
168}
169
170async fn wait_for<F, Fut, Msg>(cond: F, timeout: Duration, negated: bool, msg: Msg) -> Result<()>
176where
177 F: Fn() -> Fut,
178 Fut: Future<Output = Result<bool>>,
179 Msg: FnOnce() -> String,
180{
181 let want = !negated;
182 let deadline = tokio::time::Instant::now() + timeout;
183 loop {
184 let got = cond().await?;
185 if got == want {
186 return Ok(());
187 }
188 if tokio::time::Instant::now() >= deadline {
189 return Err(Error::Timeout(msg()));
190 }
191 tokio::time::sleep(POLL_INTERVAL).await;
192 }
193}
194
195#[derive(Clone)]
201pub struct LocatorAssertions {
202 locator: Locator,
203 timeout: Duration,
204 negated: bool,
205}
206
207impl LocatorAssertions {
208 pub fn new(locator: Locator) -> Self {
210 Self {
211 locator,
212 timeout: DEFAULT_TIMEOUT,
213 negated: false,
214 }
215 }
216
217 pub fn with_timeout(mut self, timeout: Duration) -> Self {
219 self.timeout = timeout;
220 self
221 }
222
223 pub fn set_timeout(&mut self, timeout: Duration) -> &mut Self {
225 self.timeout = timeout;
226 self
227 }
228
229 pub fn not(&self) -> Self {
231 let mut c = self.clone();
232 c.negated = true;
233 c
234 }
235
236 fn sel(&self) -> &str {
237 self.locator.selector()
238 }
239
240 pub async fn to_be_visible(&self) -> Result<()> {
243 let sel = self.sel().to_string();
244 let l = self.locator.clone();
245 let negated = self.negated;
246 let timeout = self.timeout;
247 wait_for(
248 move || {
249 let l = l.clone();
250 async move { l.is_visible().await }
251 },
252 timeout,
253 negated,
254 || format!("to_be_visible('{sel}') timed out after {}ms", timeout.as_millis()),
255 )
256 .await
257 }
258
259 pub async fn to_be_hidden(&self) -> Result<()> {
260 let sel = self.sel().to_string();
261 let l = self.locator.clone();
262 let negated = self.negated;
263 let timeout = self.timeout;
264 wait_for(
265 move || {
266 let l = l.clone();
267 async move { l.is_hidden().await }
268 },
269 timeout,
270 negated,
271 || format!("to_be_hidden('{sel}') timed out after {}ms", timeout.as_millis()),
272 )
273 .await
274 }
275
276 pub async fn to_be_enabled(&self) -> Result<()> {
277 let sel = self.sel().to_string();
278 let l = self.locator.clone();
279 let negated = self.negated;
280 let timeout = self.timeout;
281 wait_for(
282 move || {
283 let l = l.clone();
284 async move { l.is_enabled().await }
285 },
286 timeout,
287 negated,
288 || format!("to_be_enabled('{sel}') timed out after {}ms", timeout.as_millis()),
289 )
290 .await
291 }
292
293 pub async fn to_be_disabled(&self) -> Result<()> {
294 let sel = self.sel().to_string();
295 let l = self.locator.clone();
296 let negated = self.negated;
297 let timeout = self.timeout;
298 wait_for(
299 move || {
300 let l = l.clone();
301 async move { l.is_disabled().await }
302 },
303 timeout,
304 negated,
305 || format!("to_be_disabled('{sel}') timed out after {}ms", timeout.as_millis()),
306 )
307 .await
308 }
309
310 pub async fn to_be_editable(&self) -> Result<()> {
311 let sel = self.sel().to_string();
312 let l = self.locator.clone();
313 let negated = self.negated;
314 let timeout = self.timeout;
315 wait_for(
316 move || {
317 let l = l.clone();
318 async move { l.is_editable().await }
319 },
320 timeout,
321 negated,
322 || format!("to_be_editable('{sel}') timed out after {}ms", timeout.as_millis()),
323 )
324 .await
325 }
326
327 pub async fn to_be_checked(&self) -> Result<()> {
328 let sel = self.sel().to_string();
329 let l = self.locator.clone();
330 let negated = self.negated;
331 let timeout = self.timeout;
332 wait_for(
333 move || {
334 let l = l.clone();
335 async move { l.is_checked().await }
336 },
337 timeout,
338 negated,
339 || format!("to_be_checked('{sel}') timed out after {}ms", timeout.as_millis()),
340 )
341 .await
342 }
343
344 pub async fn to_be_unchecked(&self) -> Result<()> {
345 let sel = self.sel().to_string();
346 let l = self.locator.clone();
347 let negated = self.negated;
348 let timeout = self.timeout;
349 wait_for(
350 move || {
351 let l = l.clone();
352 async move {
353 Ok(!l.is_checked().await?)
355 }
356 },
357 timeout,
358 negated,
359 || format!("to_be_unchecked('{sel}') timed out after {}ms", timeout.as_millis()),
360 )
361 .await
362 }
363
364 pub async fn to_have_text(&self, expected: &str) -> Result<()> {
368 let sel = self.sel().to_string();
369 let l = self.locator.clone();
370 let negated = self.negated;
371 let timeout = self.timeout;
372 let expected_owned = expected.to_string();
373 let expected_for_msg = expected_owned.clone();
374 wait_for(
375 move || {
376 let l = l.clone();
377 let expected_owned = expected_owned.clone();
378 async move {
379 let actual = l.text_content().await?;
380 let actual = actual.unwrap_or_default();
381 Ok(actual.trim() == expected_owned.trim())
382 }
383 },
384 timeout,
385 negated,
386 move || {
387 format!(
388 "to_have_text('{sel}', '{expected_for_msg}') timed out after {}ms",
389 timeout.as_millis()
390 )
391 },
392 )
393 .await
394 }
395
396 pub async fn to_contain_text(&self, expected: &str) -> Result<()> {
398 let sel = self.sel().to_string();
399 let l = self.locator.clone();
400 let negated = self.negated;
401 let timeout = self.timeout;
402 let expected_owned = expected.to_string();
403 let expected_for_msg = expected_owned.clone();
404 wait_for(
405 move || {
406 let l = l.clone();
407 let expected_owned = expected_owned.clone();
408 async move {
409 let actual = l.text_content().await?;
410 Ok(actual
411 .unwrap_or_default()
412 .trim()
413 .contains(expected_owned.trim()))
414 }
415 },
416 timeout,
417 negated,
418 move || {
419 format!(
420 "to_contain_text('{sel}', '{expected_for_msg}') timed out after {}ms",
421 timeout.as_millis()
422 )
423 },
424 )
425 .await
426 }
427
428 pub async fn to_have_inner_text(&self, expected: &str) -> Result<()> {
430 let sel = self.sel().to_string();
431 let l = self.locator.clone();
432 let negated = self.negated;
433 let timeout = self.timeout;
434 let expected_owned = expected.to_string();
435 let expected_for_msg = expected_owned.clone();
436 wait_for(
437 move || {
438 let l = l.clone();
439 let expected_owned = expected_owned.clone();
440 async move {
441 let actual = l.inner_text().await?;
442 Ok(actual == expected_owned)
443 }
444 },
445 timeout,
446 negated,
447 move || {
448 format!(
449 "to_have_inner_text('{sel}', '{expected_for_msg}') timed out after {}ms",
450 timeout.as_millis()
451 )
452 },
453 )
454 .await
455 }
456
457 pub async fn to_have_text_regex(&self, pattern: &str) -> Result<()> {
461 let sel = self.sel().to_string();
462 let l = self.locator.clone();
463 let negated = self.negated;
464 let timeout = self.timeout;
465 let re = regex::Regex::new(pattern)
466 .map_err(|e| Error::InvalidArgument(format!("invalid regex pattern {pattern:?}: {e}")))?;
467 let pattern_for_msg = pattern.to_string();
468 wait_for(
469 move || {
470 let l = l.clone();
471 let re = re.clone();
472 async move {
473 let actual = l.text_content().await?;
474 let actual = actual.unwrap_or_default();
475 Ok(re.is_match(actual.trim()))
476 }
477 },
478 timeout,
479 negated,
480 move || {
481 format!(
482 "to_have_text_regex('{sel}', '{pattern_for_msg}') timed out after {}ms",
483 timeout.as_millis()
484 )
485 },
486 )
487 .await
488 }
489
490 pub async fn to_contain_text_regex(&self, pattern: &str) -> Result<()> {
492 let sel = self.sel().to_string();
493 let l = self.locator.clone();
494 let negated = self.negated;
495 let timeout = self.timeout;
496 let re = regex::Regex::new(pattern)
497 .map_err(|e| Error::InvalidArgument(format!("invalid regex pattern {pattern:?}: {e}")))?;
498 let pattern_for_msg = pattern.to_string();
499 wait_for(
500 move || {
501 let l = l.clone();
502 let re = re.clone();
503 async move {
504 let actual = l.text_content().await?;
505 let actual = actual.unwrap_or_default();
506 Ok(re.is_match(&actual))
507 }
508 },
509 timeout,
510 negated,
511 move || {
512 format!(
513 "to_contain_text_regex('{sel}', '{pattern_for_msg}') timed out after {}ms",
514 timeout.as_millis()
515 )
516 },
517 )
518 .await
519 }
520
521 pub async fn to_have_inner_text_regex(&self, pattern: &str) -> Result<()> {
523 let sel = self.sel().to_string();
524 let l = self.locator.clone();
525 let negated = self.negated;
526 let timeout = self.timeout;
527 let re = regex::Regex::new(pattern)
528 .map_err(|e| Error::InvalidArgument(format!("invalid regex pattern {pattern:?}: {e}")))?;
529 let pattern_for_msg = pattern.to_string();
530 wait_for(
531 move || {
532 let l = l.clone();
533 let re = re.clone();
534 async move {
535 let actual = l.inner_text().await?;
536 Ok(re.is_match(&actual))
537 }
538 },
539 timeout,
540 negated,
541 move || {
542 format!(
543 "to_have_inner_text_regex('{sel}', '{pattern_for_msg}') timed out after {}ms",
544 timeout.as_millis()
545 )
546 },
547 )
548 .await
549 }
550
551 pub async fn to_have_count(&self, expected: usize) -> Result<()> {
555 let sel = self.sel().to_string();
556 let l = self.locator.clone();
557 let negated = self.negated;
558 let timeout = self.timeout;
559 wait_for(
560 move || {
561 let l = l.clone();
562 async move { Ok(l.count().await? == expected) }
563 },
564 timeout,
565 negated,
566 move || {
567 format!(
568 "to_have_count('{sel}', {expected}) timed out after {}ms",
569 timeout.as_millis()
570 )
571 },
572 )
573 .await
574 }
575
576 pub async fn to_have_attribute(&self, name: &str, value: &str) -> Result<()> {
580 let sel = self.sel().to_string();
581 let l = self.locator.clone();
582 let negated = self.negated;
583 let timeout = self.timeout;
584 let name_owned = name.to_string();
585 let value_owned = value.to_string();
586 let name_for_msg = name.to_string();
587 let value_for_msg = value.to_string();
588 wait_for(
589 move || {
590 let l = l.clone();
591 let name_owned = name_owned.clone();
592 let value_owned = value_owned.clone();
593 async move {
594 let attr = l.get_attribute(&name_owned).await?;
595 Ok(attr.as_deref() == Some(value_owned.as_str()))
596 }
597 },
598 timeout,
599 negated,
600 move || {
601 format!(
602 "to_have_attribute('{sel}', '{name_for_msg}', '{value_for_msg}') timed out after {}ms",
603 timeout.as_millis()
604 )
605 },
606 )
607 .await
608 }
609
610 pub async fn to_have_value(&self, expected: &str) -> Result<()> {
612 let sel = self.sel().to_string();
613 let l = self.locator.clone();
614 let negated = self.negated;
615 let timeout = self.timeout;
616 let expected_owned = expected.to_string();
617 let expected_for_msg = expected.to_string();
618 wait_for(
619 move || {
620 let l = l.clone();
621 let expected_owned = expected_owned.clone();
622 async move { Ok(l.input_value(None).await? == expected_owned) }
623 },
624 timeout,
625 negated,
626 move || {
627 format!(
628 "to_have_value('{sel}', '{expected_for_msg}') timed out after {}ms",
629 timeout.as_millis()
630 )
631 },
632 )
633 .await
634 }
635
636 pub async fn to_have_value_regex(&self, pattern: &str) -> Result<()> {
638 let sel = self.sel().to_string();
639 let l = self.locator.clone();
640 let negated = self.negated;
641 let timeout = self.timeout;
642 let re = regex::Regex::new(pattern)
643 .map_err(|e| Error::InvalidArgument(format!("invalid regex pattern {pattern:?}: {e}")))?;
644 let pattern_for_msg = pattern.to_string();
645 wait_for(
646 move || {
647 let l = l.clone();
648 let re = re.clone();
649 async move {
650 let actual = l.input_value(None).await?;
651 Ok(re.is_match(&actual))
652 }
653 },
654 timeout,
655 negated,
656 move || {
657 format!(
658 "to_have_value_regex('{sel}', '{pattern_for_msg}') timed out after {}ms",
659 timeout.as_millis()
660 )
661 },
662 )
663 .await
664 }
665
666 pub async fn to_have_attribute_regex(&self, name: &str, pattern: &str) -> Result<()> {
668 let sel = self.sel().to_string();
669 let l = self.locator.clone();
670 let negated = self.negated;
671 let timeout = self.timeout;
672 let re = regex::Regex::new(pattern)
673 .map_err(|e| Error::InvalidArgument(format!("invalid regex pattern {pattern:?}: {e}")))?;
674 let name_owned = name.to_string();
675 let name_for_msg = name.to_string();
676 let pattern_for_msg = pattern.to_string();
677 wait_for(
678 move || {
679 let l = l.clone();
680 let re = re.clone();
681 let name_owned = name_owned.clone();
682 async move {
683 let attr = l.get_attribute(&name_owned).await?;
684 Ok(match attr {
685 Some(v) => re.is_match(&v),
686 None => false,
687 })
688 }
689 },
690 timeout,
691 negated,
692 move || {
693 format!(
694 "to_have_attribute_regex('{sel}', '{name_for_msg}', '{pattern_for_msg}') timed out after {}ms",
695 timeout.as_millis()
696 )
697 },
698 )
699 .await
700 }
701
702 pub async fn to_have_css(&self, name: &str, value: &str) -> Result<()> {
709 let sel = self.sel().to_string();
710 let l = self.locator.clone();
711 let negated = self.negated;
712 let timeout = self.timeout;
713 let expr = format!(
714 "getComputedStyle(el).getPropertyValue({})",
715 serde_json::to_string(name)?
716 );
717 let value_owned = value.to_string();
718 let name_for_msg = name.to_string();
719 let value_for_msg = value.to_string();
720 wait_for(
721 move || {
722 let l = l.clone();
723 let expr = expr.clone();
724 let value_owned = value_owned.clone();
725 async move {
726 let actual: String = l.evaluate(&expr, None).await?;
727 Ok(actual == value_owned)
728 }
729 },
730 timeout,
731 negated,
732 move || {
733 format!(
734 "to_have_css('{sel}', '{name_for_msg}', '{value_for_msg}') timed out after {}ms",
735 timeout.as_millis()
736 )
737 },
738 )
739 .await
740 }
741
742 pub async fn to_have_css_regex(&self, name: &str, pattern: &str) -> Result<()> {
745 let sel = self.sel().to_string();
746 let l = self.locator.clone();
747 let negated = self.negated;
748 let timeout = self.timeout;
749 let re = regex::Regex::new(pattern)
750 .map_err(|e| Error::InvalidArgument(format!("invalid regex pattern {pattern:?}: {e}")))?;
751 let expr = format!(
752 "getComputedStyle(el).getPropertyValue({})",
753 serde_json::to_string(name)?
754 );
755 let name_for_msg = name.to_string();
756 let pattern_for_msg = pattern.to_string();
757 wait_for(
758 move || {
759 let l = l.clone();
760 let re = re.clone();
761 let expr = expr.clone();
762 async move {
763 let actual: String = l.evaluate(&expr, None).await?;
764 Ok(re.is_match(&actual))
765 }
766 },
767 timeout,
768 negated,
769 move || {
770 format!(
771 "to_have_css_regex('{sel}', '{name_for_msg}', '{pattern_for_msg}') timed out after {}ms",
772 timeout.as_millis()
773 )
774 },
775 )
776 .await
777 }
778
779 pub async fn to_have_class(&self, expected: &str) -> Result<()> {
783 let sel = self.sel().to_string();
784 let l = self.locator.clone();
785 let negated = self.negated;
786 let timeout = self.timeout;
787 let expected_owned = expected.to_string();
788 let expected_for_msg = expected.to_string();
789 wait_for(
790 move || {
791 let l = l.clone();
792 let expected_owned = expected_owned.clone();
793 async move {
794 let class = l.get_attribute("class").await?;
795 Ok(class.unwrap_or_default() == expected_owned)
796 }
797 },
798 timeout,
799 negated,
800 move || {
801 format!(
802 "to_have_class('{sel}', '{expected_for_msg}') timed out after {}ms",
803 timeout.as_millis()
804 )
805 },
806 )
807 .await
808 }
809
810 pub async fn to_have_class_regex(&self, pattern: &str) -> Result<()> {
812 let sel = self.sel().to_string();
813 let l = self.locator.clone();
814 let negated = self.negated;
815 let timeout = self.timeout;
816 let re = regex::Regex::new(pattern)
817 .map_err(|e| Error::InvalidArgument(format!("invalid regex pattern {pattern:?}: {e}")))?;
818 let pattern_for_msg = pattern.to_string();
819 wait_for(
820 move || {
821 let l = l.clone();
822 let re = re.clone();
823 async move {
824 let class = l.get_attribute("class").await?;
825 Ok(match class {
826 Some(v) => re.is_match(&v),
827 None => false,
828 })
829 }
830 },
831 timeout,
832 negated,
833 move || {
834 format!(
835 "to_have_class_regex('{sel}', '{pattern_for_msg}') timed out after {}ms",
836 timeout.as_millis()
837 )
838 },
839 )
840 .await
841 }
842
843 pub async fn to_be_focused(&self) -> Result<()> {
847 let sel = self.sel().to_string();
848 let l = self.locator.clone();
849 let negated = self.negated;
850 let timeout = self.timeout;
851 wait_for(
852 move || {
853 let l = l.clone();
854 async move { l.is_focused().await }
855 },
856 timeout,
857 negated,
858 || format!("to_be_focused('{sel}') timed out after {}ms", timeout.as_millis()),
859 )
860 .await
861 }
862
863 pub async fn to_match_aria_snapshot(&self, expected: &str) -> Result<()> {
873 let sel = self.sel().to_string();
874 let l = self.locator.clone();
875 let negated = self.negated;
876 let timeout = self.timeout;
877 let expected_owned = expected.to_string();
878 let expected_for_msg = expected.to_string();
879 wait_for(
880 move || {
881 let l = l.clone();
882 let expected_owned = expected_owned.clone();
883 async move {
884 let actual = l.aria_snapshot().await?;
885 Ok(actual.trim() == expected_owned.trim())
886 }
887 },
888 timeout,
889 negated,
890 move || {
891 format!(
892 "to_match_aria_snapshot('{sel}', '{expected_for_msg}') timed out after {}ms",
893 timeout.as_millis()
894 )
895 },
896 )
897 .await
898 }
899
900 pub async fn to_have_screenshot(
926 &self,
927 name: &str,
928 options: Option<ScreenshotAssertOptions>,
929 ) -> Result<()> {
930 let actual = self.locator.screenshot(None).await?;
931 let mut path = name.to_string();
932 if Path::new(&path).extension().and_then(|e| e.to_str()) != Some("png") {
933 path.push_str(".png");
934 }
935 if !Path::new(&path).exists() {
936 std::fs::write(&path, &actual)?;
937 return Ok(());
938 }
939 let baseline = std::fs::read(&path)?;
940
941 #[cfg(feature = "screenshot-diff")]
942 {
943 let opts = options.unwrap_or_default();
944 match compare_screenshot_pixels(&baseline, &actual, &opts)? {
945 ScreenshotCmp::Match => Ok(()),
946 ScreenshotCmp::Mismatch { diff, total } => {
947 write_actual(&path, &actual)?;
948 Err(Error::Timeout(format!(
949 "to_have_screenshot('{}') does not match baseline: {} of {} pixels differ \
950 (max_diff_pixels={}, threshold={}); actual written to '{}-actual.png'",
951 path,
952 diff,
953 total,
954 opts.max_diff_pixels.unwrap_or(0),
955 opts.threshold.unwrap_or(0.0),
956 path.trim_end_matches(".png"),
957 )))
958 }
959 }
960 }
961
962 #[cfg(not(feature = "screenshot-diff"))]
963 {
964 let _ = options;
966 if baseline == actual {
967 Ok(())
968 } else {
969 write_actual(&path, &actual)?;
970 Err(Error::Timeout(format!(
971 "to_have_screenshot('{}') does not match baseline; actual written to '{}-actual.png'",
972 path,
973 path.trim_end_matches(".png")
974 )))
975 }
976 }
977 }
978
979 pub async fn to_be_empty(&self) -> Result<()> {
982 let sel = self.sel().to_string();
983 let l = self.locator.clone();
984 let negated = self.negated;
985 let timeout = self.timeout;
986 wait_for(
987 move || {
988 let l = l.clone();
989 async move {
990 let count = l.count().await?;
991 if count == 0 {
992 return Ok(true);
993 }
994 let value = l.input_value(None).await.unwrap_or_default();
996 Ok(value.is_empty())
997 }
998 },
999 timeout,
1000 negated,
1001 move || format!("to_be_empty('{sel}') timed out after {}ms", timeout.as_millis()),
1002 )
1003 .await
1004 }
1005}
1006
1007#[derive(Clone)]
1013pub struct PageAssertions {
1014 page: Page,
1015 timeout: Duration,
1016 negated: bool,
1017}
1018
1019impl PageAssertions {
1020 pub fn new(page: Page) -> Self {
1022 Self {
1023 page,
1024 timeout: DEFAULT_TIMEOUT,
1025 negated: false,
1026 }
1027 }
1028
1029 pub fn with_timeout(mut self, timeout: Duration) -> Self {
1031 self.timeout = timeout;
1032 self
1033 }
1034
1035 pub fn set_timeout(&mut self, timeout: Duration) -> &mut Self {
1037 self.timeout = timeout;
1038 self
1039 }
1040
1041 pub fn not(&self) -> Self {
1043 let mut c = self.clone();
1044 c.negated = true;
1045 c
1046 }
1047
1048 pub async fn to_have_title(&self, expected: &str) -> Result<()> {
1050 let p = self.page.clone();
1051 let negated = self.negated;
1052 let timeout = self.timeout;
1053 let expected_owned = expected.to_string();
1054 let expected_for_msg = expected.to_string();
1055 wait_for(
1056 move || {
1057 let p = p.clone();
1058 let expected_owned = expected_owned.clone();
1059 async move { Ok(p.title().await? == expected_owned) }
1060 },
1061 timeout,
1062 negated,
1063 move || {
1064 format!(
1065 "to_have_title('{expected_for_msg}') timed out after {}ms",
1066 timeout.as_millis()
1067 )
1068 },
1069 )
1070 .await
1071 }
1072
1073 pub async fn to_have_url(&self, expected: &str) -> Result<()> {
1075 let p = self.page.clone();
1076 let negated = self.negated;
1077 let timeout = self.timeout;
1078 let expected_owned = expected.to_string();
1079 let expected_for_msg = expected.to_string();
1080 wait_for(
1081 move || {
1082 let p = p.clone();
1083 let expected_owned = expected_owned.clone();
1084 async move { Ok(p.url().await? == expected_owned) }
1085 },
1086 timeout,
1087 negated,
1088 move || {
1089 format!(
1090 "to_have_url('{expected_for_msg}') timed out after {}ms",
1091 timeout.as_millis()
1092 )
1093 },
1094 )
1095 .await
1096 }
1097
1098 pub async fn to_have_title_regex(&self, pattern: &str) -> Result<()> {
1100 let p = self.page.clone();
1101 let negated = self.negated;
1102 let timeout = self.timeout;
1103 let re = regex::Regex::new(pattern)
1104 .map_err(|e| Error::InvalidArgument(format!("invalid regex pattern {pattern:?}: {e}")))?;
1105 let pattern_for_msg = pattern.to_string();
1106 wait_for(
1107 move || {
1108 let p = p.clone();
1109 let re = re.clone();
1110 async move {
1111 let actual = p.title().await?;
1112 Ok(re.is_match(&actual))
1113 }
1114 },
1115 timeout,
1116 negated,
1117 move || {
1118 format!(
1119 "to_have_title_regex('{pattern_for_msg}') timed out after {}ms",
1120 timeout.as_millis()
1121 )
1122 },
1123 )
1124 .await
1125 }
1126
1127 pub async fn to_have_url_regex(&self, pattern: &str) -> Result<()> {
1129 let p = self.page.clone();
1130 let negated = self.negated;
1131 let timeout = self.timeout;
1132 let re = regex::Regex::new(pattern)
1133 .map_err(|e| Error::InvalidArgument(format!("invalid regex pattern {pattern:?}: {e}")))?;
1134 let pattern_for_msg = pattern.to_string();
1135 wait_for(
1136 move || {
1137 let p = p.clone();
1138 let re = re.clone();
1139 async move {
1140 let actual = p.url().await?;
1141 Ok(re.is_match(&actual))
1142 }
1143 },
1144 timeout,
1145 negated,
1146 move || {
1147 format!(
1148 "to_have_url_regex('{pattern_for_msg}') timed out after {}ms",
1149 timeout.as_millis()
1150 )
1151 },
1152 )
1153 .await
1154 }
1155
1156 pub async fn to_match_aria_snapshot(&self, expected: &str) -> Result<()> {
1162 let p = self.page.clone();
1163 let negated = self.negated;
1164 let timeout = self.timeout;
1165 let expected_owned = expected.to_string();
1166 let expected_for_msg = expected.to_string();
1167 wait_for(
1168 move || {
1169 let p = p.clone();
1170 let expected_owned = expected_owned.clone();
1171 async move {
1172 let actual = p.aria_snapshot().await?;
1173 Ok(actual.trim() == expected_owned.trim())
1174 }
1175 },
1176 timeout,
1177 negated,
1178 move || {
1179 format!(
1180 "to_match_aria_snapshot('{expected_for_msg}') timed out after {}ms",
1181 timeout.as_millis()
1182 )
1183 },
1184 )
1185 .await
1186 }
1187}