Skip to main content

ferridriver_expect/
builder.rs

1//! `Expect<T>` builder shared by every consumer (test runner, QuickJS
2//! binding, plain Rust callers). Targets are subjects of web-first
3//! matchers — `&Locator`, `&Arc<Page>`, `&HttpResponse`. Value matchers
4//! live on [`crate::ExpectValue`] instead.
5
6use std::future::Future;
7use std::time::Duration;
8
9use crate::AssertionFailure;
10use crate::poll::{DEFAULT_EXPECT_TIMEOUT, POLL_INTERVALS};
11
12/// Options for `expect(locator).toBeInViewport()`.
13#[derive(Debug, Clone, Default)]
14pub struct InViewportOptions {
15  /// Required intersection ratio in `[0, 1]`. `None` accepts any
16  /// non-zero overlap (Playwright default).
17  pub ratio: Option<f64>,
18}
19
20/// Options for `expect(locator).toHaveCSS()`.
21#[derive(Debug, Clone, Default)]
22pub struct HaveCssOptions {
23  /// Pseudo-element selector (e.g. `"::before"`, `"::after"`).
24  pub pseudo: Option<String>,
25}
26
27/// Wrap a subject for auto-retrying assertions.
28#[must_use]
29pub fn expect<T>(subject: &T) -> Expect<'_, T> {
30  Expect {
31    subject,
32    timeout: DEFAULT_EXPECT_TIMEOUT,
33    is_not: false,
34    is_soft: false,
35    message: None,
36  }
37}
38
39/// Create a pre-configured expect with custom defaults (Playwright's
40/// `expect.configure()`).
41#[must_use]
42pub fn expect_configured<T>(subject: &T, timeout: Duration) -> Expect<'_, T> {
43  Expect {
44    subject,
45    timeout,
46    is_not: false,
47    is_soft: false,
48    message: None,
49  }
50}
51
52/// Auto-retrying assertion builder.
53pub struct Expect<'a, T> {
54  pub subject: &'a T,
55  pub timeout: Duration,
56  pub is_not: bool,
57  pub is_soft: bool,
58  pub message: Option<String>,
59}
60
61impl<T> Expect<'_, T> {
62  /// Invert the assertion (Playwright's `.not`).
63  #[must_use]
64  pub fn not(mut self) -> Self {
65    self.is_not = !self.is_not;
66    self
67  }
68
69  /// Override the timeout for this assertion.
70  #[must_use]
71  pub fn with_timeout(mut self, timeout: Duration) -> Self {
72    self.timeout = timeout;
73    self
74  }
75
76  /// Custom failure-message prefix.
77  #[must_use]
78  pub fn with_message(mut self, msg: impl Into<String>) -> Self {
79    self.message = Some(msg.into());
80    self
81  }
82
83  /// Mark as soft assertion — error is returned but collected by the
84  /// caller rather than thrown.
85  #[must_use]
86  pub fn soft(mut self) -> Self {
87    self.is_soft = true;
88    self
89  }
90}
91
92// ── expect.poll() ──
93
94/// Poll a generic async function until its return value satisfies a
95/// matcher. Matches Playwright's `expect.poll()`.
96pub struct ExpectPoll<F> {
97  generator: F,
98  timeout: Duration,
99  intervals: Vec<u64>,
100}
101
102/// Create a polling expect (Playwright's `expect.poll(fn)`).
103pub fn expect_poll<F, Fut, T>(generator: F, timeout: Duration) -> ExpectPoll<F>
104where
105  F: Fn() -> Fut,
106  Fut: Future<Output = T>,
107{
108  ExpectPoll {
109    generator,
110    timeout,
111    intervals: POLL_INTERVALS.to_vec(),
112  }
113}
114
115impl<F, Fut, T> ExpectPoll<F>
116where
117  F: Fn() -> Fut,
118  Fut: Future<Output = T>,
119  T: PartialEq + std::fmt::Debug,
120{
121  #[must_use]
122  pub fn with_intervals(mut self, intervals: Vec<u64>) -> Self {
123    self.intervals = intervals;
124    self
125  }
126
127  /// Assert the polled value equals the expected value.
128  pub async fn to_equal(self, expected: T) -> Result<(), AssertionFailure> {
129    let deadline = tokio::time::Instant::now() + self.timeout;
130    let mut interval_idx = 0;
131    loop {
132      let actual = (self.generator)().await;
133      if actual == expected {
134        return Ok(());
135      }
136      let interval_ms = self
137        .intervals
138        .get(interval_idx)
139        .copied()
140        .unwrap_or_else(|| self.intervals.last().copied().unwrap_or(1000));
141      interval_idx += 1;
142      let sleep_dur = Duration::from_millis(interval_ms);
143      if tokio::time::Instant::now() + sleep_dur > deadline {
144        return Err(AssertionFailure::new(
145          "expect.poll().to_equal() timed out".to_string(),
146          Some(format!("Expected: {expected:?}\nReceived: {actual:?}")),
147        ));
148      }
149      tokio::time::sleep(sleep_dur).await;
150    }
151  }
152
153  /// Assert the polled value satisfies a predicate.
154  pub async fn to_satisfy(self, predicate: impl Fn(&T) -> bool, description: &str) -> Result<(), AssertionFailure> {
155    let deadline = tokio::time::Instant::now() + self.timeout;
156    let mut interval_idx = 0;
157    loop {
158      let actual = (self.generator)().await;
159      if predicate(&actual) {
160        return Ok(());
161      }
162      let interval_ms = self
163        .intervals
164        .get(interval_idx)
165        .copied()
166        .unwrap_or_else(|| self.intervals.last().copied().unwrap_or(1000));
167      interval_idx += 1;
168      let sleep_dur = Duration::from_millis(interval_ms);
169      if tokio::time::Instant::now() + sleep_dur > deadline {
170        return Err(AssertionFailure::new(
171          "expect.poll().to_satisfy() timed out".to_string(),
172          Some(format!("Expected: {description}\nReceived: {actual:?}")),
173        ));
174      }
175      tokio::time::sleep(sleep_dur).await;
176    }
177  }
178}
179
180// ── toPass() ──
181
182pub struct ToPassOptions {
183  pub timeout: Duration,
184  pub intervals: Vec<u64>,
185  pub message: Option<String>,
186}
187
188impl Default for ToPassOptions {
189  fn default() -> Self {
190    Self {
191      timeout: DEFAULT_EXPECT_TIMEOUT,
192      intervals: POLL_INTERVALS.to_vec(),
193      message: None,
194    }
195  }
196}
197
198/// Retry an async block until it passes or timeout (Playwright's
199/// `expect(fn).toPass()`).
200pub async fn to_pass<F, Fut>(timeout: Duration, body: F) -> Result<(), AssertionFailure>
201where
202  F: Fn() -> Fut,
203  Fut: Future<Output = Result<(), AssertionFailure>>,
204{
205  to_pass_with_options(
206    body,
207    ToPassOptions {
208      timeout,
209      ..Default::default()
210    },
211  )
212  .await
213}
214
215pub async fn to_pass_with_options<F, Fut>(body: F, options: ToPassOptions) -> Result<(), AssertionFailure>
216where
217  F: Fn() -> Fut,
218  Fut: Future<Output = Result<(), AssertionFailure>>,
219{
220  let deadline = tokio::time::Instant::now() + options.timeout;
221  let mut interval_idx = 0;
222  let mut attempts = 0u32;
223
224  // Loop produces the last failure on timeout exit; the initial `None`
225  // never reads back because the body runs at least once.
226  let final_err: AssertionFailure = loop {
227    attempts += 1;
228    match body().await {
229      Ok(()) => return Ok(()),
230      Err(e) => {
231        let interval_ms = options
232          .intervals
233          .get(interval_idx)
234          .copied()
235          .unwrap_or_else(|| options.intervals.last().copied().unwrap_or(1000));
236        interval_idx += 1;
237        let sleep_dur = Duration::from_millis(interval_ms);
238        if tokio::time::Instant::now() + sleep_dur > deadline {
239          break e;
240        }
241        tokio::time::sleep(sleep_dur).await;
242      },
243    }
244  };
245
246  let mut err = final_err;
247  let prefix = options.message.as_deref().unwrap_or("toPass()");
248  err.message = format!(
249    "{prefix} failed after {attempts} attempt(s) ({:?}): {}",
250    options.timeout, err.message
251  );
252  Err(err)
253}