Skip to main content

playwright_cdp/
assertions.rs

1//! Playwright-style `expect(locator)` / `expect(page)` assertions.
2//!
3//! Each assertion polls its underlying [`Locator`] / [`Page`] method every
4//! ~100ms until the condition holds, or the assertion timeout (default 5s)
5//! elapses, in which case [`Error::Timeout`] is returned with a message naming
6//! the assertion and the actual-vs-expected values.
7//!
8//! Negation via [`LocatorAssertions::not`] / [`PageAssertions::not`] flips the
9//! sense: a negated assertion succeeds when the base condition is **false**
10//! within the timeout.
11
12use 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
20/// Default assertion timeout (mirrors Playwright's `expect` default).
21const DEFAULT_TIMEOUT: Duration = Duration::from_millis(5000);
22/// Polling interval between condition checks.
23const POLL_INTERVAL: Duration = Duration::from_millis(100);
24
25// ===========================================================================
26// Screenshot assertion options + pixel-tolerance comparison (feature-gated)
27// ===========================================================================
28
29/// Options for [`LocatorAssertions::to_have_screenshot`].
30///
31/// Builder-style; constructed via [`ScreenshotAssertOptions::new`] then chained
32/// setters. All fields are optional and default to a strict comparison
33/// (`max_diff_pixels = 0`, i.e. zero tolerated differing pixels).
34///
35/// **Only effective when the `screenshot-diff` cargo feature is enabled.**
36/// With the feature OFF (default), `to_have_screenshot` performs exact
37/// byte comparison and these options are ignored.
38#[derive(Debug, Clone, Default)]
39#[non_exhaustive]
40pub struct ScreenshotAssertOptions {
41    /// Maximum number of differing pixels allowed for the assertion to pass
42    /// (default `0` — strict). Only consulted when `screenshot-diff` is on.
43    pub max_diff_pixels: Option<u64>,
44    /// Per-pixel color-distance threshold in `0.0..=1.0`. Two pixels whose
45    /// normalized RGBA distance exceeds this count as "different" (default
46    /// `0.0` — pixels must be byte-identical to be considered equal). Only
47    /// consulted when `screenshot-diff` is on.
48    pub threshold: Option<f32>,
49}
50
51impl ScreenshotAssertOptions {
52    /// Create an empty (all-default) options set.
53    pub fn new() -> Self {
54        Self::default()
55    }
56
57    /// Set the maximum number of differing pixels tolerated.
58    pub fn with_max_diff_pixels(mut self, pixels: u64) -> Self {
59        self.max_diff_pixels = Some(pixels);
60        self
61    }
62
63    /// Set the per-pixel color-distance threshold (`0.0..=1.0`).
64    pub fn with_threshold(mut self, threshold: f32) -> Self {
65        self.threshold = Some(threshold);
66        self
67    }
68}
69
70/// Write `actual` bytes to `{path-stem}-actual.png` next to the baseline.
71/// `path` is the baseline path ending in `.png`.
72fn 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/// Outcome of comparing two decoded screenshots.
81#[cfg(feature = "screenshot-diff")]
82enum ScreenshotCmp {
83    /// Pixels are within tolerance.
84    Match,
85    /// Pixels exceed tolerance. `diff` is the differing-pixel count, `total`
86    /// the total pixel count of the (matched-size) image.
87    Mismatch { diff: u64, total: u64 },
88}
89
90/// Decode two PNG byte buffers and compare them pixel-by-pixel with the
91/// tolerance in `opts`. Returns [`ScreenshotCmp::Match`] when within tolerance.
92///
93/// - Different dimensions -> always a mismatch (`diff = total` of the larger).
94/// - A pixel counts as "different" when its normalized RGBA Euclidean distance
95///   exceeds `threshold` (default `0.0`, i.e. any channel difference).
96/// - The comparison passes when `diff <= max_diff_pixels` (default `0`).
97#[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    // Different dimensions -> never a match.
114    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            // Normalized per-channel distance (each channel 0.0-1.0).
136            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
155// --- top-level constructors ---
156
157/// Construct [`LocatorAssertions`] for `locator`, mirroring Playwright's
158/// `expect(locator)`.
159pub fn expect(locator: Locator) -> LocatorAssertions {
160    LocatorAssertions::new(locator)
161}
162
163/// Construct [`PageAssertions`] for `page`. Mirrors Playwright's
164/// `expect(page)`. (Distinct name from [`expect`] because Rust cannot overload
165/// `expect` on the parameter type without a trait.)
166pub fn expect_page(page: Page) -> PageAssertions {
167    PageAssertions::new(page)
168}
169
170/// Poll `cond` every [`POLL_INTERVAL`] until it returns `Ok(want)`, where
171/// `want = !negated`. Returns `Ok(())` on success, or `Err(Error::Timeout(msg))`
172/// on timeout. The `msg` closure produces the failure message lazily.
173///
174/// `cond` errors are propagated immediately (they are not assertion failures).
175async 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// ===========================================================================
196// LocatorAssertions
197// ===========================================================================
198
199/// Assertions over a [`Locator`], produced by [`expect`].
200#[derive(Clone)]
201pub struct LocatorAssertions {
202    locator: Locator,
203    timeout: Duration,
204    negated: bool,
205}
206
207impl LocatorAssertions {
208    /// Wrap a locator with the default 5s timeout, non-negated.
209    pub fn new(locator: Locator) -> Self {
210        Self {
211            locator,
212            timeout: DEFAULT_TIMEOUT,
213            negated: false,
214        }
215    }
216
217    /// Builder: override the assertion timeout.
218    pub fn with_timeout(mut self, timeout: Duration) -> Self {
219        self.timeout = timeout;
220        self
221    }
222
223    /// Override the assertion timeout in place.
224    pub fn set_timeout(&mut self, timeout: Duration) -> &mut Self {
225        self.timeout = timeout;
226        self
227    }
228
229    /// Return a negated clone: succeeding when the base condition is false.
230    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    // --- visibility / state ---
241
242    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                    // unchecked == !checked
354                    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    // --- text ---
365
366    /// Assert the element's trimmed `textContent` equals `expected`.
367    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    /// Assert the element's trimmed `textContent` contains `expected`.
397    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    /// Assert the element's `innerText` equals `expected`.
429    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    // --- text (regex variants) ---
458
459    /// Assert the element's trimmed `textContent` matches the regex `pattern`.
460    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    /// Assert the element's `textContent` matches the regex `pattern` (no trim).
491    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    /// Assert the element's `innerText` matches the regex `pattern`.
522    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    // --- count ---
552
553    /// Assert the number of matching elements equals `expected`.
554    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    // --- attributes / value ---
577
578    /// Assert the element has attribute `name` equal to `value`.
579    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    /// Assert the element's input value equals `expected`.
611    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    /// Assert the element's input value matches the regex `pattern`.
637    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    /// Assert the element's `class` attribute matches the regex `pattern`.
667    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    // --- CSS (computed style) ---
703
704    /// Assert the element's computed CSS property `name` equals `value`.
705    ///
706    /// Reads the value via `getComputedStyle(el).getPropertyValue(name)` and
707    /// compares for exact equality.
708    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    /// Assert the element's computed CSS property `name` matches the regex
743    /// `pattern`.
744    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    // --- class ---
780
781    /// Assert the element's `class` attribute equals `expected`.
782    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    /// Assert the element's `class` attribute matches the regex `pattern`.
811    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    // --- focus ---
844
845    /// Assert the element is the current `document.activeElement`.
846    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    // --- ARIA snapshot ---
864
865    /// Assert the element's aria snapshot equals `expected`.
866    ///
867    /// Simplified variant: compares the trimmed snapshot string against the
868    /// trimmed `expected` for exact equality. This is **not** Playwright's
869    /// structured accessibility-tree diff (it does no node normalization or
870    /// whitespace folding beyond `trim()`), so mismatches in indentation or
871    /// ordering will fail even when the trees are semantically equivalent.
872    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    // --- screenshot ---
901
902    /// Assert the element's screenshot matches a baseline PNG file `name`.
903    ///
904    /// A `.png` suffix is appended to `name` if absent. If the baseline does
905    /// not yet exist, the first run writes it (establishing the baseline) and
906    /// succeeds.
907    ///
908    /// **Comparison mode depends on the `screenshot-diff` cargo feature:**
909    ///
910    /// - **Feature OFF (default):** exact byte-level comparison of the
911    ///   captured PNG against the baseline. The rendered bytes must be
912    ///   identical. The `options` argument is accepted but ignored.
913    /// - **Feature ON (`screenshot-diff`):** pixel-level tolerance comparison
914    ///   via the `image` crate. Both PNGs are decoded to RGBA and compared
915    ///   pixel-by-pixel; a mismatch is reported only when the number of
916    ///   differing pixels exceeds the tolerance in [`ScreenshotAssertOptions`]
917    ///   (`max_diff_pixels` / `threshold`). Disable the feature to drop the
918    ///   `image` dependency entirely.
919    ///
920    /// On mismatch the actual bytes are always dumped to `{name}-actual.png`
921    /// for inspection, and the error message carries diff statistics.
922    ///
923    /// Because this captures a single frame rather than polling a condition,
924    /// it bypasses [`wait_for`] and runs to completion immediately.
925    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            // No options consumed in byte mode; silence unused-variable lint.
965            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    /// Assert the element is empty: an empty input value, or zero matching
980    /// elements.
981    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                    // For a present element, "empty" means an empty input value.
995                    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// ===========================================================================
1008// PageAssertions
1009// ===========================================================================
1010
1011/// Assertions over a [`Page`], produced by [`expect_page`].
1012#[derive(Clone)]
1013pub struct PageAssertions {
1014    page: Page,
1015    timeout: Duration,
1016    negated: bool,
1017}
1018
1019impl PageAssertions {
1020    /// Wrap a page with the default 5s timeout, non-negated.
1021    pub fn new(page: Page) -> Self {
1022        Self {
1023            page,
1024            timeout: DEFAULT_TIMEOUT,
1025            negated: false,
1026        }
1027    }
1028
1029    /// Builder: override the assertion timeout.
1030    pub fn with_timeout(mut self, timeout: Duration) -> Self {
1031        self.timeout = timeout;
1032        self
1033    }
1034
1035    /// Override the assertion timeout in place.
1036    pub fn set_timeout(&mut self, timeout: Duration) -> &mut Self {
1037        self.timeout = timeout;
1038        self
1039    }
1040
1041    /// Return a negated clone: succeeding when the base condition is false.
1042    pub fn not(&self) -> Self {
1043        let mut c = self.clone();
1044        c.negated = true;
1045        c
1046    }
1047
1048    /// Assert the page title equals `expected`.
1049    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    /// Assert the page URL equals `expected`.
1074    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    /// Assert the page title matches the regex `pattern`.
1099    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    /// Assert the page URL matches the regex `pattern`.
1128    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    /// Assert the page's aria snapshot equals `expected`.
1157    ///
1158    /// Simplified variant: compares the trimmed snapshot string against the
1159    /// trimmed `expected` for exact equality — **not** Playwright's structured
1160    /// accessibility-tree diff.
1161    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}