Skip to main content

browser_commander/core/
readiness.rs

1//! Readiness primitives - one monotonic deadline, checks that report evidence.
2//!
3//! Navigation used to answer "did the page settle?" and then throw the answer
4//! away: `wait_for_url_stabilization` returned `false` on timeout, the caller
5//! discarded it, and the navigation was reported as a success anyway. These
6//! types make the answer impossible to discard - a readiness wait carries the
7//! evidence for every check it ran, and one deadline bounds all of them.
8
9use serde_json::{json, Value};
10use std::fmt;
11use std::time::{Duration, Instant};
12
13/// How a readiness wait ended.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum ReadinessStatus {
16    /// Every check was satisfied.
17    #[default]
18    Ready,
19    /// The budget ran out before every check could answer.
20    TimedOut,
21    /// A check reported, within budget, that it was not satisfied.
22    Failed,
23}
24
25impl fmt::Display for ReadinessStatus {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        match self {
28            ReadinessStatus::Ready => write!(f, "ready"),
29            ReadinessStatus::TimedOut => write!(f, "timed_out"),
30            ReadinessStatus::Failed => write!(f, "failed"),
31        }
32    }
33}
34
35/// A monotonic budget shared by every check in one readiness wait.
36///
37/// The clock is monotonic on purpose: a wall-clock jump - an NTP correction, a
38/// suspended laptop - must not turn a five second budget into a five minute
39/// one. `remaining` never grows, so no caller can be handed a larger budget
40/// than the one it asked for.
41#[derive(Debug, Clone)]
42pub struct Deadline {
43    started_at: Instant,
44    timeout: Duration,
45}
46
47impl Deadline {
48    /// Start a deadline with the given total budget.
49    #[must_use]
50    pub fn new(timeout: Duration) -> Self {
51        Self {
52            started_at: Instant::now(),
53            timeout,
54        }
55    }
56
57    /// Total budget this deadline was created with.
58    #[must_use]
59    pub fn timeout(&self) -> Duration {
60        self.timeout
61    }
62
63    /// Time spent so far.
64    #[must_use]
65    pub fn elapsed(&self) -> Duration {
66        self.started_at.elapsed()
67    }
68
69    /// Elapsed milliseconds, for evidence records.
70    #[must_use]
71    pub fn elapsed_ms(&self) -> u128 {
72        self.elapsed().as_millis()
73    }
74
75    /// Budget left, saturating at zero.
76    #[must_use]
77    pub fn remaining(&self) -> Duration {
78        self.timeout.saturating_sub(self.elapsed())
79    }
80
81    /// Whether the budget is spent.
82    #[must_use]
83    pub fn expired(&self) -> bool {
84        self.remaining().is_zero()
85    }
86
87    /// Sleep for at most the remaining budget.
88    pub async fn sleep_at_most(&self, requested: Duration) {
89        let capped = requested.min(self.remaining());
90        if !capped.is_zero() {
91            tokio::time::sleep(capped).await;
92        }
93    }
94}
95
96/// What running an operation under a deadline produced.
97///
98/// `TimedOut` carries no value on purpose: nothing was observed, so there is
99/// nothing to report but the fact that the budget, rather than the operation,
100/// ended the wait.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum DeadlineOutcome<T> {
103    /// The operation answered inside the budget.
104    Completed(T),
105    /// The budget ran out first.
106    TimedOut,
107}
108
109impl<T> DeadlineOutcome<T> {
110    /// Whether the budget ran out before the operation answered.
111    #[must_use]
112    pub fn timed_out(&self) -> bool {
113        matches!(self, Self::TimedOut)
114    }
115
116    /// The value, if there is one.
117    #[must_use]
118    pub fn value(self) -> Option<T> {
119        match self {
120            Self::Completed(value) => Some(value),
121            Self::TimedOut => None,
122        }
123    }
124}
125
126/// Run an operation under a deadline, reporting expiry instead of waiting.
127///
128/// Engine probes carry timeouts of their own - Playwright's locator default is
129/// 30 seconds - so an operation given a 3 second budget could spend ten times
130/// that waiting for an element a navigation had already taken away. The budget
131/// the caller asked for has to win.
132pub async fn run_within_deadline<F, T>(deadline: &Deadline, operation: F) -> DeadlineOutcome<T>
133where
134    F: std::future::Future<Output = T>,
135{
136    let remaining = deadline.remaining();
137    if remaining.is_zero() {
138        // Starting an operation with no budget left only delays the answer we
139        // already have.
140        return DeadlineOutcome::TimedOut;
141    }
142
143    match tokio::time::timeout(remaining, operation).await {
144        Ok(value) => DeadlineOutcome::Completed(value),
145        Err(_) => DeadlineOutcome::TimedOut,
146    }
147}
148
149/// Evidence for one check that ran.
150#[derive(Debug, Clone, PartialEq)]
151pub struct CheckRecord {
152    /// Check name.
153    pub name: String,
154    /// Whether the check was satisfied.
155    pub satisfied: bool,
156    /// Whether the check could not run and was skipped.
157    pub skipped: bool,
158    /// Milliseconds into the wait at which the check started.
159    pub started_at_ms: u128,
160    /// Milliseconds the check itself took.
161    pub elapsed_ms: u128,
162    /// Structured detail describing what the check observed.
163    pub detail: Value,
164}
165
166impl CheckRecord {
167    /// Record a satisfied check.
168    #[must_use]
169    pub fn satisfied(name: impl Into<String>, detail: Value) -> Self {
170        Self {
171            name: name.into(),
172            satisfied: true,
173            skipped: false,
174            started_at_ms: 0,
175            elapsed_ms: 0,
176            detail,
177        }
178    }
179
180    /// Record a check that was not satisfied.
181    #[must_use]
182    pub fn unsatisfied(name: impl Into<String>, detail: Value) -> Self {
183        Self {
184            name: name.into(),
185            satisfied: false,
186            skipped: false,
187            started_at_ms: 0,
188            elapsed_ms: 0,
189            detail,
190        }
191    }
192
193    /// Record a check that could not run.
194    #[must_use]
195    pub fn skipped(name: impl Into<String>, reason: impl Into<String>) -> Self {
196        Self {
197            name: name.into(),
198            satisfied: false,
199            skipped: true,
200            started_at_ms: 0,
201            elapsed_ms: 0,
202            detail: json!({ "reason": reason.into() }),
203        }
204    }
205
206    /// Attach the timing window the check occupied.
207    #[must_use]
208    pub fn with_timing(mut self, started_at_ms: u128, elapsed_ms: u128) -> Self {
209        self.started_at_ms = started_at_ms;
210        self.elapsed_ms = elapsed_ms;
211        self
212    }
213}
214
215/// The outcome of a readiness wait, with the evidence behind it.
216#[derive(Debug, Clone, Default)]
217pub struct ReadinessOutcome {
218    /// How the wait ended.
219    pub status: ReadinessStatus,
220    /// Whether every check was satisfied.
221    pub ready: bool,
222    /// Names of satisfied checks.
223    pub satisfied: Vec<String>,
224    /// Names of checks that reported "not satisfied".
225    pub failed: Vec<String>,
226    /// Names of checks that could not run.
227    pub skipped: Vec<String>,
228    /// Names of checks that never started because the budget ran out.
229    pub pending: Vec<String>,
230    /// Evidence for every check that ran.
231    pub evidence: Vec<CheckRecord>,
232    /// Total milliseconds spent.
233    pub elapsed_ms: u128,
234    /// The budget the wait was given.
235    pub timeout_ms: u128,
236}
237
238impl ReadinessOutcome {
239    /// Start an empty outcome bound to a deadline's budget.
240    #[must_use]
241    pub fn new(deadline: &Deadline) -> Self {
242        Self {
243            status: ReadinessStatus::Ready,
244            ready: true,
245            timeout_ms: deadline.timeout().as_millis(),
246            ..Self::default()
247        }
248    }
249
250    /// Fold one check's evidence into the outcome.
251    pub fn record(&mut self, record: CheckRecord) {
252        if record.skipped {
253            self.skipped.push(record.name.clone());
254        } else if record.satisfied {
255            self.satisfied.push(record.name.clone());
256        } else {
257            self.failed.push(record.name.clone());
258        }
259        self.evidence.push(record);
260    }
261
262    /// Note a check that never got to run.
263    pub fn defer(&mut self, name: impl Into<String>) {
264        self.pending.push(name.into());
265    }
266
267    /// Settle the status against the deadline once every check has reported.
268    #[must_use]
269    pub fn finish(mut self, deadline: &Deadline) -> Self {
270        self.ready = self.failed.is_empty() && self.pending.is_empty();
271        self.elapsed_ms = deadline.elapsed_ms();
272        self.status = if self.ready {
273            ReadinessStatus::Ready
274        } else if deadline.expired() {
275            ReadinessStatus::TimedOut
276        } else {
277            ReadinessStatus::Failed
278        };
279        self
280    }
281
282    /// A one-line summary suitable for a result reason.
283    #[must_use]
284    pub fn summary(&self) -> String {
285        if self.ready {
286            return format!("page ready after {}ms", self.elapsed_ms);
287        }
288        format!(
289            "page not ready ({}) after {}ms; failed=[{}] pending=[{}]",
290            self.status,
291            self.elapsed_ms,
292            self.failed.join(", "),
293            self.pending.join(", ")
294        )
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[tokio::test]
303    async fn run_within_deadline_returns_a_value_that_arrives_in_time() {
304        let deadline = Deadline::new(Duration::from_secs(1));
305
306        let outcome = run_within_deadline(&deadline, async { "done" }).await;
307
308        assert!(!outcome.timed_out());
309        assert_eq!(outcome.value(), Some("done"));
310    }
311
312    #[tokio::test]
313    async fn run_within_deadline_reports_expiry_instead_of_waiting_the_operation_out() {
314        let deadline = Deadline::new(Duration::from_millis(30));
315        let started = Instant::now();
316
317        let outcome = run_within_deadline(&deadline, async {
318            tokio::time::sleep(Duration::from_secs(5)).await;
319            "too late"
320        })
321        .await;
322
323        assert!(outcome.timed_out());
324        assert!(
325            started.elapsed() < Duration::from_secs(2),
326            "the budget, not the operation, has to end the wait"
327        );
328    }
329
330    #[tokio::test]
331    async fn run_within_deadline_does_not_start_an_operation_with_no_budget_left() {
332        let deadline = Deadline::new(Duration::ZERO);
333        let started = std::sync::atomic::AtomicBool::new(false);
334
335        let outcome = run_within_deadline(&deadline, async {
336            started.store(true, std::sync::atomic::Ordering::SeqCst);
337        })
338        .await;
339
340        assert!(outcome.timed_out());
341        assert!(
342            !started.load(std::sync::atomic::Ordering::SeqCst),
343            "an operation with no budget left only delays the answer we have"
344        );
345    }
346
347    #[test]
348    fn a_deadline_never_hands_out_more_budget_than_it_was_given() {
349        // Regression guard for issue #89: the JS implementation computed
350        // `max(60000, timeout - elapsed)`, so a 10ms budget became 60s.
351        let deadline = Deadline::new(Duration::from_millis(10));
352
353        assert!(deadline.remaining() <= Duration::from_millis(10));
354        assert_eq!(deadline.timeout(), Duration::from_millis(10));
355    }
356
357    #[test]
358    fn remaining_saturates_at_zero() {
359        let deadline = Deadline::new(Duration::ZERO);
360
361        assert!(deadline.expired());
362        assert_eq!(deadline.remaining(), Duration::ZERO);
363    }
364
365    #[tokio::test]
366    async fn sleeping_is_capped_by_the_remaining_budget() {
367        let deadline = Deadline::new(Duration::from_millis(20));
368
369        deadline.sleep_at_most(Duration::from_secs(30)).await;
370
371        assert!(deadline.elapsed() < Duration::from_secs(1));
372    }
373
374    #[test]
375    fn an_unsatisfied_check_never_reports_ready() {
376        // Regression guard for issue #89: "did not stabilize" used to be
377        // discarded and the operation reported as a success.
378        let deadline = Deadline::new(Duration::from_secs(5));
379        let mut outcome = ReadinessOutcome::new(&deadline);
380
381        outcome.record(CheckRecord::unsatisfied(
382            "url_stable_for",
383            json!({ "url": "https://example.com/" }),
384        ));
385        let outcome = outcome.finish(&deadline);
386
387        assert!(!outcome.ready);
388        assert_eq!(outcome.status, ReadinessStatus::Failed);
389        assert_eq!(outcome.failed, vec!["url_stable_for".to_string()]);
390        assert!(outcome.summary().contains("url_stable_for"));
391    }
392
393    #[test]
394    fn an_expired_budget_reports_timed_out() {
395        let deadline = Deadline::new(Duration::ZERO);
396        let mut outcome = ReadinessOutcome::new(&deadline);
397
398        outcome.record(CheckRecord::unsatisfied("url_stable_for", json!({})));
399        let outcome = outcome.finish(&deadline);
400
401        assert_eq!(outcome.status, ReadinessStatus::TimedOut);
402    }
403
404    #[test]
405    fn a_skipped_check_does_not_block_readiness() {
406        let deadline = Deadline::new(Duration::from_secs(5));
407        let mut outcome = ReadinessOutcome::new(&deadline);
408
409        outcome.record(CheckRecord::skipped("network_idle_for", "no tracker"));
410        let outcome = outcome.finish(&deadline);
411
412        assert!(outcome.ready);
413        assert_eq!(outcome.skipped, vec!["network_idle_for".to_string()]);
414    }
415
416    #[test]
417    fn checks_that_never_ran_are_pending_not_satisfied() {
418        let deadline = Deadline::new(Duration::from_secs(5));
419        let mut outcome = ReadinessOutcome::new(&deadline);
420
421        outcome.record(CheckRecord::satisfied("url_stable_for", json!({})));
422        outcome.defer("verify_navigation");
423        let outcome = outcome.finish(&deadline);
424
425        assert!(!outcome.ready);
426        assert_eq!(outcome.pending, vec!["verify_navigation".to_string()]);
427    }
428
429    #[test]
430    fn evidence_carries_the_timing_window_of_each_check() {
431        let record = CheckRecord::satisfied("url_stable_for", json!({})).with_timing(5, 12);
432
433        assert_eq!(record.started_at_ms, 5);
434        assert_eq!(record.elapsed_ms, 12);
435    }
436
437    #[test]
438    fn statuses_render_the_documented_names() {
439        assert_eq!(ReadinessStatus::Ready.to_string(), "ready");
440        assert_eq!(ReadinessStatus::TimedOut.to_string(), "timed_out");
441        assert_eq!(ReadinessStatus::Failed.to_string(), "failed");
442    }
443}