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/// ```ignore
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        let start = std::time::Instant::now();
844        let selector = self.locator.selector().to_string();
845        let expr = format!(
846            "(el) => getComputedStyle(el).getPropertyValue({})",
847            serde_json::to_string(name).unwrap()
848        );
849
850        loop {
851            let actual: String = self.locator.evaluate(&expr, None::<()>).await?;
852
853            let matched = actual == value;
854            let matches = if self.negate { !matched } else { matched };
855
856            if matches {
857                return Ok(());
858            }
859
860            if start.elapsed() >= self.timeout {
861                let message = if self.negate {
862                    format!(
863                        "Expected element '{}' NOT to have CSS '{}'='{}', but it did after {:?}",
864                        selector, name, value, self.timeout
865                    )
866                } else {
867                    format!(
868                        "Expected element '{}' to have CSS '{}'='{}', but had '{}' after {:?}",
869                        selector, name, value, actual, self.timeout
870                    )
871                };
872                return Err(crate::error::Error::AssertionTimeout(message));
873            }
874
875            tokio::time::sleep(self.poll_interval).await;
876        }
877    }
878
879    /// Asserts that the element's computed CSS property matches the specified regex pattern.
880    pub async fn to_have_css_regex(self, name: &str, pattern: &str) -> Result<()> {
881        let start = std::time::Instant::now();
882        let selector = self.locator.selector().to_string();
883        let re = regex::Regex::new(pattern)
884            .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
885        let expr = format!(
886            "(el) => getComputedStyle(el).getPropertyValue({})",
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 = re.is_match(&actual);
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 '{}' CSS '{}' NOT to match pattern '{}', but it did after {:?}",
904                        selector, name, pattern, self.timeout
905                    )
906                } else {
907                    format!(
908                        "Expected element '{}' CSS '{}' to match pattern '{}', but had '{}' after {:?}",
909                        selector, name, pattern, 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 locator resolves to exactly `count` matching elements.
920    ///
921    /// See: <https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-have-count>
922    pub async fn to_have_count(self, count: usize) -> Result<()> {
923        let start = std::time::Instant::now();
924        let selector = self.locator.selector().to_string();
925
926        loop {
927            let actual = self.locator.count().await?;
928
929            let matched = actual == count;
930            let matches = if self.negate { !matched } else { matched };
931
932            if matches {
933                return Ok(());
934            }
935
936            if start.elapsed() >= self.timeout {
937                let message = if self.negate {
938                    format!(
939                        "Expected locator '{}' NOT to have count {}, but it did after {:?}",
940                        selector, count, self.timeout
941                    )
942                } else {
943                    format!(
944                        "Expected locator '{}' to have count {}, but had {} after {:?}",
945                        selector, count, actual, self.timeout
946                    )
947                };
948                return Err(crate::error::Error::AssertionTimeout(message));
949            }
950
951            tokio::time::sleep(self.poll_interval).await;
952        }
953    }
954
955    /// Asserts that the accessible subtree rooted at the locator matches the expected ARIA snapshot.
956    ///
957    /// The `expected` string is a YAML representation of the accessibility tree.
958    /// The Playwright server handles auto-retrying within the assertion timeout.
959    ///
960    /// # Example (in module-level doctest)
961    ///
962    /// ```ignore
963    /// expect(page.locator("body").await)
964    ///     .to_match_aria_snapshot("- heading \"Hello\" [level=1]\n- button \"Click me\"")
965    ///     .await?;
966    /// ```
967    ///
968    /// See: <https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-match-aria-snapshot>
969    pub async fn to_match_aria_snapshot(self, expected: &str) -> Result<()> {
970        use crate::protocol::serialize_argument;
971
972        let selector = self.locator.selector().to_string();
973        let timeout_ms = self.timeout.as_millis() as f64;
974        let expected_value = serialize_argument(&serde_json::Value::String(expected.to_string()));
975
976        self.locator
977            .frame()
978            .frame_expect(
979                &selector,
980                "to.match.aria",
981                expected_value,
982                self.negate,
983                timeout_ms,
984            )
985            .await
986    }
987
988    /// Asserts that a locator's screenshot matches a baseline image.
989    ///
990    /// On first run (no baseline file), saves the screenshot as the new baseline.
991    /// On subsequent runs, compares the screenshot pixel-by-pixel against the baseline.
992    ///
993    /// **Available with the `screenshot-diff` feature** (default-on). Disable
994    /// default features to drop the `image` crate and ~5 transitive deps if
995    /// you don't use screenshot comparison.
996    ///
997    /// See: <https://playwright.dev/docs/test-assertions#locator-assertions-to-have-screenshot-1>
998    #[cfg(feature = "screenshot-diff")]
999    pub async fn to_have_screenshot(
1000        self,
1001        baseline_path: impl AsRef<Path>,
1002        options: Option<ScreenshotAssertionOptions>,
1003    ) -> Result<()> {
1004        let opts = options.unwrap_or_default();
1005        let baseline_path = baseline_path.as_ref();
1006
1007        // Disable animations if requested
1008        if opts.animations == Some(Animations::Disabled) {
1009            let _ = self
1010                .locator
1011                .evaluate_js(DISABLE_ANIMATIONS_JS, None::<&()>)
1012                .await;
1013        }
1014
1015        // Build screenshot options with mask support
1016        let screenshot_opts = if let Some(ref mask_locators) = opts.mask {
1017            // Inject mask overlays before capturing
1018            let mask_js = build_mask_js(mask_locators);
1019            let _ = self.locator.evaluate_js(&mask_js, None::<&()>).await;
1020            None
1021        } else {
1022            None
1023        };
1024
1025        compare_screenshot(
1026            &opts,
1027            baseline_path,
1028            self.timeout,
1029            self.poll_interval,
1030            self.negate,
1031            || async { self.locator.screenshot(screenshot_opts.clone()).await },
1032        )
1033        .await
1034    }
1035}
1036
1037/// CSS to disable all animations and transitions
1038#[cfg(feature = "screenshot-diff")]
1039const DISABLE_ANIMATIONS_JS: &str = r#"
1040(() => {
1041    const style = document.createElement('style');
1042    style.textContent = '*, *::before, *::after { animation-duration: 0s !important; animation-delay: 0s !important; transition-duration: 0s !important; transition-delay: 0s !important; }';
1043    style.setAttribute('data-playwright-no-animations', '');
1044    document.head.appendChild(style);
1045})()
1046"#;
1047
1048/// Build JavaScript to overlay mask regions with pink (#FF00FF) rectangles
1049#[cfg(feature = "screenshot-diff")]
1050fn build_mask_js(locators: &[Locator]) -> String {
1051    let selectors: Vec<String> = locators
1052        .iter()
1053        .map(|l| {
1054            let sel = l.selector().replace('\'', "\\'");
1055            format!(
1056                r#"
1057                (function() {{
1058                    var els = document.querySelectorAll('{}');
1059                    els.forEach(function(el) {{
1060                        var rect = el.getBoundingClientRect();
1061                        var overlay = document.createElement('div');
1062                        overlay.setAttribute('data-playwright-mask', '');
1063                        overlay.style.cssText = 'position:fixed;z-index:2147483647;background:#FF00FF;pointer-events:none;'
1064                            + 'left:' + rect.left + 'px;top:' + rect.top + 'px;width:' + rect.width + 'px;height:' + rect.height + 'px;';
1065                        document.body.appendChild(overlay);
1066                    }});
1067                }})();
1068                "#,
1069                sel
1070            )
1071        })
1072        .collect();
1073    selectors.join("\n")
1074}
1075
1076/// Animation control for screenshots
1077///
1078/// See: <https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-have-screenshot-1>
1079#[cfg(feature = "screenshot-diff")]
1080#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1081pub enum Animations {
1082    /// Allow animations to run normally
1083    Allow,
1084    /// Disable CSS animations and transitions before capturing
1085    Disabled,
1086}
1087
1088/// Options for screenshot assertions
1089///
1090/// See: <https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-have-screenshot-1>
1091#[cfg(feature = "screenshot-diff")]
1092#[derive(Debug, Clone, Default)]
1093pub struct ScreenshotAssertionOptions {
1094    /// Maximum number of different pixels allowed (default: 0)
1095    pub max_diff_pixels: Option<u32>,
1096    /// Maximum ratio of different pixels (0.0 to 1.0)
1097    pub max_diff_pixel_ratio: Option<f64>,
1098    /// Per-pixel color distance threshold (0.0 to 1.0, default: 0.2)
1099    pub threshold: Option<f64>,
1100    /// Disable CSS animations before capturing
1101    pub animations: Option<Animations>,
1102    /// Locators to mask with pink (#FF00FF) overlay
1103    pub mask: Option<Vec<Locator>>,
1104    /// Force update baseline even if it exists
1105    pub update_snapshots: Option<bool>,
1106}
1107
1108#[cfg(feature = "screenshot-diff")]
1109impl ScreenshotAssertionOptions {
1110    /// Create a new builder for ScreenshotAssertionOptions
1111    pub fn builder() -> ScreenshotAssertionOptionsBuilder {
1112        ScreenshotAssertionOptionsBuilder::default()
1113    }
1114}
1115
1116/// Builder for ScreenshotAssertionOptions
1117#[cfg(feature = "screenshot-diff")]
1118#[derive(Debug, Clone, Default)]
1119pub struct ScreenshotAssertionOptionsBuilder {
1120    max_diff_pixels: Option<u32>,
1121    max_diff_pixel_ratio: Option<f64>,
1122    threshold: Option<f64>,
1123    animations: Option<Animations>,
1124    mask: Option<Vec<Locator>>,
1125    update_snapshots: Option<bool>,
1126}
1127
1128#[cfg(feature = "screenshot-diff")]
1129impl ScreenshotAssertionOptionsBuilder {
1130    /// Maximum number of different pixels allowed
1131    pub fn max_diff_pixels(mut self, pixels: u32) -> Self {
1132        self.max_diff_pixels = Some(pixels);
1133        self
1134    }
1135
1136    /// Maximum ratio of different pixels (0.0 to 1.0)
1137    pub fn max_diff_pixel_ratio(mut self, ratio: f64) -> Self {
1138        self.max_diff_pixel_ratio = Some(ratio);
1139        self
1140    }
1141
1142    /// Per-pixel color distance threshold (0.0 to 1.0)
1143    pub fn threshold(mut self, threshold: f64) -> Self {
1144        self.threshold = Some(threshold);
1145        self
1146    }
1147
1148    /// Disable CSS animations and transitions before capturing
1149    pub fn animations(mut self, animations: Animations) -> Self {
1150        self.animations = Some(animations);
1151        self
1152    }
1153
1154    /// Locators to mask with pink (#FF00FF) overlay
1155    pub fn mask(mut self, locators: Vec<Locator>) -> Self {
1156        self.mask = Some(locators);
1157        self
1158    }
1159
1160    /// Force update baseline even if it exists
1161    pub fn update_snapshots(mut self, update: bool) -> Self {
1162        self.update_snapshots = Some(update);
1163        self
1164    }
1165
1166    /// Build the ScreenshotAssertionOptions
1167    pub fn build(self) -> ScreenshotAssertionOptions {
1168        ScreenshotAssertionOptions {
1169            max_diff_pixels: self.max_diff_pixels,
1170            max_diff_pixel_ratio: self.max_diff_pixel_ratio,
1171            threshold: self.threshold,
1172            animations: self.animations,
1173            mask: self.mask,
1174            update_snapshots: self.update_snapshots,
1175        }
1176    }
1177}
1178
1179/// Creates a page-level expectation for screenshot assertions.
1180///
1181/// See: <https://playwright.dev/docs/test-assertions#page-assertions-to-have-screenshot-1>
1182pub fn expect_page(page: &Page) -> PageExpectation {
1183    PageExpectation::new(page.clone())
1184}
1185
1186/// Page-level expectation for screenshot assertions.
1187#[allow(clippy::wrong_self_convention)]
1188pub struct PageExpectation {
1189    page: Page,
1190    timeout: Duration,
1191    poll_interval: Duration,
1192    negate: bool,
1193}
1194
1195impl PageExpectation {
1196    fn new(page: Page) -> Self {
1197        Self {
1198            page,
1199            timeout: DEFAULT_ASSERTION_TIMEOUT,
1200            poll_interval: DEFAULT_POLL_INTERVAL,
1201            negate: false,
1202        }
1203    }
1204
1205    /// Sets a custom timeout for this assertion.
1206    pub fn with_timeout(mut self, timeout: Duration) -> Self {
1207        self.timeout = timeout;
1208        self
1209    }
1210
1211    /// Negates the assertion.
1212    #[allow(clippy::should_implement_trait)]
1213    pub fn not(mut self) -> Self {
1214        self.negate = true;
1215        self
1216    }
1217
1218    /// Asserts that the page title matches the expected string.
1219    ///
1220    /// Auto-retries until the title matches or the timeout expires.
1221    ///
1222    /// See: <https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-title>
1223    pub async fn to_have_title(self, expected: &str) -> Result<()> {
1224        let start = std::time::Instant::now();
1225        let expected = expected.trim();
1226
1227        loop {
1228            let actual = self.page.title().await?;
1229            let actual = actual.trim();
1230
1231            let matches = if self.negate {
1232                actual != expected
1233            } else {
1234                actual == expected
1235            };
1236
1237            if matches {
1238                return Ok(());
1239            }
1240
1241            if start.elapsed() >= self.timeout {
1242                let message = if self.negate {
1243                    format!(
1244                        "Expected page NOT to have title '{}', but it did after {:?}",
1245                        expected, self.timeout,
1246                    )
1247                } else {
1248                    format!(
1249                        "Expected page to have title '{}', but got '{}' after {:?}",
1250                        expected, actual, self.timeout,
1251                    )
1252                };
1253                return Err(crate::error::Error::AssertionTimeout(message));
1254            }
1255
1256            tokio::time::sleep(self.poll_interval).await;
1257        }
1258    }
1259
1260    /// Asserts that the page title matches the given regex pattern.
1261    ///
1262    /// Auto-retries until the title matches or the timeout expires.
1263    ///
1264    /// See: <https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-title>
1265    pub async fn to_have_title_regex(self, pattern: &str) -> Result<()> {
1266        let start = std::time::Instant::now();
1267        let re = regex::Regex::new(pattern)
1268            .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
1269
1270        loop {
1271            let actual = self.page.title().await?;
1272
1273            let matches = if self.negate {
1274                !re.is_match(&actual)
1275            } else {
1276                re.is_match(&actual)
1277            };
1278
1279            if matches {
1280                return Ok(());
1281            }
1282
1283            if start.elapsed() >= self.timeout {
1284                let message = if self.negate {
1285                    format!(
1286                        "Expected page title NOT to match '{}', but '{}' matched after {:?}",
1287                        pattern, actual, self.timeout,
1288                    )
1289                } else {
1290                    format!(
1291                        "Expected page title to match '{}', but got '{}' after {:?}",
1292                        pattern, actual, self.timeout,
1293                    )
1294                };
1295                return Err(crate::error::Error::AssertionTimeout(message));
1296            }
1297
1298            tokio::time::sleep(self.poll_interval).await;
1299        }
1300    }
1301
1302    /// Asserts that the page URL matches the expected string.
1303    ///
1304    /// Auto-retries until the URL matches or the timeout expires.
1305    ///
1306    /// See: <https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-url>
1307    pub async fn to_have_url(self, expected: &str) -> Result<()> {
1308        let start = std::time::Instant::now();
1309
1310        loop {
1311            let actual = self.page.url();
1312
1313            let matches = if self.negate {
1314                actual != expected
1315            } else {
1316                actual == expected
1317            };
1318
1319            if matches {
1320                return Ok(());
1321            }
1322
1323            if start.elapsed() >= self.timeout {
1324                let message = if self.negate {
1325                    format!(
1326                        "Expected page NOT to have URL '{}', but it did after {:?}",
1327                        expected, self.timeout,
1328                    )
1329                } else {
1330                    format!(
1331                        "Expected page to have URL '{}', but got '{}' after {:?}",
1332                        expected, actual, self.timeout,
1333                    )
1334                };
1335                return Err(crate::error::Error::AssertionTimeout(message));
1336            }
1337
1338            tokio::time::sleep(self.poll_interval).await;
1339        }
1340    }
1341
1342    /// Asserts that the page URL matches the given regex pattern.
1343    ///
1344    /// Auto-retries until the URL matches or the timeout expires.
1345    ///
1346    /// See: <https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-url>
1347    pub async fn to_have_url_regex(self, pattern: &str) -> Result<()> {
1348        let start = std::time::Instant::now();
1349        let re = regex::Regex::new(pattern)
1350            .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
1351
1352        loop {
1353            let actual = self.page.url();
1354
1355            let matches = if self.negate {
1356                !re.is_match(&actual)
1357            } else {
1358                re.is_match(&actual)
1359            };
1360
1361            if matches {
1362                return Ok(());
1363            }
1364
1365            if start.elapsed() >= self.timeout {
1366                let message = if self.negate {
1367                    format!(
1368                        "Expected page URL NOT to match '{}', but '{}' matched after {:?}",
1369                        pattern, actual, self.timeout,
1370                    )
1371                } else {
1372                    format!(
1373                        "Expected page URL to match '{}', but got '{}' after {:?}",
1374                        pattern, actual, self.timeout,
1375                    )
1376                };
1377                return Err(crate::error::Error::AssertionTimeout(message));
1378            }
1379
1380            tokio::time::sleep(self.poll_interval).await;
1381        }
1382    }
1383
1384    /// Asserts that the page screenshot matches a baseline image.
1385    ///
1386    /// **Available with the `screenshot-diff` feature** (default-on).
1387    ///
1388    /// See: <https://playwright.dev/docs/test-assertions#page-assertions-to-have-screenshot-1>
1389    #[cfg(feature = "screenshot-diff")]
1390    pub async fn to_have_screenshot(
1391        self,
1392        baseline_path: impl AsRef<Path>,
1393        options: Option<ScreenshotAssertionOptions>,
1394    ) -> Result<()> {
1395        let opts = options.unwrap_or_default();
1396        let baseline_path = baseline_path.as_ref();
1397
1398        // Disable animations if requested
1399        if opts.animations == Some(Animations::Disabled) {
1400            let _ = self.page.evaluate_expression(DISABLE_ANIMATIONS_JS).await;
1401        }
1402
1403        // Inject mask overlays if specified
1404        if let Some(ref mask_locators) = opts.mask {
1405            let mask_js = build_mask_js(mask_locators);
1406            let _ = self.page.evaluate_expression(&mask_js).await;
1407        }
1408
1409        compare_screenshot(
1410            &opts,
1411            baseline_path,
1412            self.timeout,
1413            self.poll_interval,
1414            self.negate,
1415            || async { self.page.screenshot(None).await },
1416        )
1417        .await
1418    }
1419}
1420
1421/// Core screenshot comparison logic shared by Locator and Page assertions.
1422#[cfg(feature = "screenshot-diff")]
1423async fn compare_screenshot<F, Fut>(
1424    opts: &ScreenshotAssertionOptions,
1425    baseline_path: &Path,
1426    timeout: Duration,
1427    poll_interval: Duration,
1428    negate: bool,
1429    take_screenshot: F,
1430) -> Result<()>
1431where
1432    F: Fn() -> Fut,
1433    Fut: std::future::Future<Output = Result<Vec<u8>>>,
1434{
1435    let threshold = opts.threshold.unwrap_or(0.2);
1436    let max_diff_pixels = opts.max_diff_pixels;
1437    let max_diff_pixel_ratio = opts.max_diff_pixel_ratio;
1438    let update_snapshots = opts.update_snapshots.unwrap_or(false);
1439
1440    // Take initial screenshot
1441    let actual_bytes = take_screenshot().await?;
1442
1443    // If baseline doesn't exist or update_snapshots is set, save and return
1444    if !baseline_path.exists() || update_snapshots {
1445        if let Some(parent) = baseline_path.parent() {
1446            tokio::fs::create_dir_all(parent).await.map_err(|e| {
1447                crate::error::Error::ProtocolError(format!(
1448                    "Failed to create baseline directory: {}",
1449                    e
1450                ))
1451            })?;
1452        }
1453        tokio::fs::write(baseline_path, &actual_bytes)
1454            .await
1455            .map_err(|e| {
1456                crate::error::Error::ProtocolError(format!(
1457                    "Failed to write baseline screenshot: {}",
1458                    e
1459                ))
1460            })?;
1461        return Ok(());
1462    }
1463
1464    // Load baseline
1465    let baseline_bytes = tokio::fs::read(baseline_path).await.map_err(|e| {
1466        crate::error::Error::ProtocolError(format!("Failed to read baseline screenshot: {}", e))
1467    })?;
1468
1469    let start = std::time::Instant::now();
1470
1471    loop {
1472        let screenshot_bytes = if start.elapsed().is_zero() {
1473            actual_bytes.clone()
1474        } else {
1475            take_screenshot().await?
1476        };
1477
1478        let comparison = compare_images(&baseline_bytes, &screenshot_bytes, threshold)?;
1479
1480        let within_tolerance =
1481            is_within_tolerance(&comparison, max_diff_pixels, max_diff_pixel_ratio);
1482
1483        let matches = if negate {
1484            !within_tolerance
1485        } else {
1486            within_tolerance
1487        };
1488
1489        if matches {
1490            return Ok(());
1491        }
1492
1493        if start.elapsed() >= timeout {
1494            if negate {
1495                return Err(crate::error::Error::AssertionTimeout(format!(
1496                    "Expected screenshots NOT to match, but they matched after {:?}",
1497                    timeout
1498                )));
1499            }
1500
1501            // Save actual and diff images for debugging
1502            let baseline_stem = baseline_path
1503                .file_stem()
1504                .and_then(|s| s.to_str())
1505                .unwrap_or("screenshot");
1506            let baseline_ext = baseline_path
1507                .extension()
1508                .and_then(|s| s.to_str())
1509                .unwrap_or("png");
1510            let baseline_dir = baseline_path.parent().unwrap_or(Path::new("."));
1511
1512            let actual_path =
1513                baseline_dir.join(format!("{}-actual.{}", baseline_stem, baseline_ext));
1514            let diff_path = baseline_dir.join(format!("{}-diff.{}", baseline_stem, baseline_ext));
1515
1516            let _ = tokio::fs::write(&actual_path, &screenshot_bytes).await;
1517
1518            if let Ok(diff_bytes) =
1519                generate_diff_image(&baseline_bytes, &screenshot_bytes, threshold)
1520            {
1521                let _ = tokio::fs::write(&diff_path, diff_bytes).await;
1522            }
1523
1524            return Err(crate::error::Error::AssertionTimeout(format!(
1525                "Screenshot mismatch: {} pixels differ ({:.2}% of total). \
1526                 Max allowed: {}. Threshold: {:.2}. \
1527                 Actual saved to: {}. Diff saved to: {}. \
1528                 Timed out after {:?}",
1529                comparison.diff_count,
1530                comparison.diff_ratio * 100.0,
1531                max_diff_pixels
1532                    .map(|p| p.to_string())
1533                    .or_else(|| max_diff_pixel_ratio.map(|r| format!("{:.2}%", r * 100.0)))
1534                    .unwrap_or_else(|| "0".to_string()),
1535                threshold,
1536                actual_path.display(),
1537                diff_path.display(),
1538                timeout,
1539            )));
1540        }
1541
1542        tokio::time::sleep(poll_interval).await;
1543    }
1544}
1545
1546/// Result of comparing two images pixel-by-pixel
1547#[cfg(feature = "screenshot-diff")]
1548struct ImageComparison {
1549    diff_count: u32,
1550    diff_ratio: f64,
1551}
1552
1553#[cfg(feature = "screenshot-diff")]
1554fn is_within_tolerance(
1555    comparison: &ImageComparison,
1556    max_diff_pixels: Option<u32>,
1557    max_diff_pixel_ratio: Option<f64>,
1558) -> bool {
1559    if let Some(max_pixels) = max_diff_pixels {
1560        if comparison.diff_count > max_pixels {
1561            return false;
1562        }
1563    } else if let Some(max_ratio) = max_diff_pixel_ratio {
1564        if comparison.diff_ratio > max_ratio {
1565            return false;
1566        }
1567    } else {
1568        // No tolerance specified — require exact match
1569        if comparison.diff_count > 0 {
1570            return false;
1571        }
1572    }
1573    true
1574}
1575
1576/// Compare two PNG images pixel-by-pixel with a color distance threshold
1577#[cfg(feature = "screenshot-diff")]
1578fn compare_images(
1579    baseline_bytes: &[u8],
1580    actual_bytes: &[u8],
1581    threshold: f64,
1582) -> Result<ImageComparison> {
1583    use image::GenericImageView;
1584
1585    let baseline_img = image::load_from_memory(baseline_bytes).map_err(|e| {
1586        crate::error::Error::ProtocolError(format!("Failed to decode baseline image: {}", e))
1587    })?;
1588    let actual_img = image::load_from_memory(actual_bytes).map_err(|e| {
1589        crate::error::Error::ProtocolError(format!("Failed to decode actual image: {}", e))
1590    })?;
1591
1592    let (bw, bh) = baseline_img.dimensions();
1593    let (aw, ah) = actual_img.dimensions();
1594
1595    // Different dimensions = all pixels differ
1596    if bw != aw || bh != ah {
1597        let total = bw.max(aw) * bh.max(ah);
1598        return Ok(ImageComparison {
1599            diff_count: total,
1600            diff_ratio: 1.0,
1601        });
1602    }
1603
1604    let total_pixels = bw * bh;
1605    if total_pixels == 0 {
1606        return Ok(ImageComparison {
1607            diff_count: 0,
1608            diff_ratio: 0.0,
1609        });
1610    }
1611
1612    let threshold_sq = threshold * threshold;
1613    let mut diff_count: u32 = 0;
1614
1615    for y in 0..bh {
1616        for x in 0..bw {
1617            let bp = baseline_img.get_pixel(x, y);
1618            let ap = actual_img.get_pixel(x, y);
1619
1620            // Compute normalized color distance (each channel 0.0-1.0)
1621            let dr = (bp[0] as f64 - ap[0] as f64) / 255.0;
1622            let dg = (bp[1] as f64 - ap[1] as f64) / 255.0;
1623            let db = (bp[2] as f64 - ap[2] as f64) / 255.0;
1624            let da = (bp[3] as f64 - ap[3] as f64) / 255.0;
1625
1626            let dist_sq = (dr * dr + dg * dg + db * db + da * da) / 4.0;
1627
1628            if dist_sq > threshold_sq {
1629                diff_count += 1;
1630            }
1631        }
1632    }
1633
1634    Ok(ImageComparison {
1635        diff_count,
1636        diff_ratio: diff_count as f64 / total_pixels as f64,
1637    })
1638}
1639
1640/// Generate a diff image highlighting differences in red
1641#[cfg(feature = "screenshot-diff")]
1642fn generate_diff_image(
1643    baseline_bytes: &[u8],
1644    actual_bytes: &[u8],
1645    threshold: f64,
1646) -> Result<Vec<u8>> {
1647    use image::{GenericImageView, ImageBuffer, Rgba};
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    let width = bw.max(aw);
1659    let height = bh.max(ah);
1660
1661    let threshold_sq = threshold * threshold;
1662
1663    let mut diff_img: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::new(width, height);
1664
1665    for y in 0..height {
1666        for x in 0..width {
1667            if x >= bw || y >= bh || x >= aw || y >= ah {
1668                // Out of bounds for one image — mark as diff
1669                diff_img.put_pixel(x, y, Rgba([255, 0, 0, 255]));
1670                continue;
1671            }
1672
1673            let bp = baseline_img.get_pixel(x, y);
1674            let ap = actual_img.get_pixel(x, y);
1675
1676            let dr = (bp[0] as f64 - ap[0] as f64) / 255.0;
1677            let dg = (bp[1] as f64 - ap[1] as f64) / 255.0;
1678            let db = (bp[2] as f64 - ap[2] as f64) / 255.0;
1679            let da = (bp[3] as f64 - ap[3] as f64) / 255.0;
1680
1681            let dist_sq = (dr * dr + dg * dg + db * db + da * da) / 4.0;
1682
1683            if dist_sq > threshold_sq {
1684                // Different — red highlight
1685                diff_img.put_pixel(x, y, Rgba([255, 0, 0, 255]));
1686            } else {
1687                // Same — semi-transparent grayscale of actual
1688                let gray = ((ap[0] as u16 + ap[1] as u16 + ap[2] as u16) / 3) as u8;
1689                diff_img.put_pixel(x, y, Rgba([gray, gray, gray, 100]));
1690            }
1691        }
1692    }
1693
1694    let mut output = std::io::Cursor::new(Vec::new());
1695    diff_img
1696        .write_to(&mut output, image::ImageFormat::Png)
1697        .map_err(|e| {
1698            crate::error::Error::ProtocolError(format!("Failed to encode diff image: {}", e))
1699        })?;
1700
1701    Ok(output.into_inner())
1702}
1703
1704#[cfg(test)]
1705mod tests {
1706    use super::*;
1707
1708    #[test]
1709    fn test_expectation_defaults() {
1710        // Verify default timeout and poll interval constants
1711        assert_eq!(DEFAULT_ASSERTION_TIMEOUT, Duration::from_secs(5));
1712        assert_eq!(DEFAULT_POLL_INTERVAL, Duration::from_millis(100));
1713    }
1714}