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