Skip to main content

playwright_rs/
assertions.rs

1// Assertions - Auto-retry assertions for testing
2//
3// Provides expect() API with auto-retry logic matching Playwright's assertions.
4//
5// See: https://playwright.dev/docs/test-assertions
6
7use crate::error::Result;
8use crate::protocol::{Locator, Page};
9#[cfg(feature = "screenshot-diff")]
10use std::path::Path;
11use std::time::Duration;
12
13/// Default timeout for assertions (5 seconds, matching Playwright)
14const DEFAULT_ASSERTION_TIMEOUT: Duration = Duration::from_secs(5);
15
16/// Default polling interval for assertions (100ms)
17const DEFAULT_POLL_INTERVAL: Duration = Duration::from_millis(100);
18
19/// Creates an expectation for a locator with auto-retry behavior.
20///
21/// Assertions will retry until they pass or timeout (default: 5 seconds).
22///
23/// # Example
24///
25/// ```no_run
26/// use playwright_rs::{expect, protocol::Playwright};
27/// use std::time::Duration;
28///
29/// #[tokio::main]
30/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
31///     let playwright = Playwright::launch().await?;
32///     let browser = playwright.chromium().launch().await?;
33///     let page = browser.new_page().await?;
34///
35///     // Test to_be_visible and to_be_hidden
36///     page.goto("data:text/html,<button id='btn'>Click me</button><div id='hidden' style='display:none'>Hidden</div>", None).await?;
37///     expect(page.locator("#btn")).to_be_visible().await?;
38///     expect(page.locator("#hidden")).to_be_hidden().await?;
39///
40///     // Test not() negation
41///     expect(page.locator("#btn")).not().to_be_hidden().await?;
42///     expect(page.locator("#hidden")).not().to_be_visible().await?;
43///
44///     // Test with_timeout()
45///     page.goto("data:text/html,<div id='element'>Visible</div>", None).await?;
46///     expect(page.locator("#element"))
47///         .with_timeout(Duration::from_secs(10))
48///         .to_be_visible()
49///         .await?;
50///
51///     // Test to_be_enabled and to_be_disabled
52///     page.goto("data:text/html,<button id='enabled'>Enabled</button><button id='disabled' disabled>Disabled</button>", None).await?;
53///     expect(page.locator("#enabled")).to_be_enabled().await?;
54///     expect(page.locator("#disabled")).to_be_disabled().await?;
55///
56///     // Test to_be_checked and to_be_unchecked
57///     page.goto("data:text/html,<input type='checkbox' id='checked' checked><input type='checkbox' id='unchecked'>", None).await?;
58///     expect(page.locator("#checked")).to_be_checked().await?;
59///     expect(page.locator("#unchecked")).to_be_unchecked().await?;
60///
61///     // Test to_be_editable
62///     page.goto("data:text/html,<input type='text' id='editable'>", None).await?;
63///     expect(page.locator("#editable")).to_be_editable().await?;
64///
65///     // Test to_be_focused
66///     page.goto("data:text/html,<input type='text' id='input'>", None).await?;
67///     page.evaluate::<(), ()>("document.getElementById('input').focus()", None).await?;
68///     expect(page.locator("#input")).to_be_focused().await?;
69///
70///     // Test to_contain_text
71///     page.goto("data:text/html,<div id='content'>Hello World</div>", None).await?;
72///     expect(page.locator("#content")).to_contain_text("Hello").await?;
73///     expect(page.locator("#content")).to_contain_text("World").await?;
74///
75///     // Test to_have_text
76///     expect(page.locator("#content")).to_have_text("Hello World").await?;
77///
78///     // Test to_have_value
79///     page.goto("data:text/html,<input type='text' id='input' value='test value'>", None).await?;
80///     expect(page.locator("#input")).to_have_value("test value").await?;
81///
82///     // Test to_have_attribute / to_have_class / to_have_css / to_have_count
83///     page.goto(
84///         "data:text/html,<a id='link' class='primary' href='/x' style='color:red'>A</a><a class='primary'>B</a>",
85///         None,
86///     ).await?;
87///     expect(page.locator("#link")).to_have_attribute("href", "/x").await?;
88///     expect(page.locator("#link")).to_have_class("primary").await?;
89///     expect(page.locator("#link")).to_have_css("color", "rgb(255, 0, 0)").await?;
90///     expect(page.locator(".primary")).to_have_count(2).await?;
91///
92///     browser.close().await?;
93///     Ok(())
94/// }
95/// ```
96///
97/// See: <https://playwright.dev/docs/test-assertions>
98pub fn expect(locator: Locator) -> Expectation {
99    Expectation::new(locator)
100}
101
102/// Collapses runs of whitespace (spaces, tabs, newlines) to single spaces and
103/// trims the ends, matching upstream Playwright's whitespace normalization for
104/// the string-argument text assertions. The regex assertion variants match the
105/// raw text and must not use this.
106fn normalize_whitespace(s: &str) -> String {
107    s.split_whitespace().collect::<Vec<_>>().join(" ")
108}
109
110/// Expectation wraps a locator and provides assertion methods with auto-retry.
111pub struct Expectation {
112    locator: Locator,
113    timeout: Duration,
114    poll_interval: Duration,
115    negate: bool,
116}
117
118// Allow clippy::wrong_self_convention for to_* methods that consume self
119// This matches Playwright's expect API pattern where assertions are chained and consumed
120#[allow(clippy::wrong_self_convention)]
121impl Expectation {
122    /// Creates a new expectation for the given locator.
123    pub(crate) fn new(locator: Locator) -> Self {
124        Self {
125            locator,
126            timeout: DEFAULT_ASSERTION_TIMEOUT,
127            poll_interval: DEFAULT_POLL_INTERVAL,
128            negate: false,
129        }
130    }
131
132    /// Sets a custom timeout for this assertion.
133    ///
134    pub fn with_timeout(mut self, timeout: Duration) -> Self {
135        self.timeout = timeout;
136        self
137    }
138
139    /// Sets a custom poll interval for this assertion.
140    ///
141    /// Default is 100ms.
142    pub fn with_poll_interval(mut self, interval: Duration) -> Self {
143        self.poll_interval = interval;
144        self
145    }
146
147    /// Negates the assertion.
148    ///
149    /// Note: We intentionally use `.not()` method instead of implementing `std::ops::Not`
150    /// to match Playwright's API across all language bindings (JS/Python/Java/.NET).
151    #[allow(clippy::should_implement_trait)]
152    pub fn not(mut self) -> Self {
153        self.negate = true;
154        self
155    }
156
157    /// Asserts that the element is visible.
158    ///
159    /// This assertion will retry until the element becomes visible or timeout.
160    ///
161    /// See: <https://playwright.dev/docs/test-assertions#locator-assertions-to-be-visible>
162    pub async fn to_be_visible(self) -> Result<()> {
163        let start = std::time::Instant::now();
164        let selector = self.locator.selector().to_string();
165
166        loop {
167            let is_visible = self.locator.is_visible().await?;
168
169            // Check if condition matches (with negation support)
170            let matches = if self.negate { !is_visible } else { is_visible };
171
172            if matches {
173                return Ok(());
174            }
175
176            // Check timeout
177            if start.elapsed() >= self.timeout {
178                let message = if self.negate {
179                    format!(
180                        "Expected element '{}' NOT to be visible, but it was visible after {:?}",
181                        selector, self.timeout
182                    )
183                } else {
184                    format!(
185                        "Expected element '{}' to be visible, but it was not visible after {:?}",
186                        selector, self.timeout
187                    )
188                };
189                return Err(crate::error::Error::AssertionTimeout(message));
190            }
191
192            // Wait before next poll
193            tokio::time::sleep(self.poll_interval).await;
194        }
195    }
196
197    /// Asserts that the element is hidden (not visible).
198    ///
199    /// This assertion will retry until the element becomes hidden or timeout.
200    ///
201    /// See: <https://playwright.dev/docs/test-assertions#locator-assertions-to-be-hidden>
202    pub async fn to_be_hidden(self) -> Result<()> {
203        // to_be_hidden is the opposite of to_be_visible
204        // Use negation to reuse the visibility logic
205        let negated = Expectation {
206            negate: !self.negate, // Flip negation
207            ..self
208        };
209        negated.to_be_visible().await
210    }
211
212    /// Asserts that the element has the specified text content (exact match).
213    ///
214    /// This assertion will retry until the element has the exact text or timeout.
215    /// Whitespace is normalized in both the element text and the expected string
216    /// before comparison (runs of whitespace, including newlines, collapse to
217    /// single spaces), matching upstream Playwright — so multi-line rendered
218    /// text matches a single-line expectation. Use
219    /// [`to_have_text_regex`](Self::to_have_text_regex) to match the raw text.
220    ///
221    /// See: <https://playwright.dev/docs/test-assertions#locator-assertions-to-have-text>
222    pub async fn to_have_text(self, expected: &str) -> Result<()> {
223        let start = std::time::Instant::now();
224        let selector = self.locator.selector().to_string();
225        let expected = normalize_whitespace(expected);
226
227        loop {
228            // Get text content (using inner_text for consistency with Playwright)
229            let actual_text = self.locator.inner_text().await?;
230            let actual = normalize_whitespace(&actual_text);
231
232            // Check if condition matches (with negation support)
233            let matches = if self.negate {
234                actual != expected
235            } else {
236                actual == expected
237            };
238
239            if matches {
240                return Ok(());
241            }
242
243            // Check timeout
244            if start.elapsed() >= self.timeout {
245                let message = if self.negate {
246                    format!(
247                        "Expected element '{}' NOT to have text '{}', but it did after {:?}",
248                        selector, expected, self.timeout
249                    )
250                } else {
251                    format!(
252                        "Expected element '{}' to have text '{}', but had '{}' after {:?}",
253                        selector, expected, actual, self.timeout
254                    )
255                };
256                return Err(crate::error::Error::AssertionTimeout(message));
257            }
258
259            // Wait before next poll
260            tokio::time::sleep(self.poll_interval).await;
261        }
262    }
263
264    /// Asserts that the element's text matches the specified regex pattern.
265    ///
266    /// This assertion will retry until the element's text matches the pattern or timeout.
267    pub async fn to_have_text_regex(self, pattern: &str) -> Result<()> {
268        let start = std::time::Instant::now();
269        let selector = self.locator.selector().to_string();
270        let re = regex::Regex::new(pattern)
271            .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
272
273        loop {
274            let actual_text = self.locator.inner_text().await?;
275            let actual = actual_text.trim();
276
277            // Check if condition matches (with negation support)
278            let matches = if self.negate {
279                !re.is_match(actual)
280            } else {
281                re.is_match(actual)
282            };
283
284            if matches {
285                return Ok(());
286            }
287
288            // Check timeout
289            if start.elapsed() >= self.timeout {
290                let message = if self.negate {
291                    format!(
292                        "Expected element '{}' NOT to match pattern '{}', but it did after {:?}",
293                        selector, pattern, self.timeout
294                    )
295                } else {
296                    format!(
297                        "Expected element '{}' to match pattern '{}', but had '{}' after {:?}",
298                        selector, pattern, actual, self.timeout
299                    )
300                };
301                return Err(crate::error::Error::AssertionTimeout(message));
302            }
303
304            // Wait before next poll
305            tokio::time::sleep(self.poll_interval).await;
306        }
307    }
308
309    /// Asserts that the element contains the specified text (substring match).
310    ///
311    /// This assertion will retry until the element contains the text or timeout.
312    /// Whitespace is normalized in both the element text and the expected string
313    /// before comparison (runs of whitespace, including newlines, collapse to
314    /// single spaces), matching upstream Playwright — so multi-line rendered
315    /// text matches a single-line expectation. Use
316    /// [`to_contain_text_regex`](Self::to_contain_text_regex) to match the raw
317    /// text.
318    ///
319    /// See: <https://playwright.dev/docs/test-assertions#locator-assertions-to-contain-text>
320    pub async fn to_contain_text(self, expected: &str) -> Result<()> {
321        let start = std::time::Instant::now();
322        let selector = self.locator.selector().to_string();
323        let expected = normalize_whitespace(expected);
324
325        loop {
326            let actual_text = self.locator.inner_text().await?;
327            let actual = normalize_whitespace(&actual_text);
328
329            // Check if condition matches (with negation support)
330            let matches = if self.negate {
331                !actual.contains(&expected)
332            } else {
333                actual.contains(&expected)
334            };
335
336            if matches {
337                return Ok(());
338            }
339
340            // Check timeout
341            if start.elapsed() >= self.timeout {
342                let message = if self.negate {
343                    format!(
344                        "Expected element '{}' NOT to contain text '{}', but it did after {:?}",
345                        selector, expected, self.timeout
346                    )
347                } else {
348                    format!(
349                        "Expected element '{}' to contain text '{}', but had '{}' after {:?}",
350                        selector, expected, actual, self.timeout
351                    )
352                };
353                return Err(crate::error::Error::AssertionTimeout(message));
354            }
355
356            // Wait before next poll
357            tokio::time::sleep(self.poll_interval).await;
358        }
359    }
360
361    /// Asserts that the element's text contains a substring matching the regex pattern.
362    ///
363    /// This assertion will retry until the element contains the pattern or timeout.
364    pub async fn to_contain_text_regex(self, pattern: &str) -> Result<()> {
365        let start = std::time::Instant::now();
366        let selector = self.locator.selector().to_string();
367        let re = regex::Regex::new(pattern)
368            .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
369
370        loop {
371            let actual_text = self.locator.inner_text().await?;
372            let actual = actual_text.trim();
373
374            // Check if condition matches (with negation support)
375            let matches = if self.negate {
376                !re.is_match(actual)
377            } else {
378                re.is_match(actual)
379            };
380
381            if matches {
382                return Ok(());
383            }
384
385            // Check timeout
386            if start.elapsed() >= self.timeout {
387                let message = if self.negate {
388                    format!(
389                        "Expected element '{}' NOT to contain pattern '{}', but it did after {:?}",
390                        selector, pattern, self.timeout
391                    )
392                } else {
393                    format!(
394                        "Expected element '{}' to contain pattern '{}', but had '{}' after {:?}",
395                        selector, pattern, actual, self.timeout
396                    )
397                };
398                return Err(crate::error::Error::AssertionTimeout(message));
399            }
400
401            // Wait before next poll
402            tokio::time::sleep(self.poll_interval).await;
403        }
404    }
405
406    /// Asserts that the input element has the specified value.
407    ///
408    /// This assertion will retry until the input has the exact value or timeout.
409    ///
410    /// See: <https://playwright.dev/docs/test-assertions#locator-assertions-to-have-value>
411    pub async fn to_have_value(self, expected: &str) -> Result<()> {
412        let start = std::time::Instant::now();
413        let selector = self.locator.selector().to_string();
414
415        loop {
416            let actual = self.locator.input_value(None).await?;
417
418            // Check if condition matches (with negation support)
419            let matches = if self.negate {
420                actual != expected
421            } else {
422                actual == expected
423            };
424
425            if matches {
426                return Ok(());
427            }
428
429            // Check timeout
430            if start.elapsed() >= self.timeout {
431                let message = if self.negate {
432                    format!(
433                        "Expected input '{}' NOT to have value '{}', but it did after {:?}",
434                        selector, expected, self.timeout
435                    )
436                } else {
437                    format!(
438                        "Expected input '{}' to have value '{}', but had '{}' after {:?}",
439                        selector, expected, actual, self.timeout
440                    )
441                };
442                return Err(crate::error::Error::AssertionTimeout(message));
443            }
444
445            // Wait before next poll
446            tokio::time::sleep(self.poll_interval).await;
447        }
448    }
449
450    /// Asserts that the input element's value matches the specified regex pattern.
451    ///
452    /// This assertion will retry until the input value matches the pattern or timeout.
453    pub async fn to_have_value_regex(self, pattern: &str) -> Result<()> {
454        let start = std::time::Instant::now();
455        let selector = self.locator.selector().to_string();
456        let re = regex::Regex::new(pattern)
457            .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
458
459        loop {
460            let actual = self.locator.input_value(None).await?;
461
462            // Check if condition matches (with negation support)
463            let matches = if self.negate {
464                !re.is_match(&actual)
465            } else {
466                re.is_match(&actual)
467            };
468
469            if matches {
470                return Ok(());
471            }
472
473            // Check timeout
474            if start.elapsed() >= self.timeout {
475                let message = if self.negate {
476                    format!(
477                        "Expected input '{}' NOT to match pattern '{}', but it did after {:?}",
478                        selector, pattern, self.timeout
479                    )
480                } else {
481                    format!(
482                        "Expected input '{}' to match pattern '{}', but had '{}' after {:?}",
483                        selector, pattern, actual, self.timeout
484                    )
485                };
486                return Err(crate::error::Error::AssertionTimeout(message));
487            }
488
489            // Wait before next poll
490            tokio::time::sleep(self.poll_interval).await;
491        }
492    }
493
494    /// Asserts that the element is enabled.
495    ///
496    /// This assertion will retry until the element is enabled or timeout.
497    /// An element is enabled if it does not have the "disabled" attribute.
498    ///
499    /// See: <https://playwright.dev/docs/test-assertions#locator-assertions-to-be-enabled>
500    pub async fn to_be_enabled(self) -> Result<()> {
501        let start = std::time::Instant::now();
502        let selector = self.locator.selector().to_string();
503
504        loop {
505            let is_enabled = self.locator.is_enabled().await?;
506
507            // Check if condition matches (with negation support)
508            let matches = if self.negate { !is_enabled } else { is_enabled };
509
510            if matches {
511                return Ok(());
512            }
513
514            // Check timeout
515            if start.elapsed() >= self.timeout {
516                let message = if self.negate {
517                    format!(
518                        "Expected element '{}' NOT to be enabled, but it was enabled after {:?}",
519                        selector, self.timeout
520                    )
521                } else {
522                    format!(
523                        "Expected element '{}' to be enabled, but it was not enabled after {:?}",
524                        selector, self.timeout
525                    )
526                };
527                return Err(crate::error::Error::AssertionTimeout(message));
528            }
529
530            // Wait before next poll
531            tokio::time::sleep(self.poll_interval).await;
532        }
533    }
534
535    /// Asserts that the element is disabled.
536    ///
537    /// This assertion will retry until the element is disabled or timeout.
538    /// An element is disabled if it has the "disabled" attribute.
539    ///
540    /// See: <https://playwright.dev/docs/test-assertions#locator-assertions-to-be-disabled>
541    pub async fn to_be_disabled(self) -> Result<()> {
542        // to_be_disabled is the opposite of to_be_enabled
543        // Use negation to reuse the enabled logic
544        let negated = Expectation {
545            negate: !self.negate, // Flip negation
546            ..self
547        };
548        negated.to_be_enabled().await
549    }
550
551    /// Asserts that the checkbox or radio button is checked.
552    ///
553    /// This assertion will retry until the element is checked or timeout.
554    ///
555    /// See: <https://playwright.dev/docs/test-assertions#locator-assertions-to-be-checked>
556    pub async fn to_be_checked(self) -> Result<()> {
557        let start = std::time::Instant::now();
558        let selector = self.locator.selector().to_string();
559
560        loop {
561            let is_checked = self.locator.is_checked().await?;
562
563            // Check if condition matches (with negation support)
564            let matches = if self.negate { !is_checked } else { is_checked };
565
566            if matches {
567                return Ok(());
568            }
569
570            // Check timeout
571            if start.elapsed() >= self.timeout {
572                let message = if self.negate {
573                    format!(
574                        "Expected element '{}' NOT to be checked, but it was checked after {:?}",
575                        selector, self.timeout
576                    )
577                } else {
578                    format!(
579                        "Expected element '{}' to be checked, but it was not checked after {:?}",
580                        selector, self.timeout
581                    )
582                };
583                return Err(crate::error::Error::AssertionTimeout(message));
584            }
585
586            // Wait before next poll
587            tokio::time::sleep(self.poll_interval).await;
588        }
589    }
590
591    /// Asserts that the checkbox or radio button is unchecked.
592    ///
593    /// This assertion will retry until the element is unchecked or timeout.
594    ///
595    /// See: <https://playwright.dev/docs/test-assertions#locator-assertions-to-be-checked>
596    pub async fn to_be_unchecked(self) -> Result<()> {
597        // to_be_unchecked is the opposite of to_be_checked
598        // Use negation to reuse the checked logic
599        let negated = Expectation {
600            negate: !self.negate, // Flip negation
601            ..self
602        };
603        negated.to_be_checked().await
604    }
605
606    /// Asserts that the element is editable.
607    ///
608    /// This assertion will retry until the element is editable or timeout.
609    /// An element is editable if it is enabled and does not have the "readonly" attribute.
610    ///
611    /// See: <https://playwright.dev/docs/test-assertions#locator-assertions-to-be-editable>
612    pub async fn to_be_editable(self) -> Result<()> {
613        let start = std::time::Instant::now();
614        let selector = self.locator.selector().to_string();
615
616        loop {
617            let is_editable = self.locator.is_editable().await?;
618
619            // Check if condition matches (with negation support)
620            let matches = if self.negate {
621                !is_editable
622            } else {
623                is_editable
624            };
625
626            if matches {
627                return Ok(());
628            }
629
630            // Check timeout
631            if start.elapsed() >= self.timeout {
632                let message = if self.negate {
633                    format!(
634                        "Expected element '{}' NOT to be editable, but it was editable after {:?}",
635                        selector, self.timeout
636                    )
637                } else {
638                    format!(
639                        "Expected element '{}' to be editable, but it was not editable after {:?}",
640                        selector, self.timeout
641                    )
642                };
643                return Err(crate::error::Error::AssertionTimeout(message));
644            }
645
646            // Wait before next poll
647            tokio::time::sleep(self.poll_interval).await;
648        }
649    }
650
651    /// Asserts that the element is focused (currently has focus).
652    ///
653    /// This assertion will retry until the element becomes focused or timeout.
654    ///
655    /// See: <https://playwright.dev/docs/test-assertions#locator-assertions-to-be-focused>
656    pub async fn to_be_focused(self) -> Result<()> {
657        let start = std::time::Instant::now();
658        let selector = self.locator.selector().to_string();
659
660        loop {
661            let is_focused = self.locator.is_focused().await?;
662
663            // Check if condition matches (with negation support)
664            let matches = if self.negate { !is_focused } else { is_focused };
665
666            if matches {
667                return Ok(());
668            }
669
670            // Check timeout
671            if start.elapsed() >= self.timeout {
672                let message = if self.negate {
673                    format!(
674                        "Expected element '{}' NOT to be focused, but it was focused after {:?}",
675                        selector, self.timeout
676                    )
677                } else {
678                    format!(
679                        "Expected element '{}' to be focused, but it was not focused after {:?}",
680                        selector, self.timeout
681                    )
682                };
683                return Err(crate::error::Error::AssertionTimeout(message));
684            }
685
686            // Wait before next poll
687            tokio::time::sleep(self.poll_interval).await;
688        }
689    }
690
691    /// Asserts that the element has the specified attribute set to the given value.
692    ///
693    /// A missing attribute (rather than one set to an empty string) never matches.
694    ///
695    /// See: <https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-have-attribute>
696    pub async fn to_have_attribute(self, name: &str, value: &str) -> Result<()> {
697        let start = std::time::Instant::now();
698        let selector = self.locator.selector().to_string();
699
700        loop {
701            let actual = self.locator.get_attribute(name).await?;
702
703            let matched = actual.as_deref() == Some(value);
704            let matches = if self.negate { !matched } else { matched };
705
706            if matches {
707                return Ok(());
708            }
709
710            if start.elapsed() >= self.timeout {
711                let actual_display = actual.as_deref().unwrap_or("<missing>");
712                let message = if self.negate {
713                    format!(
714                        "Expected element '{}' NOT to have attribute '{}'='{}', but it did after {:?}",
715                        selector, name, value, self.timeout
716                    )
717                } else {
718                    format!(
719                        "Expected element '{}' to have attribute '{}'='{}', but had '{}' after {:?}",
720                        selector, name, value, actual_display, self.timeout
721                    )
722                };
723                return Err(crate::error::Error::AssertionTimeout(message));
724            }
725
726            tokio::time::sleep(self.poll_interval).await;
727        }
728    }
729
730    /// Asserts that the element's attribute value matches the specified regex pattern.
731    ///
732    /// A missing attribute never matches.
733    pub async fn to_have_attribute_regex(self, name: &str, pattern: &str) -> Result<()> {
734        let start = std::time::Instant::now();
735        let selector = self.locator.selector().to_string();
736        let re = regex::Regex::new(pattern)
737            .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
738
739        loop {
740            let actual = self.locator.get_attribute(name).await?;
741
742            let matched = actual.as_deref().is_some_and(|v| re.is_match(v));
743            let matches = if self.negate { !matched } else { matched };
744
745            if matches {
746                return Ok(());
747            }
748
749            if start.elapsed() >= self.timeout {
750                let actual_display = actual.as_deref().unwrap_or("<missing>");
751                let message = if self.negate {
752                    format!(
753                        "Expected element '{}' attribute '{}' NOT to match pattern '{}', but it did after {:?}",
754                        selector, name, pattern, self.timeout
755                    )
756                } else {
757                    format!(
758                        "Expected element '{}' attribute '{}' to match pattern '{}', but had '{}' after {:?}",
759                        selector, name, pattern, actual_display, self.timeout
760                    )
761                };
762                return Err(crate::error::Error::AssertionTimeout(message));
763            }
764
765            tokio::time::sleep(self.poll_interval).await;
766        }
767    }
768
769    /// Asserts that the element has exactly the specified `class` attribute string.
770    ///
771    /// Mirrors Playwright's string-form behaviour: the element's full `class` attribute
772    /// (whitespace-trimmed) must equal `expected`. To match against a regex, use
773    /// [`to_have_class_regex`](Self::to_have_class_regex).
774    ///
775    /// See: <https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-have-class>
776    pub async fn to_have_class(self, expected: &str) -> Result<()> {
777        let start = std::time::Instant::now();
778        let selector = self.locator.selector().to_string();
779
780        loop {
781            let actual = self
782                .locator
783                .get_attribute("class")
784                .await?
785                .unwrap_or_default();
786            let actual_trimmed = actual.trim();
787
788            let matched = actual_trimmed == expected;
789            let matches = if self.negate { !matched } else { matched };
790
791            if matches {
792                return Ok(());
793            }
794
795            if start.elapsed() >= self.timeout {
796                let message = if self.negate {
797                    format!(
798                        "Expected element '{}' NOT to have class '{}', but it did after {:?}",
799                        selector, expected, self.timeout
800                    )
801                } else {
802                    format!(
803                        "Expected element '{}' to have class '{}', but had '{}' after {:?}",
804                        selector, expected, actual_trimmed, self.timeout
805                    )
806                };
807                return Err(crate::error::Error::AssertionTimeout(message));
808            }
809
810            tokio::time::sleep(self.poll_interval).await;
811        }
812    }
813
814    /// Asserts that the element's `class` attribute matches the specified regex pattern.
815    pub async fn to_have_class_regex(self, pattern: &str) -> Result<()> {
816        let start = std::time::Instant::now();
817        let selector = self.locator.selector().to_string();
818        let re = regex::Regex::new(pattern)
819            .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
820
821        loop {
822            let actual = self
823                .locator
824                .get_attribute("class")
825                .await?
826                .unwrap_or_default();
827
828            let matched = re.is_match(&actual);
829            let matches = if self.negate { !matched } else { matched };
830
831            if matches {
832                return Ok(());
833            }
834
835            if start.elapsed() >= self.timeout {
836                let message = if self.negate {
837                    format!(
838                        "Expected element '{}' class NOT to match pattern '{}', but it did after {:?}",
839                        selector, pattern, self.timeout
840                    )
841                } else {
842                    format!(
843                        "Expected element '{}' class to match pattern '{}', but had '{}' after {:?}",
844                        selector, pattern, actual, self.timeout
845                    )
846                };
847                return Err(crate::error::Error::AssertionTimeout(message));
848            }
849
850            tokio::time::sleep(self.poll_interval).await;
851        }
852    }
853
854    /// Asserts that the element has the given computed CSS property value.
855    ///
856    /// The value is read via `getComputedStyle(element).getPropertyValue(name)`, so
857    /// browser-normalized representations apply (e.g. `rgb(255, 0, 0)` rather than
858    /// `red`, `400` for `font-weight: bold`).
859    ///
860    /// See: <https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-have-css>
861    pub async fn to_have_css(self, name: &str, value: &str) -> Result<()> {
862        self.to_have_css_inner(name, value, None).await
863    }
864
865    /// Asserts the computed CSS of a **pseudo-element** (e.g. `"::before"`,
866    /// `"::after"`) matches `value`. Otherwise like
867    /// [`to_have_css`](Self::to_have_css).
868    ///
869    /// See: <https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-have-css>
870    pub async fn to_have_css_pseudo(self, name: &str, value: &str, pseudo: &str) -> Result<()> {
871        self.to_have_css_inner(name, value, Some(pseudo)).await
872    }
873
874    async fn to_have_css_inner(self, name: &str, value: &str, pseudo: Option<&str>) -> Result<()> {
875        let start = std::time::Instant::now();
876        let selector = self.locator.selector().to_string();
877        let getter = match pseudo {
878            Some(p) => format!(
879                "getComputedStyle(el, {})",
880                serde_json::to_string(p).unwrap()
881            ),
882            None => "getComputedStyle(el)".to_string(),
883        };
884        let expr = format!(
885            "(el) => {}.getPropertyValue({})",
886            getter,
887            serde_json::to_string(name).unwrap()
888        );
889
890        loop {
891            let actual: String = self.locator.evaluate(&expr, None::<()>).await?;
892
893            let matched = actual == value;
894            let matches = if self.negate { !matched } else { matched };
895
896            if matches {
897                return Ok(());
898            }
899
900            if start.elapsed() >= self.timeout {
901                let message = if self.negate {
902                    format!(
903                        "Expected element '{}' NOT to have CSS '{}'='{}', but it did after {:?}",
904                        selector, name, value, self.timeout
905                    )
906                } else {
907                    format!(
908                        "Expected element '{}' to have CSS '{}'='{}', but had '{}' after {:?}",
909                        selector, name, value, actual, self.timeout
910                    )
911                };
912                return Err(crate::error::Error::AssertionTimeout(message));
913            }
914
915            tokio::time::sleep(self.poll_interval).await;
916        }
917    }
918
919    /// Asserts that the element's computed CSS property matches the specified regex pattern.
920    pub async fn to_have_css_regex(self, name: &str, pattern: &str) -> Result<()> {
921        let start = std::time::Instant::now();
922        let selector = self.locator.selector().to_string();
923        let re = regex::Regex::new(pattern)
924            .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
925        let expr = format!(
926            "(el) => getComputedStyle(el).getPropertyValue({})",
927            serde_json::to_string(name).unwrap()
928        );
929
930        loop {
931            let actual: String = self.locator.evaluate(&expr, None::<()>).await?;
932
933            let matched = re.is_match(&actual);
934            let matches = if self.negate { !matched } else { matched };
935
936            if matches {
937                return Ok(());
938            }
939
940            if start.elapsed() >= self.timeout {
941                let message = if self.negate {
942                    format!(
943                        "Expected element '{}' CSS '{}' NOT to match pattern '{}', but it did after {:?}",
944                        selector, name, pattern, self.timeout
945                    )
946                } else {
947                    format!(
948                        "Expected element '{}' CSS '{}' to match pattern '{}', but had '{}' after {:?}",
949                        selector, name, pattern, actual, self.timeout
950                    )
951                };
952                return Err(crate::error::Error::AssertionTimeout(message));
953            }
954
955            tokio::time::sleep(self.poll_interval).await;
956        }
957    }
958
959    /// Asserts that the locator resolves to exactly `count` matching elements.
960    ///
961    /// See: <https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-have-count>
962    pub async fn to_have_count(self, count: usize) -> Result<()> {
963        let start = std::time::Instant::now();
964        let selector = self.locator.selector().to_string();
965
966        loop {
967            let actual = self.locator.count().await?;
968
969            let matched = actual == count;
970            let matches = if self.negate { !matched } else { matched };
971
972            if matches {
973                return Ok(());
974            }
975
976            if start.elapsed() >= self.timeout {
977                let message = if self.negate {
978                    format!(
979                        "Expected locator '{}' NOT to have count {}, but it did after {:?}",
980                        selector, count, self.timeout
981                    )
982                } else {
983                    format!(
984                        "Expected locator '{}' to have count {}, but had {} after {:?}",
985                        selector, count, actual, self.timeout
986                    )
987                };
988                return Err(crate::error::Error::AssertionTimeout(message));
989            }
990
991            tokio::time::sleep(self.poll_interval).await;
992        }
993    }
994
995    /// Asserts that the accessible subtree rooted at the locator matches the expected ARIA snapshot.
996    ///
997    /// The `expected` string is a YAML representation of the accessibility tree.
998    /// The Playwright server handles auto-retrying within the assertion timeout.
999    ///
1000    /// # Example
1001    ///
1002    /// ```no_run
1003    /// # use playwright_rs::{Playwright, expect};
1004    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1005    /// # let pw = Playwright::launch().await?;
1006    /// # let browser = pw.chromium().launch().await?;
1007    /// # let page = browser.new_page().await?;
1008    /// expect(page.locator("body"))
1009    ///     .to_match_aria_snapshot("- heading \"Hello\" [level=1]\n- button \"Click me\"")
1010    ///     .await?;
1011    /// # Ok(())
1012    /// # }
1013    /// ```
1014    ///
1015    /// See: <https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-match-aria-snapshot>
1016    pub async fn to_match_aria_snapshot(self, expected: &str) -> Result<()> {
1017        use crate::protocol::serialize_argument;
1018
1019        let selector = self.locator.selector().to_string();
1020        let timeout_ms = self.timeout.as_millis() as f64;
1021        let expected_value = serialize_argument(&serde_json::Value::String(expected.to_string()));
1022
1023        self.locator
1024            .frame()
1025            .frame_expect(
1026                &selector,
1027                "to.match.aria",
1028                expected_value,
1029                self.negate,
1030                timeout_ms,
1031            )
1032            .await
1033    }
1034
1035    /// Asserts that a locator's screenshot matches a baseline image.
1036    ///
1037    /// On first run (no baseline file), saves the screenshot as the new baseline.
1038    /// On subsequent runs, compares the screenshot pixel-by-pixel against the baseline.
1039    ///
1040    /// **Available with the `screenshot-diff` feature** (default-on). Disable
1041    /// default features to drop the `image` crate and ~5 transitive deps if
1042    /// you don't use screenshot comparison.
1043    ///
1044    /// See: <https://playwright.dev/docs/test-assertions#locator-assertions-to-have-screenshot-1>
1045    #[cfg(feature = "screenshot-diff")]
1046    pub async fn to_have_screenshot(
1047        self,
1048        baseline_path: impl AsRef<Path>,
1049        options: Option<ScreenshotAssertionOptions>,
1050    ) -> Result<()> {
1051        let opts = options.unwrap_or_default();
1052        let baseline_path = baseline_path.as_ref();
1053
1054        // Disable animations if requested
1055        if opts.animations == Some(Animations::Disabled) {
1056            let _ = self
1057                .locator
1058                .evaluate_js(DISABLE_ANIMATIONS_JS, None::<&()>)
1059                .await;
1060        }
1061
1062        // Build screenshot options with mask support
1063        let screenshot_opts = if let Some(ref mask_locators) = opts.mask {
1064            // Inject mask overlays before capturing
1065            let mask_js = build_mask_js(mask_locators);
1066            let _ = self.locator.evaluate_js(&mask_js, None::<&()>).await;
1067            None
1068        } else {
1069            None
1070        };
1071
1072        compare_screenshot(
1073            &opts,
1074            baseline_path,
1075            self.timeout,
1076            self.poll_interval,
1077            self.negate,
1078            || async { self.locator.screenshot(screenshot_opts.clone()).await },
1079        )
1080        .await
1081    }
1082}
1083
1084/// CSS to disable all animations and transitions
1085#[cfg(feature = "screenshot-diff")]
1086const DISABLE_ANIMATIONS_JS: &str = r#"
1087(() => {
1088    const style = document.createElement('style');
1089    style.textContent = '*, *::before, *::after { animation-duration: 0s !important; animation-delay: 0s !important; transition-duration: 0s !important; transition-delay: 0s !important; }';
1090    style.setAttribute('data-playwright-no-animations', '');
1091    document.head.appendChild(style);
1092})()
1093"#;
1094
1095/// Build JavaScript to overlay mask regions with pink (#FF00FF) rectangles
1096#[cfg(feature = "screenshot-diff")]
1097fn build_mask_js(locators: &[Locator]) -> String {
1098    let selectors: Vec<String> = locators
1099        .iter()
1100        .map(|l| {
1101            let sel = l.selector().replace('\'', "\\'");
1102            format!(
1103                r#"
1104                (function() {{
1105                    var els = document.querySelectorAll('{}');
1106                    els.forEach(function(el) {{
1107                        var rect = el.getBoundingClientRect();
1108                        var overlay = document.createElement('div');
1109                        overlay.setAttribute('data-playwright-mask', '');
1110                        overlay.style.cssText = 'position:fixed;z-index:2147483647;background:#FF00FF;pointer-events:none;'
1111                            + 'left:' + rect.left + 'px;top:' + rect.top + 'px;width:' + rect.width + 'px;height:' + rect.height + 'px;';
1112                        document.body.appendChild(overlay);
1113                    }});
1114                }})();
1115                "#,
1116                sel
1117            )
1118        })
1119        .collect();
1120    selectors.join("\n")
1121}
1122
1123// `Animations` lives in the always-available screenshot module (shared with
1124// `ScreenshotOptions`); the screenshot-diff assertions reuse it.
1125#[cfg(feature = "screenshot-diff")]
1126use crate::protocol::Animations;
1127
1128/// Options for screenshot assertions
1129///
1130/// See: <https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-have-screenshot-1>
1131#[cfg(feature = "screenshot-diff")]
1132#[derive(Debug, Clone, Default)]
1133#[non_exhaustive]
1134pub struct ScreenshotAssertionOptions {
1135    /// Maximum number of different pixels allowed (default: 0)
1136    pub max_diff_pixels: Option<u32>,
1137    /// Maximum ratio of different pixels (0.0 to 1.0)
1138    pub max_diff_pixel_ratio: Option<f64>,
1139    /// Per-pixel color distance threshold (0.0 to 1.0, default: 0.2)
1140    pub threshold: Option<f64>,
1141    /// Disable CSS animations before capturing
1142    pub animations: Option<Animations>,
1143    /// Locators to mask with pink (#FF00FF) overlay
1144    pub mask: Option<Vec<Locator>>,
1145    /// Force update baseline even if it exists
1146    pub update_snapshots: Option<bool>,
1147}
1148
1149#[cfg(feature = "screenshot-diff")]
1150impl ScreenshotAssertionOptions {
1151    /// Create a new builder for ScreenshotAssertionOptions
1152    pub fn builder() -> ScreenshotAssertionOptionsBuilder {
1153        ScreenshotAssertionOptionsBuilder::default()
1154    }
1155}
1156
1157/// Builder for ScreenshotAssertionOptions
1158#[cfg(feature = "screenshot-diff")]
1159#[derive(Debug, Clone, Default)]
1160pub struct ScreenshotAssertionOptionsBuilder {
1161    max_diff_pixels: Option<u32>,
1162    max_diff_pixel_ratio: Option<f64>,
1163    threshold: Option<f64>,
1164    animations: Option<Animations>,
1165    mask: Option<Vec<Locator>>,
1166    update_snapshots: Option<bool>,
1167}
1168
1169#[cfg(feature = "screenshot-diff")]
1170impl ScreenshotAssertionOptionsBuilder {
1171    /// Maximum number of different pixels allowed
1172    pub fn max_diff_pixels(mut self, pixels: u32) -> Self {
1173        self.max_diff_pixels = Some(pixels);
1174        self
1175    }
1176
1177    /// Maximum ratio of different pixels (0.0 to 1.0)
1178    pub fn max_diff_pixel_ratio(mut self, ratio: f64) -> Self {
1179        self.max_diff_pixel_ratio = Some(ratio);
1180        self
1181    }
1182
1183    /// Per-pixel color distance threshold (0.0 to 1.0)
1184    pub fn threshold(mut self, threshold: f64) -> Self {
1185        self.threshold = Some(threshold);
1186        self
1187    }
1188
1189    /// Disable CSS animations and transitions before capturing
1190    pub fn animations(mut self, animations: Animations) -> Self {
1191        self.animations = Some(animations);
1192        self
1193    }
1194
1195    /// Locators to mask with pink (#FF00FF) overlay
1196    pub fn mask(mut self, locators: Vec<Locator>) -> Self {
1197        self.mask = Some(locators);
1198        self
1199    }
1200
1201    /// Force update baseline even if it exists
1202    pub fn update_snapshots(mut self, update: bool) -> Self {
1203        self.update_snapshots = Some(update);
1204        self
1205    }
1206
1207    /// Build the ScreenshotAssertionOptions
1208    pub fn build(self) -> ScreenshotAssertionOptions {
1209        ScreenshotAssertionOptions {
1210            max_diff_pixels: self.max_diff_pixels,
1211            max_diff_pixel_ratio: self.max_diff_pixel_ratio,
1212            threshold: self.threshold,
1213            animations: self.animations,
1214            mask: self.mask,
1215            update_snapshots: self.update_snapshots,
1216        }
1217    }
1218}
1219
1220/// Creates a page-level expectation for screenshot assertions.
1221///
1222/// See: <https://playwright.dev/docs/test-assertions#page-assertions-to-have-screenshot-1>
1223pub fn expect_page(page: &Page) -> PageExpectation {
1224    PageExpectation::new(page.clone())
1225}
1226
1227/// Page-level expectation for screenshot assertions.
1228#[allow(clippy::wrong_self_convention)]
1229pub struct PageExpectation {
1230    page: Page,
1231    timeout: Duration,
1232    poll_interval: Duration,
1233    negate: bool,
1234}
1235
1236impl PageExpectation {
1237    fn new(page: Page) -> Self {
1238        Self {
1239            page,
1240            timeout: DEFAULT_ASSERTION_TIMEOUT,
1241            poll_interval: DEFAULT_POLL_INTERVAL,
1242            negate: false,
1243        }
1244    }
1245
1246    /// Sets a custom timeout for this assertion.
1247    pub fn with_timeout(mut self, timeout: Duration) -> Self {
1248        self.timeout = timeout;
1249        self
1250    }
1251
1252    /// Negates the assertion.
1253    #[allow(clippy::should_implement_trait)]
1254    pub fn not(mut self) -> Self {
1255        self.negate = true;
1256        self
1257    }
1258
1259    /// Asserts that the page title matches the expected string.
1260    ///
1261    /// Auto-retries until the title matches or the timeout expires.
1262    ///
1263    /// See: <https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-title>
1264    pub async fn to_have_title(self, expected: &str) -> Result<()> {
1265        let start = std::time::Instant::now();
1266        let expected = expected.trim();
1267
1268        loop {
1269            let actual = self.page.title().await?;
1270            let actual = actual.trim();
1271
1272            let matches = if self.negate {
1273                actual != expected
1274            } else {
1275                actual == expected
1276            };
1277
1278            if matches {
1279                return Ok(());
1280            }
1281
1282            if start.elapsed() >= self.timeout {
1283                let message = if self.negate {
1284                    format!(
1285                        "Expected page NOT to have title '{}', but it did after {:?}",
1286                        expected, self.timeout,
1287                    )
1288                } else {
1289                    format!(
1290                        "Expected page to have title '{}', but got '{}' after {:?}",
1291                        expected, actual, self.timeout,
1292                    )
1293                };
1294                return Err(crate::error::Error::AssertionTimeout(message));
1295            }
1296
1297            tokio::time::sleep(self.poll_interval).await;
1298        }
1299    }
1300
1301    /// Asserts that the page title matches the given regex pattern.
1302    ///
1303    /// Auto-retries until the title matches or the timeout expires.
1304    ///
1305    /// See: <https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-title>
1306    pub async fn to_have_title_regex(self, pattern: &str) -> Result<()> {
1307        let start = std::time::Instant::now();
1308        let re = regex::Regex::new(pattern)
1309            .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
1310
1311        loop {
1312            let actual = self.page.title().await?;
1313
1314            let matches = if self.negate {
1315                !re.is_match(&actual)
1316            } else {
1317                re.is_match(&actual)
1318            };
1319
1320            if matches {
1321                return Ok(());
1322            }
1323
1324            if start.elapsed() >= self.timeout {
1325                let message = if self.negate {
1326                    format!(
1327                        "Expected page title NOT to match '{}', but '{}' matched after {:?}",
1328                        pattern, actual, self.timeout,
1329                    )
1330                } else {
1331                    format!(
1332                        "Expected page title to match '{}', but got '{}' after {:?}",
1333                        pattern, actual, self.timeout,
1334                    )
1335                };
1336                return Err(crate::error::Error::AssertionTimeout(message));
1337            }
1338
1339            tokio::time::sleep(self.poll_interval).await;
1340        }
1341    }
1342
1343    /// Asserts that the page's accessibility tree matches the expected ARIA snapshot.
1344    ///
1345    /// The page-level counterpart of the locator assertion
1346    /// `to_match_aria_snapshot` (from [`expect`]); it matches the whole document
1347    /// (rooted at `:root`). The `expected` string is a YAML representation of
1348    /// the accessibility tree, and the Playwright server auto-retries within the
1349    /// assertion timeout.
1350    ///
1351    /// # Example
1352    ///
1353    /// ```no_run
1354    /// # use playwright_rs::{Playwright, expect_page};
1355    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1356    /// # let pw = Playwright::launch().await?;
1357    /// # let browser = pw.chromium().launch().await?;
1358    /// # let page = browser.new_page().await?;
1359    /// expect_page(&page)
1360    ///     .to_match_aria_snapshot("- heading \"Welcome\" [level=1]")
1361    ///     .await?;
1362    /// # Ok(())
1363    /// # }
1364    /// ```
1365    ///
1366    /// See: <https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-match-aria-snapshot>
1367    pub async fn to_match_aria_snapshot(self, expected: &str) -> Result<()> {
1368        use crate::protocol::serialize_argument;
1369
1370        let timeout_ms = self.timeout.as_millis() as f64;
1371        let expected_value = serialize_argument(&serde_json::Value::String(expected.to_string()));
1372
1373        let frame = self.page.main_frame().await?;
1374        frame
1375            .frame_expect(
1376                ":root",
1377                "to.match.aria",
1378                expected_value,
1379                self.negate,
1380                timeout_ms,
1381            )
1382            .await
1383    }
1384
1385    /// Asserts that the page URL matches the expected string.
1386    ///
1387    /// Auto-retries until the URL matches or the timeout expires.
1388    ///
1389    /// See: <https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-url>
1390    pub async fn to_have_url(self, expected: &str) -> Result<()> {
1391        let start = std::time::Instant::now();
1392
1393        loop {
1394            let actual = self.page.url();
1395
1396            let matches = if self.negate {
1397                actual != expected
1398            } else {
1399                actual == expected
1400            };
1401
1402            if matches {
1403                return Ok(());
1404            }
1405
1406            if start.elapsed() >= self.timeout {
1407                let message = if self.negate {
1408                    format!(
1409                        "Expected page NOT to have URL '{}', but it did after {:?}",
1410                        expected, self.timeout,
1411                    )
1412                } else {
1413                    format!(
1414                        "Expected page to have URL '{}', but got '{}' after {:?}",
1415                        expected, actual, self.timeout,
1416                    )
1417                };
1418                return Err(crate::error::Error::AssertionTimeout(message));
1419            }
1420
1421            tokio::time::sleep(self.poll_interval).await;
1422        }
1423    }
1424
1425    /// Asserts that the page URL matches the given regex pattern.
1426    ///
1427    /// Auto-retries until the URL matches or the timeout expires.
1428    ///
1429    /// See: <https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-url>
1430    pub async fn to_have_url_regex(self, pattern: &str) -> Result<()> {
1431        let start = std::time::Instant::now();
1432        let re = regex::Regex::new(pattern)
1433            .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
1434
1435        loop {
1436            let actual = self.page.url();
1437
1438            let matches = if self.negate {
1439                !re.is_match(&actual)
1440            } else {
1441                re.is_match(&actual)
1442            };
1443
1444            if matches {
1445                return Ok(());
1446            }
1447
1448            if start.elapsed() >= self.timeout {
1449                let message = if self.negate {
1450                    format!(
1451                        "Expected page URL NOT to match '{}', but '{}' matched after {:?}",
1452                        pattern, actual, self.timeout,
1453                    )
1454                } else {
1455                    format!(
1456                        "Expected page URL to match '{}', but got '{}' after {:?}",
1457                        pattern, actual, self.timeout,
1458                    )
1459                };
1460                return Err(crate::error::Error::AssertionTimeout(message));
1461            }
1462
1463            tokio::time::sleep(self.poll_interval).await;
1464        }
1465    }
1466
1467    /// Asserts that the page screenshot matches a baseline image.
1468    ///
1469    /// **Available with the `screenshot-diff` feature** (default-on).
1470    ///
1471    /// See: <https://playwright.dev/docs/test-assertions#page-assertions-to-have-screenshot-1>
1472    #[cfg(feature = "screenshot-diff")]
1473    pub async fn to_have_screenshot(
1474        self,
1475        baseline_path: impl AsRef<Path>,
1476        options: Option<ScreenshotAssertionOptions>,
1477    ) -> Result<()> {
1478        let opts = options.unwrap_or_default();
1479        let baseline_path = baseline_path.as_ref();
1480
1481        // Disable animations if requested
1482        if opts.animations == Some(Animations::Disabled) {
1483            let _ = self.page.evaluate_expression(DISABLE_ANIMATIONS_JS).await;
1484        }
1485
1486        // Inject mask overlays if specified
1487        if let Some(ref mask_locators) = opts.mask {
1488            let mask_js = build_mask_js(mask_locators);
1489            let _ = self.page.evaluate_expression(&mask_js).await;
1490        }
1491
1492        compare_screenshot(
1493            &opts,
1494            baseline_path,
1495            self.timeout,
1496            self.poll_interval,
1497            self.negate,
1498            || async { self.page.screenshot(None).await },
1499        )
1500        .await
1501    }
1502}
1503
1504/// Core screenshot comparison logic shared by Locator and Page assertions.
1505#[cfg(feature = "screenshot-diff")]
1506async fn compare_screenshot<F, Fut>(
1507    opts: &ScreenshotAssertionOptions,
1508    baseline_path: &Path,
1509    timeout: Duration,
1510    poll_interval: Duration,
1511    negate: bool,
1512    take_screenshot: F,
1513) -> Result<()>
1514where
1515    F: Fn() -> Fut,
1516    Fut: std::future::Future<Output = Result<Vec<u8>>>,
1517{
1518    let threshold = opts.threshold.unwrap_or(0.2);
1519    let max_diff_pixels = opts.max_diff_pixels;
1520    let max_diff_pixel_ratio = opts.max_diff_pixel_ratio;
1521    let update_snapshots = opts.update_snapshots.unwrap_or(false);
1522
1523    // Take initial screenshot
1524    let actual_bytes = take_screenshot().await?;
1525
1526    // If baseline doesn't exist or update_snapshots is set, save and return
1527    if !baseline_path.exists() || update_snapshots {
1528        if let Some(parent) = baseline_path.parent() {
1529            tokio::fs::create_dir_all(parent).await.map_err(|e| {
1530                crate::error::Error::ProtocolError(format!(
1531                    "Failed to create baseline directory: {}",
1532                    e
1533                ))
1534            })?;
1535        }
1536        tokio::fs::write(baseline_path, &actual_bytes)
1537            .await
1538            .map_err(|e| {
1539                crate::error::Error::ProtocolError(format!(
1540                    "Failed to write baseline screenshot: {}",
1541                    e
1542                ))
1543            })?;
1544        return Ok(());
1545    }
1546
1547    // Load baseline
1548    let baseline_bytes = tokio::fs::read(baseline_path).await.map_err(|e| {
1549        crate::error::Error::ProtocolError(format!("Failed to read baseline screenshot: {}", e))
1550    })?;
1551
1552    let start = std::time::Instant::now();
1553
1554    loop {
1555        let screenshot_bytes = if start.elapsed().is_zero() {
1556            actual_bytes.clone()
1557        } else {
1558            take_screenshot().await?
1559        };
1560
1561        let comparison = compare_images(&baseline_bytes, &screenshot_bytes, threshold)?;
1562
1563        let within_tolerance =
1564            is_within_tolerance(&comparison, max_diff_pixels, max_diff_pixel_ratio);
1565
1566        let matches = if negate {
1567            !within_tolerance
1568        } else {
1569            within_tolerance
1570        };
1571
1572        if matches {
1573            return Ok(());
1574        }
1575
1576        if start.elapsed() >= timeout {
1577            if negate {
1578                return Err(crate::error::Error::AssertionTimeout(format!(
1579                    "Expected screenshots NOT to match, but they matched after {:?}",
1580                    timeout
1581                )));
1582            }
1583
1584            // Save actual and diff images for debugging
1585            let baseline_stem = baseline_path
1586                .file_stem()
1587                .and_then(|s| s.to_str())
1588                .unwrap_or("screenshot");
1589            let baseline_ext = baseline_path
1590                .extension()
1591                .and_then(|s| s.to_str())
1592                .unwrap_or("png");
1593            let baseline_dir = baseline_path.parent().unwrap_or(Path::new("."));
1594
1595            let actual_path =
1596                baseline_dir.join(format!("{}-actual.{}", baseline_stem, baseline_ext));
1597            let diff_path = baseline_dir.join(format!("{}-diff.{}", baseline_stem, baseline_ext));
1598
1599            let _ = tokio::fs::write(&actual_path, &screenshot_bytes).await;
1600
1601            if let Ok(diff_bytes) =
1602                generate_diff_image(&baseline_bytes, &screenshot_bytes, threshold)
1603            {
1604                let _ = tokio::fs::write(&diff_path, diff_bytes).await;
1605            }
1606
1607            return Err(crate::error::Error::AssertionTimeout(format!(
1608                "Screenshot mismatch: {} pixels differ ({:.2}% of total). \
1609                 Max allowed: {}. Threshold: {:.2}. \
1610                 Actual saved to: {}. Diff saved to: {}. \
1611                 Timed out after {:?}",
1612                comparison.diff_count,
1613                comparison.diff_ratio * 100.0,
1614                max_diff_pixels
1615                    .map(|p| p.to_string())
1616                    .or_else(|| max_diff_pixel_ratio.map(|r| format!("{:.2}%", r * 100.0)))
1617                    .unwrap_or_else(|| "0".to_string()),
1618                threshold,
1619                actual_path.display(),
1620                diff_path.display(),
1621                timeout,
1622            )));
1623        }
1624
1625        tokio::time::sleep(poll_interval).await;
1626    }
1627}
1628
1629/// Result of comparing two images pixel-by-pixel
1630#[cfg(feature = "screenshot-diff")]
1631struct ImageComparison {
1632    diff_count: u32,
1633    diff_ratio: f64,
1634}
1635
1636#[cfg(feature = "screenshot-diff")]
1637fn is_within_tolerance(
1638    comparison: &ImageComparison,
1639    max_diff_pixels: Option<u32>,
1640    max_diff_pixel_ratio: Option<f64>,
1641) -> bool {
1642    if let Some(max_pixels) = max_diff_pixels {
1643        if comparison.diff_count > max_pixels {
1644            return false;
1645        }
1646    } else if let Some(max_ratio) = max_diff_pixel_ratio {
1647        if comparison.diff_ratio > max_ratio {
1648            return false;
1649        }
1650    } else {
1651        // No tolerance specified — require exact match
1652        if comparison.diff_count > 0 {
1653            return false;
1654        }
1655    }
1656    true
1657}
1658
1659/// Compare two PNG images pixel-by-pixel with a color distance threshold
1660#[cfg(feature = "screenshot-diff")]
1661fn compare_images(
1662    baseline_bytes: &[u8],
1663    actual_bytes: &[u8],
1664    threshold: f64,
1665) -> Result<ImageComparison> {
1666    use image::GenericImageView;
1667
1668    let baseline_img = image::load_from_memory(baseline_bytes).map_err(|e| {
1669        crate::error::Error::ProtocolError(format!("Failed to decode baseline image: {}", e))
1670    })?;
1671    let actual_img = image::load_from_memory(actual_bytes).map_err(|e| {
1672        crate::error::Error::ProtocolError(format!("Failed to decode actual image: {}", e))
1673    })?;
1674
1675    let (bw, bh) = baseline_img.dimensions();
1676    let (aw, ah) = actual_img.dimensions();
1677
1678    // Different dimensions = all pixels differ
1679    if bw != aw || bh != ah {
1680        let total = bw.max(aw) * bh.max(ah);
1681        return Ok(ImageComparison {
1682            diff_count: total,
1683            diff_ratio: 1.0,
1684        });
1685    }
1686
1687    let total_pixels = bw * bh;
1688    if total_pixels == 0 {
1689        return Ok(ImageComparison {
1690            diff_count: 0,
1691            diff_ratio: 0.0,
1692        });
1693    }
1694
1695    let threshold_sq = threshold * threshold;
1696    let mut diff_count: u32 = 0;
1697
1698    for y in 0..bh {
1699        for x in 0..bw {
1700            let bp = baseline_img.get_pixel(x, y);
1701            let ap = actual_img.get_pixel(x, y);
1702
1703            // Compute normalized color distance (each channel 0.0-1.0)
1704            let dr = (bp[0] as f64 - ap[0] as f64) / 255.0;
1705            let dg = (bp[1] as f64 - ap[1] as f64) / 255.0;
1706            let db = (bp[2] as f64 - ap[2] as f64) / 255.0;
1707            let da = (bp[3] as f64 - ap[3] as f64) / 255.0;
1708
1709            let dist_sq = (dr * dr + dg * dg + db * db + da * da) / 4.0;
1710
1711            if dist_sq > threshold_sq {
1712                diff_count += 1;
1713            }
1714        }
1715    }
1716
1717    Ok(ImageComparison {
1718        diff_count,
1719        diff_ratio: diff_count as f64 / total_pixels as f64,
1720    })
1721}
1722
1723/// Generate a diff image highlighting differences in red
1724#[cfg(feature = "screenshot-diff")]
1725fn generate_diff_image(
1726    baseline_bytes: &[u8],
1727    actual_bytes: &[u8],
1728    threshold: f64,
1729) -> Result<Vec<u8>> {
1730    use image::{GenericImageView, ImageBuffer, Rgba};
1731
1732    let baseline_img = image::load_from_memory(baseline_bytes).map_err(|e| {
1733        crate::error::Error::ProtocolError(format!("Failed to decode baseline image: {}", e))
1734    })?;
1735    let actual_img = image::load_from_memory(actual_bytes).map_err(|e| {
1736        crate::error::Error::ProtocolError(format!("Failed to decode actual image: {}", e))
1737    })?;
1738
1739    let (bw, bh) = baseline_img.dimensions();
1740    let (aw, ah) = actual_img.dimensions();
1741    let width = bw.max(aw);
1742    let height = bh.max(ah);
1743
1744    let threshold_sq = threshold * threshold;
1745
1746    let mut diff_img: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::new(width, height);
1747
1748    for y in 0..height {
1749        for x in 0..width {
1750            if x >= bw || y >= bh || x >= aw || y >= ah {
1751                // Out of bounds for one image — mark as diff
1752                diff_img.put_pixel(x, y, Rgba([255, 0, 0, 255]));
1753                continue;
1754            }
1755
1756            let bp = baseline_img.get_pixel(x, y);
1757            let ap = actual_img.get_pixel(x, y);
1758
1759            let dr = (bp[0] as f64 - ap[0] as f64) / 255.0;
1760            let dg = (bp[1] as f64 - ap[1] as f64) / 255.0;
1761            let db = (bp[2] as f64 - ap[2] as f64) / 255.0;
1762            let da = (bp[3] as f64 - ap[3] as f64) / 255.0;
1763
1764            let dist_sq = (dr * dr + dg * dg + db * db + da * da) / 4.0;
1765
1766            if dist_sq > threshold_sq {
1767                // Different — red highlight
1768                diff_img.put_pixel(x, y, Rgba([255, 0, 0, 255]));
1769            } else {
1770                // Same — semi-transparent grayscale of actual
1771                let gray = ((ap[0] as u16 + ap[1] as u16 + ap[2] as u16) / 3) as u8;
1772                diff_img.put_pixel(x, y, Rgba([gray, gray, gray, 100]));
1773            }
1774        }
1775    }
1776
1777    let mut output = std::io::Cursor::new(Vec::new());
1778    diff_img
1779        .write_to(&mut output, image::ImageFormat::Png)
1780        .map_err(|e| {
1781            crate::error::Error::ProtocolError(format!("Failed to encode diff image: {}", e))
1782        })?;
1783
1784    Ok(output.into_inner())
1785}
1786
1787#[cfg(test)]
1788mod tests {
1789    use super::*;
1790
1791    #[test]
1792    fn test_expectation_defaults() {
1793        // Verify default timeout and poll interval constants
1794        assert_eq!(DEFAULT_ASSERTION_TIMEOUT, Duration::from_secs(5));
1795        assert_eq!(DEFAULT_POLL_INTERVAL, Duration::from_millis(100));
1796    }
1797
1798    #[test]
1799    fn test_normalize_whitespace_collapses_runs_and_trims() {
1800        assert_eq!(
1801            normalize_whitespace("Scan\n→\nGroup\n→\nName"),
1802            "Scan → Group → Name"
1803        );
1804        assert_eq!(normalize_whitespace("  Hello \t  world \n"), "Hello world");
1805        assert_eq!(normalize_whitespace("already normal"), "already normal");
1806        assert_eq!(normalize_whitespace("   "), "");
1807        assert_eq!(normalize_whitespace(""), "");
1808    }
1809}