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