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}