Skip to main content

playwright_cdp/
assertions.rs

1//! Playwright-style `expect(locator)` / `expect(page)` assertions.
2//!
3//! Each assertion polls its underlying [`Locator`] / [`Page`] method every
4//! ~100ms until the condition holds, or the assertion timeout (default 5s)
5//! elapses, in which case [`Error::Timeout`] is returned with a message naming
6//! the assertion and the actual-vs-expected values.
7//!
8//! Negation via [`LocatorAssertions::not`] / [`PageAssertions::not`] flips the
9//! sense: a negated assertion succeeds when the base condition is **false**
10//! within the timeout.
11
12use std::future::Future;
13use std::time::Duration;
14
15use crate::error::{Error, Result};
16use crate::locator::Locator;
17use crate::page::Page;
18
19/// Default assertion timeout (mirrors Playwright's `expect` default).
20const DEFAULT_TIMEOUT: Duration = Duration::from_millis(5000);
21/// Polling interval between condition checks.
22const POLL_INTERVAL: Duration = Duration::from_millis(100);
23
24// --- top-level constructors ---
25
26/// Construct [`LocatorAssertions`] for `locator`, mirroring Playwright's
27/// `expect(locator)`.
28pub fn expect(locator: Locator) -> LocatorAssertions {
29    LocatorAssertions::new(locator)
30}
31
32/// Construct [`PageAssertions`] for `page`. Mirrors Playwright's
33/// `expect(page)`. (Distinct name from [`expect`] because Rust cannot overload
34/// `expect` on the parameter type without a trait.)
35pub fn expect_page(page: Page) -> PageAssertions {
36    PageAssertions::new(page)
37}
38
39/// Poll `cond` every [`POLL_INTERVAL`] until it returns `Ok(want)`, where
40/// `want = !negated`. Returns `Ok(())` on success, or `Err(Error::Timeout(msg))`
41/// on timeout. The `msg` closure produces the failure message lazily.
42///
43/// `cond` errors are propagated immediately (they are not assertion failures).
44async fn wait_for<F, Fut, Msg>(cond: F, timeout: Duration, negated: bool, msg: Msg) -> Result<()>
45where
46    F: Fn() -> Fut,
47    Fut: Future<Output = Result<bool>>,
48    Msg: FnOnce() -> String,
49{
50    let want = !negated;
51    let deadline = tokio::time::Instant::now() + timeout;
52    loop {
53        let got = cond().await?;
54        if got == want {
55            return Ok(());
56        }
57        if tokio::time::Instant::now() >= deadline {
58            return Err(Error::Timeout(msg()));
59        }
60        tokio::time::sleep(POLL_INTERVAL).await;
61    }
62}
63
64// ===========================================================================
65// LocatorAssertions
66// ===========================================================================
67
68/// Assertions over a [`Locator`], produced by [`expect`].
69#[derive(Clone)]
70pub struct LocatorAssertions {
71    locator: Locator,
72    timeout: Duration,
73    negated: bool,
74}
75
76impl LocatorAssertions {
77    /// Wrap a locator with the default 5s timeout, non-negated.
78    pub fn new(locator: Locator) -> Self {
79        Self {
80            locator,
81            timeout: DEFAULT_TIMEOUT,
82            negated: false,
83        }
84    }
85
86    /// Builder: override the assertion timeout.
87    pub fn with_timeout(mut self, timeout: Duration) -> Self {
88        self.timeout = timeout;
89        self
90    }
91
92    /// Override the assertion timeout in place.
93    pub fn set_timeout(&mut self, timeout: Duration) -> &mut Self {
94        self.timeout = timeout;
95        self
96    }
97
98    /// Return a negated clone: succeeding when the base condition is false.
99    pub fn not(&self) -> Self {
100        let mut c = self.clone();
101        c.negated = true;
102        c
103    }
104
105    fn sel(&self) -> &str {
106        self.locator.selector()
107    }
108
109    // --- visibility / state ---
110
111    pub async fn to_be_visible(&self) -> Result<()> {
112        let sel = self.sel().to_string();
113        let l = self.locator.clone();
114        let negated = self.negated;
115        let timeout = self.timeout;
116        wait_for(
117            move || {
118                let l = l.clone();
119                async move { l.is_visible().await }
120            },
121            timeout,
122            negated,
123            || format!("to_be_visible('{sel}') timed out after {}ms", timeout.as_millis()),
124        )
125        .await
126    }
127
128    pub async fn to_be_hidden(&self) -> Result<()> {
129        let sel = self.sel().to_string();
130        let l = self.locator.clone();
131        let negated = self.negated;
132        let timeout = self.timeout;
133        wait_for(
134            move || {
135                let l = l.clone();
136                async move { l.is_hidden().await }
137            },
138            timeout,
139            negated,
140            || format!("to_be_hidden('{sel}') timed out after {}ms", timeout.as_millis()),
141        )
142        .await
143    }
144
145    pub async fn to_be_enabled(&self) -> Result<()> {
146        let sel = self.sel().to_string();
147        let l = self.locator.clone();
148        let negated = self.negated;
149        let timeout = self.timeout;
150        wait_for(
151            move || {
152                let l = l.clone();
153                async move { l.is_enabled().await }
154            },
155            timeout,
156            negated,
157            || format!("to_be_enabled('{sel}') timed out after {}ms", timeout.as_millis()),
158        )
159        .await
160    }
161
162    pub async fn to_be_disabled(&self) -> Result<()> {
163        let sel = self.sel().to_string();
164        let l = self.locator.clone();
165        let negated = self.negated;
166        let timeout = self.timeout;
167        wait_for(
168            move || {
169                let l = l.clone();
170                async move { l.is_disabled().await }
171            },
172            timeout,
173            negated,
174            || format!("to_be_disabled('{sel}') timed out after {}ms", timeout.as_millis()),
175        )
176        .await
177    }
178
179    pub async fn to_be_editable(&self) -> Result<()> {
180        let sel = self.sel().to_string();
181        let l = self.locator.clone();
182        let negated = self.negated;
183        let timeout = self.timeout;
184        wait_for(
185            move || {
186                let l = l.clone();
187                async move { l.is_editable().await }
188            },
189            timeout,
190            negated,
191            || format!("to_be_editable('{sel}') timed out after {}ms", timeout.as_millis()),
192        )
193        .await
194    }
195
196    pub async fn to_be_checked(&self) -> Result<()> {
197        let sel = self.sel().to_string();
198        let l = self.locator.clone();
199        let negated = self.negated;
200        let timeout = self.timeout;
201        wait_for(
202            move || {
203                let l = l.clone();
204                async move { l.is_checked().await }
205            },
206            timeout,
207            negated,
208            || format!("to_be_checked('{sel}') timed out after {}ms", timeout.as_millis()),
209        )
210        .await
211    }
212
213    pub async fn to_be_unchecked(&self) -> Result<()> {
214        let sel = self.sel().to_string();
215        let l = self.locator.clone();
216        let negated = self.negated;
217        let timeout = self.timeout;
218        wait_for(
219            move || {
220                let l = l.clone();
221                async move {
222                    // unchecked == !checked
223                    Ok(!l.is_checked().await?)
224                }
225            },
226            timeout,
227            negated,
228            || format!("to_be_unchecked('{sel}') timed out after {}ms", timeout.as_millis()),
229        )
230        .await
231    }
232
233    // --- text ---
234
235    /// Assert the element's trimmed `textContent` equals `expected`.
236    pub async fn to_have_text(&self, expected: &str) -> Result<()> {
237        let sel = self.sel().to_string();
238        let l = self.locator.clone();
239        let negated = self.negated;
240        let timeout = self.timeout;
241        let expected_owned = expected.to_string();
242        let expected_for_msg = expected_owned.clone();
243        wait_for(
244            move || {
245                let l = l.clone();
246                let expected_owned = expected_owned.clone();
247                async move {
248                    let actual = l.text_content().await?;
249                    let actual = actual.unwrap_or_default();
250                    Ok(actual.trim() == expected_owned.trim())
251                }
252            },
253            timeout,
254            negated,
255            move || {
256                format!(
257                    "to_have_text('{sel}', '{expected_for_msg}') timed out after {}ms",
258                    timeout.as_millis()
259                )
260            },
261        )
262        .await
263    }
264
265    /// Assert the element's trimmed `textContent` contains `expected`.
266    pub async fn to_contain_text(&self, expected: &str) -> Result<()> {
267        let sel = self.sel().to_string();
268        let l = self.locator.clone();
269        let negated = self.negated;
270        let timeout = self.timeout;
271        let expected_owned = expected.to_string();
272        let expected_for_msg = expected_owned.clone();
273        wait_for(
274            move || {
275                let l = l.clone();
276                let expected_owned = expected_owned.clone();
277                async move {
278                    let actual = l.text_content().await?;
279                    Ok(actual
280                        .unwrap_or_default()
281                        .trim()
282                        .contains(expected_owned.trim()))
283                }
284            },
285            timeout,
286            negated,
287            move || {
288                format!(
289                    "to_contain_text('{sel}', '{expected_for_msg}') timed out after {}ms",
290                    timeout.as_millis()
291                )
292            },
293        )
294        .await
295    }
296
297    /// Assert the element's `innerText` equals `expected`.
298    pub async fn to_have_inner_text(&self, expected: &str) -> Result<()> {
299        let sel = self.sel().to_string();
300        let l = self.locator.clone();
301        let negated = self.negated;
302        let timeout = self.timeout;
303        let expected_owned = expected.to_string();
304        let expected_for_msg = expected_owned.clone();
305        wait_for(
306            move || {
307                let l = l.clone();
308                let expected_owned = expected_owned.clone();
309                async move {
310                    let actual = l.inner_text().await?;
311                    Ok(actual == expected_owned)
312                }
313            },
314            timeout,
315            negated,
316            move || {
317                format!(
318                    "to_have_inner_text('{sel}', '{expected_for_msg}') timed out after {}ms",
319                    timeout.as_millis()
320                )
321            },
322        )
323        .await
324    }
325
326    // --- count ---
327
328    /// Assert the number of matching elements equals `expected`.
329    pub async fn to_have_count(&self, expected: usize) -> Result<()> {
330        let sel = self.sel().to_string();
331        let l = self.locator.clone();
332        let negated = self.negated;
333        let timeout = self.timeout;
334        wait_for(
335            move || {
336                let l = l.clone();
337                async move { Ok(l.count().await? == expected) }
338            },
339            timeout,
340            negated,
341            move || {
342                format!(
343                    "to_have_count('{sel}', {expected}) timed out after {}ms",
344                    timeout.as_millis()
345                )
346            },
347        )
348        .await
349    }
350
351    // --- attributes / value ---
352
353    /// Assert the element has attribute `name` equal to `value`.
354    pub async fn to_have_attribute(&self, name: &str, value: &str) -> Result<()> {
355        let sel = self.sel().to_string();
356        let l = self.locator.clone();
357        let negated = self.negated;
358        let timeout = self.timeout;
359        let name_owned = name.to_string();
360        let value_owned = value.to_string();
361        let name_for_msg = name.to_string();
362        let value_for_msg = value.to_string();
363        wait_for(
364            move || {
365                let l = l.clone();
366                let name_owned = name_owned.clone();
367                let value_owned = value_owned.clone();
368                async move {
369                    let attr = l.get_attribute(&name_owned).await?;
370                    Ok(attr.as_deref() == Some(value_owned.as_str()))
371                }
372            },
373            timeout,
374            negated,
375            move || {
376                format!(
377                    "to_have_attribute('{sel}', '{name_for_msg}', '{value_for_msg}') timed out after {}ms",
378                    timeout.as_millis()
379                )
380            },
381        )
382        .await
383    }
384
385    /// Assert the element's input value equals `expected`.
386    pub async fn to_have_value(&self, expected: &str) -> Result<()> {
387        let sel = self.sel().to_string();
388        let l = self.locator.clone();
389        let negated = self.negated;
390        let timeout = self.timeout;
391        let expected_owned = expected.to_string();
392        let expected_for_msg = expected.to_string();
393        wait_for(
394            move || {
395                let l = l.clone();
396                let expected_owned = expected_owned.clone();
397                async move { Ok(l.input_value(None).await? == expected_owned) }
398            },
399            timeout,
400            negated,
401            move || {
402                format!(
403                    "to_have_value('{sel}', '{expected_for_msg}') timed out after {}ms",
404                    timeout.as_millis()
405                )
406            },
407        )
408        .await
409    }
410
411    /// Assert the element is empty: an empty input value, or zero matching
412    /// elements.
413    pub async fn to_be_empty(&self) -> Result<()> {
414        let sel = self.sel().to_string();
415        let l = self.locator.clone();
416        let negated = self.negated;
417        let timeout = self.timeout;
418        wait_for(
419            move || {
420                let l = l.clone();
421                async move {
422                    let count = l.count().await?;
423                    if count == 0 {
424                        return Ok(true);
425                    }
426                    // For a present element, "empty" means an empty input value.
427                    let value = l.input_value(None).await.unwrap_or_default();
428                    Ok(value.is_empty())
429                }
430            },
431            timeout,
432            negated,
433            move || format!("to_be_empty('{sel}') timed out after {}ms", timeout.as_millis()),
434        )
435        .await
436    }
437}
438
439// ===========================================================================
440// PageAssertions
441// ===========================================================================
442
443/// Assertions over a [`Page`], produced by [`expect_page`].
444#[derive(Clone)]
445pub struct PageAssertions {
446    page: Page,
447    timeout: Duration,
448    negated: bool,
449}
450
451impl PageAssertions {
452    /// Wrap a page with the default 5s timeout, non-negated.
453    pub fn new(page: Page) -> Self {
454        Self {
455            page,
456            timeout: DEFAULT_TIMEOUT,
457            negated: false,
458        }
459    }
460
461    /// Builder: override the assertion timeout.
462    pub fn with_timeout(mut self, timeout: Duration) -> Self {
463        self.timeout = timeout;
464        self
465    }
466
467    /// Override the assertion timeout in place.
468    pub fn set_timeout(&mut self, timeout: Duration) -> &mut Self {
469        self.timeout = timeout;
470        self
471    }
472
473    /// Return a negated clone: succeeding when the base condition is false.
474    pub fn not(&self) -> Self {
475        let mut c = self.clone();
476        c.negated = true;
477        c
478    }
479
480    /// Assert the page title equals `expected`.
481    pub async fn to_have_title(&self, expected: &str) -> Result<()> {
482        let p = self.page.clone();
483        let negated = self.negated;
484        let timeout = self.timeout;
485        let expected_owned = expected.to_string();
486        let expected_for_msg = expected.to_string();
487        wait_for(
488            move || {
489                let p = p.clone();
490                let expected_owned = expected_owned.clone();
491                async move { Ok(p.title().await? == expected_owned) }
492            },
493            timeout,
494            negated,
495            move || {
496                format!(
497                    "to_have_title('{expected_for_msg}') timed out after {}ms",
498                    timeout.as_millis()
499                )
500            },
501        )
502        .await
503    }
504
505    /// Assert the page URL equals `expected`.
506    pub async fn to_have_url(&self, expected: &str) -> Result<()> {
507        let p = self.page.clone();
508        let negated = self.negated;
509        let timeout = self.timeout;
510        let expected_owned = expected.to_string();
511        let expected_for_msg = expected.to_string();
512        wait_for(
513            move || {
514                let p = p.clone();
515                let expected_owned = expected_owned.clone();
516                async move { Ok(p.url().await? == expected_owned) }
517            },
518            timeout,
519            negated,
520            move || {
521                format!(
522                    "to_have_url('{expected_for_msg}') timed out after {}ms",
523                    timeout.as_millis()
524                )
525            },
526        )
527        .await
528    }
529}