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;
9use std::time::Duration;
10
11/// Default timeout for assertions (5 seconds, matching Playwright)
12const DEFAULT_ASSERTION_TIMEOUT: Duration = Duration::from_secs(5);
13
14/// Default polling interval for assertions (100ms)
15const DEFAULT_POLL_INTERVAL: Duration = Duration::from_millis(100);
16
17/// Creates an expectation for a locator with auto-retry behavior.
18///
19/// Assertions will retry until they pass or timeout (default: 5 seconds).
20///
21/// # Example
22///
23/// ```ignore
24/// use playwright_rs::{expect, protocol::Playwright};
25/// use std::time::Duration;
26///
27/// #[tokio::main]
28/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
29///     let playwright = Playwright::launch().await?;
30///     let browser = playwright.chromium().launch().await?;
31///     let page = browser.new_page().await?;
32///
33///     // Test to_be_visible and to_be_hidden
34///     page.goto("data:text/html,<button id='btn'>Click me</button><div id='hidden' style='display:none'>Hidden</div>", None).await?;
35///     expect(page.locator("#btn").await).to_be_visible().await?;
36///     expect(page.locator("#hidden").await).to_be_hidden().await?;
37///
38///     // Test not() negation
39///     expect(page.locator("#btn").await).not().to_be_hidden().await?;
40///     expect(page.locator("#hidden").await).not().to_be_visible().await?;
41///
42///     // Test with_timeout()
43///     page.goto("data:text/html,<div id='element'>Visible</div>", None).await?;
44///     expect(page.locator("#element").await)
45///         .with_timeout(Duration::from_secs(10))
46///         .to_be_visible()
47///         .await?;
48///
49///     // Test to_be_enabled and to_be_disabled
50///     page.goto("data:text/html,<button id='enabled'>Enabled</button><button id='disabled' disabled>Disabled</button>", None).await?;
51///     expect(page.locator("#enabled").await).to_be_enabled().await?;
52///     expect(page.locator("#disabled").await).to_be_disabled().await?;
53///
54///     // Test to_be_checked and to_be_unchecked
55///     page.goto("data:text/html,<input type='checkbox' id='checked' checked><input type='checkbox' id='unchecked'>", None).await?;
56///     expect(page.locator("#checked").await).to_be_checked().await?;
57///     expect(page.locator("#unchecked").await).to_be_unchecked().await?;
58///
59///     // Test to_be_editable
60///     page.goto("data:text/html,<input type='text' id='editable'>", None).await?;
61///     expect(page.locator("#editable").await).to_be_editable().await?;
62///
63///     // Test to_be_focused
64///     page.goto("data:text/html,<input type='text' id='input'>", None).await?;
65///     page.evaluate("document.getElementById('input').focus()").await?;
66///     expect(page.locator("#input").await).to_be_focused().await?;
67///
68///     // Test to_contain_text
69///     page.goto("data:text/html,<div id='content'>Hello World</div>", None).await?;
70///     expect(page.locator("#content").await).to_contain_text("Hello").await?;
71///     expect(page.locator("#content").await).to_contain_text("World").await?;
72///
73///     // Test to_have_text
74///     expect(page.locator("#content").await).to_have_text("Hello World").await?;
75///
76///     // Test to_have_value
77///     page.goto("data:text/html,<input type='text' id='input' value='test value'>", None).await?;
78///     expect(page.locator("#input").await).to_have_value("test value").await?;
79///
80///     browser.close().await?;
81///     Ok(())
82/// }
83/// ```
84///
85/// See: <https://playwright.dev/docs/test-assertions>
86pub fn expect(locator: Locator) -> Expectation {
87    Expectation::new(locator)
88}
89
90/// Expectation wraps a locator and provides assertion methods with auto-retry.
91pub struct Expectation {
92    locator: Locator,
93    timeout: Duration,
94    poll_interval: Duration,
95    negate: bool,
96}
97
98// Allow clippy::wrong_self_convention for to_* methods that consume self
99// This matches Playwright's expect API pattern where assertions are chained and consumed
100#[allow(clippy::wrong_self_convention)]
101impl Expectation {
102    /// Creates a new expectation for the given locator.
103    pub(crate) fn new(locator: Locator) -> Self {
104        Self {
105            locator,
106            timeout: DEFAULT_ASSERTION_TIMEOUT,
107            poll_interval: DEFAULT_POLL_INTERVAL,
108            negate: false,
109        }
110    }
111
112    /// Sets a custom timeout for this assertion.
113    ///
114    pub fn with_timeout(mut self, timeout: Duration) -> Self {
115        self.timeout = timeout;
116        self
117    }
118
119    /// Sets a custom poll interval for this assertion.
120    ///
121    /// Default is 100ms.
122    pub fn with_poll_interval(mut self, interval: Duration) -> Self {
123        self.poll_interval = interval;
124        self
125    }
126
127    /// Negates the assertion.
128    ///
129    /// Note: We intentionally use `.not()` method instead of implementing `std::ops::Not`
130    /// to match Playwright's API across all language bindings (JS/Python/Java/.NET).
131    #[allow(clippy::should_implement_trait)]
132    pub fn not(mut self) -> Self {
133        self.negate = true;
134        self
135    }
136
137    /// Asserts that the element is visible.
138    ///
139    /// This assertion will retry until the element becomes visible or timeout.
140    ///
141    /// See: <https://playwright.dev/docs/test-assertions#locator-assertions-to-be-visible>
142    pub async fn to_be_visible(self) -> Result<()> {
143        let start = std::time::Instant::now();
144        let selector = self.locator.selector().to_string();
145
146        loop {
147            let is_visible = self.locator.is_visible().await?;
148
149            // Check if condition matches (with negation support)
150            let matches = if self.negate { !is_visible } else { is_visible };
151
152            if matches {
153                return Ok(());
154            }
155
156            // Check timeout
157            if start.elapsed() >= self.timeout {
158                let message = if self.negate {
159                    format!(
160                        "Expected element '{}' NOT to be visible, but it was visible after {:?}",
161                        selector, self.timeout
162                    )
163                } else {
164                    format!(
165                        "Expected element '{}' to be visible, but it was not visible after {:?}",
166                        selector, self.timeout
167                    )
168                };
169                return Err(crate::error::Error::AssertionTimeout(message));
170            }
171
172            // Wait before next poll
173            tokio::time::sleep(self.poll_interval).await;
174        }
175    }
176
177    /// Asserts that the element is hidden (not visible).
178    ///
179    /// This assertion will retry until the element becomes hidden or timeout.
180    ///
181    /// See: <https://playwright.dev/docs/test-assertions#locator-assertions-to-be-hidden>
182    pub async fn to_be_hidden(self) -> Result<()> {
183        // to_be_hidden is the opposite of to_be_visible
184        // Use negation to reuse the visibility logic
185        let negated = Expectation {
186            negate: !self.negate, // Flip negation
187            ..self
188        };
189        negated.to_be_visible().await
190    }
191
192    /// Asserts that the element has the specified text content (exact match).
193    ///
194    /// This assertion will retry until the element has the exact text or timeout.
195    /// Text is trimmed before comparison.
196    ///
197    /// See: <https://playwright.dev/docs/test-assertions#locator-assertions-to-have-text>
198    pub async fn to_have_text(self, expected: &str) -> Result<()> {
199        let start = std::time::Instant::now();
200        let selector = self.locator.selector().to_string();
201        let expected = expected.trim();
202
203        loop {
204            // Get text content (using inner_text for consistency with Playwright)
205            let actual_text = self.locator.inner_text().await?;
206            let actual = actual_text.trim();
207
208            // Check if condition matches (with negation support)
209            let matches = if self.negate {
210                actual != expected
211            } else {
212                actual == expected
213            };
214
215            if matches {
216                return Ok(());
217            }
218
219            // Check timeout
220            if start.elapsed() >= self.timeout {
221                let message = if self.negate {
222                    format!(
223                        "Expected element '{}' NOT to have text '{}', but it did after {:?}",
224                        selector, expected, self.timeout
225                    )
226                } else {
227                    format!(
228                        "Expected element '{}' to have text '{}', but had '{}' after {:?}",
229                        selector, expected, actual, self.timeout
230                    )
231                };
232                return Err(crate::error::Error::AssertionTimeout(message));
233            }
234
235            // Wait before next poll
236            tokio::time::sleep(self.poll_interval).await;
237        }
238    }
239
240    /// Asserts that the element's text matches the specified regex pattern.
241    ///
242    /// This assertion will retry until the element's text matches the pattern or timeout.
243    pub async fn to_have_text_regex(self, pattern: &str) -> Result<()> {
244        let start = std::time::Instant::now();
245        let selector = self.locator.selector().to_string();
246        let re = regex::Regex::new(pattern)
247            .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
248
249        loop {
250            let actual_text = self.locator.inner_text().await?;
251            let actual = actual_text.trim();
252
253            // Check if condition matches (with negation support)
254            let matches = if self.negate {
255                !re.is_match(actual)
256            } else {
257                re.is_match(actual)
258            };
259
260            if matches {
261                return Ok(());
262            }
263
264            // Check timeout
265            if start.elapsed() >= self.timeout {
266                let message = if self.negate {
267                    format!(
268                        "Expected element '{}' NOT to match pattern '{}', but it did after {:?}",
269                        selector, pattern, self.timeout
270                    )
271                } else {
272                    format!(
273                        "Expected element '{}' to match pattern '{}', but had '{}' after {:?}",
274                        selector, pattern, actual, self.timeout
275                    )
276                };
277                return Err(crate::error::Error::AssertionTimeout(message));
278            }
279
280            // Wait before next poll
281            tokio::time::sleep(self.poll_interval).await;
282        }
283    }
284
285    /// Asserts that the element contains the specified text (substring match).
286    ///
287    /// This assertion will retry until the element contains the text or timeout.
288    ///
289    /// See: <https://playwright.dev/docs/test-assertions#locator-assertions-to-contain-text>
290    pub async fn to_contain_text(self, expected: &str) -> Result<()> {
291        let start = std::time::Instant::now();
292        let selector = self.locator.selector().to_string();
293
294        loop {
295            let actual_text = self.locator.inner_text().await?;
296            let actual = actual_text.trim();
297
298            // Check if condition matches (with negation support)
299            let matches = if self.negate {
300                !actual.contains(expected)
301            } else {
302                actual.contains(expected)
303            };
304
305            if matches {
306                return Ok(());
307            }
308
309            // Check timeout
310            if start.elapsed() >= self.timeout {
311                let message = if self.negate {
312                    format!(
313                        "Expected element '{}' NOT to contain text '{}', but it did after {:?}",
314                        selector, expected, self.timeout
315                    )
316                } else {
317                    format!(
318                        "Expected element '{}' to contain text '{}', but had '{}' after {:?}",
319                        selector, expected, actual, self.timeout
320                    )
321                };
322                return Err(crate::error::Error::AssertionTimeout(message));
323            }
324
325            // Wait before next poll
326            tokio::time::sleep(self.poll_interval).await;
327        }
328    }
329
330    /// Asserts that the element's text contains a substring matching the regex pattern.
331    ///
332    /// This assertion will retry until the element contains the pattern or timeout.
333    pub async fn to_contain_text_regex(self, pattern: &str) -> Result<()> {
334        let start = std::time::Instant::now();
335        let selector = self.locator.selector().to_string();
336        let re = regex::Regex::new(pattern)
337            .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
338
339        loop {
340            let actual_text = self.locator.inner_text().await?;
341            let actual = actual_text.trim();
342
343            // Check if condition matches (with negation support)
344            let matches = if self.negate {
345                !re.is_match(actual)
346            } else {
347                re.is_match(actual)
348            };
349
350            if matches {
351                return Ok(());
352            }
353
354            // Check timeout
355            if start.elapsed() >= self.timeout {
356                let message = if self.negate {
357                    format!(
358                        "Expected element '{}' NOT to contain pattern '{}', but it did after {:?}",
359                        selector, pattern, self.timeout
360                    )
361                } else {
362                    format!(
363                        "Expected element '{}' to contain pattern '{}', but had '{}' after {:?}",
364                        selector, pattern, actual, self.timeout
365                    )
366                };
367                return Err(crate::error::Error::AssertionTimeout(message));
368            }
369
370            // Wait before next poll
371            tokio::time::sleep(self.poll_interval).await;
372        }
373    }
374
375    /// Asserts that the input element has the specified value.
376    ///
377    /// This assertion will retry until the input has the exact value or timeout.
378    ///
379    /// See: <https://playwright.dev/docs/test-assertions#locator-assertions-to-have-value>
380    pub async fn to_have_value(self, expected: &str) -> Result<()> {
381        let start = std::time::Instant::now();
382        let selector = self.locator.selector().to_string();
383
384        loop {
385            let actual = self.locator.input_value(None).await?;
386
387            // Check if condition matches (with negation support)
388            let matches = if self.negate {
389                actual != expected
390            } else {
391                actual == expected
392            };
393
394            if matches {
395                return Ok(());
396            }
397
398            // Check timeout
399            if start.elapsed() >= self.timeout {
400                let message = if self.negate {
401                    format!(
402                        "Expected input '{}' NOT to have value '{}', but it did after {:?}",
403                        selector, expected, self.timeout
404                    )
405                } else {
406                    format!(
407                        "Expected input '{}' to have value '{}', but had '{}' after {:?}",
408                        selector, expected, actual, self.timeout
409                    )
410                };
411                return Err(crate::error::Error::AssertionTimeout(message));
412            }
413
414            // Wait before next poll
415            tokio::time::sleep(self.poll_interval).await;
416        }
417    }
418
419    /// Asserts that the input element's value matches the specified regex pattern.
420    ///
421    /// This assertion will retry until the input value matches the pattern or timeout.
422    pub async fn to_have_value_regex(self, pattern: &str) -> Result<()> {
423        let start = std::time::Instant::now();
424        let selector = self.locator.selector().to_string();
425        let re = regex::Regex::new(pattern)
426            .map_err(|e| crate::error::Error::InvalidArgument(format!("Invalid regex: {}", e)))?;
427
428        loop {
429            let actual = self.locator.input_value(None).await?;
430
431            // Check if condition matches (with negation support)
432            let matches = if self.negate {
433                !re.is_match(&actual)
434            } else {
435                re.is_match(&actual)
436            };
437
438            if matches {
439                return Ok(());
440            }
441
442            // Check timeout
443            if start.elapsed() >= self.timeout {
444                let message = if self.negate {
445                    format!(
446                        "Expected input '{}' NOT to match pattern '{}', but it did after {:?}",
447                        selector, pattern, self.timeout
448                    )
449                } else {
450                    format!(
451                        "Expected input '{}' to match pattern '{}', but had '{}' after {:?}",
452                        selector, pattern, actual, self.timeout
453                    )
454                };
455                return Err(crate::error::Error::AssertionTimeout(message));
456            }
457
458            // Wait before next poll
459            tokio::time::sleep(self.poll_interval).await;
460        }
461    }
462
463    /// Asserts that the element is enabled.
464    ///
465    /// This assertion will retry until the element is enabled or timeout.
466    /// An element is enabled if it does not have the "disabled" attribute.
467    ///
468    /// See: <https://playwright.dev/docs/test-assertions#locator-assertions-to-be-enabled>
469    pub async fn to_be_enabled(self) -> Result<()> {
470        let start = std::time::Instant::now();
471        let selector = self.locator.selector().to_string();
472
473        loop {
474            let is_enabled = self.locator.is_enabled().await?;
475
476            // Check if condition matches (with negation support)
477            let matches = if self.negate { !is_enabled } else { is_enabled };
478
479            if matches {
480                return Ok(());
481            }
482
483            // Check timeout
484            if start.elapsed() >= self.timeout {
485                let message = if self.negate {
486                    format!(
487                        "Expected element '{}' NOT to be enabled, but it was enabled after {:?}",
488                        selector, self.timeout
489                    )
490                } else {
491                    format!(
492                        "Expected element '{}' to be enabled, but it was not enabled after {:?}",
493                        selector, self.timeout
494                    )
495                };
496                return Err(crate::error::Error::AssertionTimeout(message));
497            }
498
499            // Wait before next poll
500            tokio::time::sleep(self.poll_interval).await;
501        }
502    }
503
504    /// Asserts that the element is disabled.
505    ///
506    /// This assertion will retry until the element is disabled or timeout.
507    /// An element is disabled if it has the "disabled" attribute.
508    ///
509    /// See: <https://playwright.dev/docs/test-assertions#locator-assertions-to-be-disabled>
510    pub async fn to_be_disabled(self) -> Result<()> {
511        // to_be_disabled is the opposite of to_be_enabled
512        // Use negation to reuse the enabled logic
513        let negated = Expectation {
514            negate: !self.negate, // Flip negation
515            ..self
516        };
517        negated.to_be_enabled().await
518    }
519
520    /// Asserts that the checkbox or radio button is checked.
521    ///
522    /// This assertion will retry until the element is checked or timeout.
523    ///
524    /// See: <https://playwright.dev/docs/test-assertions#locator-assertions-to-be-checked>
525    pub async fn to_be_checked(self) -> Result<()> {
526        let start = std::time::Instant::now();
527        let selector = self.locator.selector().to_string();
528
529        loop {
530            let is_checked = self.locator.is_checked().await?;
531
532            // Check if condition matches (with negation support)
533            let matches = if self.negate { !is_checked } else { is_checked };
534
535            if matches {
536                return Ok(());
537            }
538
539            // Check timeout
540            if start.elapsed() >= self.timeout {
541                let message = if self.negate {
542                    format!(
543                        "Expected element '{}' NOT to be checked, but it was checked after {:?}",
544                        selector, self.timeout
545                    )
546                } else {
547                    format!(
548                        "Expected element '{}' to be checked, but it was not checked after {:?}",
549                        selector, self.timeout
550                    )
551                };
552                return Err(crate::error::Error::AssertionTimeout(message));
553            }
554
555            // Wait before next poll
556            tokio::time::sleep(self.poll_interval).await;
557        }
558    }
559
560    /// Asserts that the checkbox or radio button is unchecked.
561    ///
562    /// This assertion will retry until the element is unchecked or timeout.
563    ///
564    /// See: <https://playwright.dev/docs/test-assertions#locator-assertions-to-be-checked>
565    pub async fn to_be_unchecked(self) -> Result<()> {
566        // to_be_unchecked is the opposite of to_be_checked
567        // Use negation to reuse the checked logic
568        let negated = Expectation {
569            negate: !self.negate, // Flip negation
570            ..self
571        };
572        negated.to_be_checked().await
573    }
574
575    /// Asserts that the element is editable.
576    ///
577    /// This assertion will retry until the element is editable or timeout.
578    /// An element is editable if it is enabled and does not have the "readonly" attribute.
579    ///
580    /// See: <https://playwright.dev/docs/test-assertions#locator-assertions-to-be-editable>
581    pub async fn to_be_editable(self) -> Result<()> {
582        let start = std::time::Instant::now();
583        let selector = self.locator.selector().to_string();
584
585        loop {
586            let is_editable = self.locator.is_editable().await?;
587
588            // Check if condition matches (with negation support)
589            let matches = if self.negate {
590                !is_editable
591            } else {
592                is_editable
593            };
594
595            if matches {
596                return Ok(());
597            }
598
599            // Check timeout
600            if start.elapsed() >= self.timeout {
601                let message = if self.negate {
602                    format!(
603                        "Expected element '{}' NOT to be editable, but it was editable after {:?}",
604                        selector, self.timeout
605                    )
606                } else {
607                    format!(
608                        "Expected element '{}' to be editable, but it was not editable after {:?}",
609                        selector, self.timeout
610                    )
611                };
612                return Err(crate::error::Error::AssertionTimeout(message));
613            }
614
615            // Wait before next poll
616            tokio::time::sleep(self.poll_interval).await;
617        }
618    }
619
620    /// Asserts that the element is focused (currently has focus).
621    ///
622    /// This assertion will retry until the element becomes focused or timeout.
623    ///
624    /// See: <https://playwright.dev/docs/test-assertions#locator-assertions-to-be-focused>
625    pub async fn to_be_focused(self) -> Result<()> {
626        let start = std::time::Instant::now();
627        let selector = self.locator.selector().to_string();
628
629        loop {
630            let is_focused = self.locator.is_focused().await?;
631
632            // Check if condition matches (with negation support)
633            let matches = if self.negate { !is_focused } else { is_focused };
634
635            if matches {
636                return Ok(());
637            }
638
639            // Check timeout
640            if start.elapsed() >= self.timeout {
641                let message = if self.negate {
642                    format!(
643                        "Expected element '{}' NOT to be focused, but it was focused after {:?}",
644                        selector, self.timeout
645                    )
646                } else {
647                    format!(
648                        "Expected element '{}' to be focused, but it was not focused after {:?}",
649                        selector, self.timeout
650                    )
651                };
652                return Err(crate::error::Error::AssertionTimeout(message));
653            }
654
655            // Wait before next poll
656            tokio::time::sleep(self.poll_interval).await;
657        }
658    }
659}
660
661#[cfg(test)]
662mod tests {
663    use super::*;
664
665    #[test]
666    fn test_expectation_defaults() {
667        // Verify default timeout and poll interval constants
668        assert_eq!(DEFAULT_ASSERTION_TIMEOUT, Duration::from_secs(5));
669        assert_eq!(DEFAULT_POLL_INTERVAL, Duration::from_millis(100));
670    }
671}