Skip to main content

heartbit_core/browser/
settle.rs

1//! Wait-for-stability / network-idle settle (capability 6 of the browser-bot spec).
2//!
3//! Playwright's actionability contract is the reference: never act on a
4//! half-rendered page. `chrome-devtools-mcp`'s `wait_for` only resolves on a
5//! *specific expected string*, so it cannot express "wait until the page stops
6//! changing". Acting on an unsettled SPA poisons the whole observe→act→verify
7//! loop (a `uid` grounded on a mid-render snapshot is stale before the click).
8//!
9//! This module synthesizes a settle primitive from a cheap, repeatable probe:
10//! `document.readyState === "complete"` plus a DOM-content signature that must
11//! stop changing for a configurable idle window. The decision logic is a **pure
12//! state machine** ([`step`]) folding one [`Probe`] at a time with a
13//! caller-supplied `dt` — no wall-clock — so every transition is exhaustively
14//! unit-testable. The async [`settle`] driver is thin glue over it: call probe,
15//! measure elapsed, fold, sleep, repeat.
16//!
17//! Network-idle (counting in-flight requests via `list_network_requests`) is a
18//! planned extension; its live output shape is not yet captured, and guessing it
19//! would be the exact fabrication this project guards against. v1 settles on
20//! DOM-ready + DOM-stability, which already covers the dominant SPA-render case.
21
22use std::future::Future;
23use std::time::{Duration, Instant};
24
25use crate::error::Error;
26
27/// Tuning for [`settle`].
28#[derive(Debug, Clone)]
29pub struct SettleConfig {
30    /// Hard upper bound on the total wait. On reaching it, [`settle`] returns
31    /// [`SettleOutcome::TimedOut`] regardless of quiescence.
32    pub timeout: Duration,
33    /// How long the page must remain continuously quiescent (DOM-ready and its
34    /// content signature unchanged) before it is considered settled.
35    pub idle_window: Duration,
36    /// Delay between successive probes.
37    pub poll_interval: Duration,
38}
39
40impl Default for SettleConfig {
41    fn default() -> Self {
42        Self {
43            timeout: Duration::from_secs(10),
44            idle_window: Duration::from_millis(500),
45            poll_interval: Duration::from_millis(100),
46        }
47    }
48}
49
50/// One probe reading of page quiescence.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct Probe {
53    /// `document.readyState === "complete"`.
54    pub dom_ready: bool,
55    /// A cheap signature of DOM content (e.g. serialized-length or node count).
56    /// Compared only for *equality* across consecutive probes to detect ongoing
57    /// mutation; its absolute value is meaningless.
58    pub dom_signature: u64,
59}
60
61/// Terminal result of [`settle`].
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum SettleOutcome {
64    /// The page was DOM-ready and unchanged for the full idle window.
65    Settled,
66    /// The timeout elapsed before the page settled.
67    TimedOut,
68}
69
70/// Running accumulator threaded across probes (pure; carries no clock).
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub(crate) struct SettleState {
73    total: Duration,
74    quiet: Duration,
75    last_sig: Option<u64>,
76}
77
78impl SettleState {
79    /// The initial state before any probe.
80    pub(crate) fn start() -> Self {
81        Self {
82            total: Duration::ZERO,
83            quiet: Duration::ZERO,
84            last_sig: None,
85        }
86    }
87}
88
89/// The result of folding one probe into the running state.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub(crate) enum SettleStep {
92    /// Not settled yet; carry this state into the next probe.
93    KeepWaiting(SettleState),
94    /// Reached a terminal outcome.
95    Done(SettleOutcome),
96}
97
98/// Pure transition: fold one [`Probe`] — taken `dt` after the previous one —
99/// into `st`, yielding the next state or a terminal [`SettleOutcome`].
100///
101/// A probe counts as *quiet* only when the page is DOM-ready **and** its content
102/// signature is unchanged from the previous probe. The very first probe is never
103/// quiet (no prior signature to compare), so settling always requires at least
104/// two consecutive matching readings. Settling is checked before the timeout, so
105/// a page that goes quiet on the same probe that crosses the deadline still
106/// settles.
107pub(crate) fn step(cfg: &SettleConfig, mut st: SettleState, dt: Duration, p: Probe) -> SettleStep {
108    st.total = st.total.saturating_add(dt);
109
110    let unchanged = st.last_sig == Some(p.dom_signature);
111    let quiet_now = p.dom_ready && unchanged;
112    if quiet_now {
113        st.quiet = st.quiet.saturating_add(dt);
114    } else {
115        st.quiet = Duration::ZERO;
116    }
117    st.last_sig = Some(p.dom_signature);
118
119    // Settling is checked BEFORE the timeout so that a probe which both completes
120    // the idle window and crosses the deadline reports Settled, not TimedOut.
121    if st.quiet >= cfg.idle_window {
122        SettleStep::Done(SettleOutcome::Settled)
123    } else if st.total >= cfg.timeout {
124        SettleStep::Done(SettleOutcome::TimedOut)
125    } else {
126        SettleStep::KeepWaiting(st)
127    }
128}
129
130/// Drive a settle loop: repeatedly call `probe`, folding each reading via
131/// [`step`] and sleeping `poll_interval` between calls, until the page settles
132/// or the timeout elapses. `dt` for each fold is the real elapsed time since the
133/// previous probe, so the loop is self-pacing.
134pub async fn settle<F, Fut>(cfg: &SettleConfig, mut probe: F) -> Result<SettleOutcome, Error>
135where
136    F: FnMut() -> Fut,
137    Fut: Future<Output = Result<Probe, Error>>,
138{
139    let mut st = SettleState::start();
140    let mut last = Instant::now();
141    loop {
142        let p = probe().await?;
143        let now = Instant::now();
144        let dt = now.saturating_duration_since(last);
145        last = now;
146        match step(cfg, st, dt, p) {
147            SettleStep::Done(outcome) => return Ok(outcome),
148            SettleStep::KeepWaiting(next) => st = next,
149        }
150        tokio::time::sleep(cfg.poll_interval).await;
151    }
152}
153
154/// Interpret the output of `evaluate_script(() => document.readyState)` as
155/// DOM-readiness. chrome-devtools-mcp returns the JSON result wrapped in text
156/// (verified live: a fenced ```json block containing `"complete"`); only the
157/// `"complete"` readyState is treated as ready (`"loading"`/`"interactive"` are
158/// not). Matching the quoted token avoids false positives from surrounding prose.
159pub fn parse_dom_ready(eval_output: &str) -> bool {
160    eval_output.contains("\"complete\"")
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    fn cfg(timeout_ms: u64, idle_ms: u64) -> SettleConfig {
168        SettleConfig {
169            timeout: Duration::from_millis(timeout_ms),
170            idle_window: Duration::from_millis(idle_ms),
171            poll_interval: Duration::ZERO,
172        }
173    }
174
175    fn ready(sig: u64) -> Probe {
176        Probe {
177            dom_ready: true,
178            dom_signature: sig,
179        }
180    }
181    fn loading(sig: u64) -> Probe {
182        Probe {
183            dom_ready: false,
184            dom_signature: sig,
185        }
186    }
187
188    // --- pure step() state machine (deterministic, no clock) ---
189
190    #[test]
191    fn first_probe_is_never_quiet() {
192        // No prior signature to compare → cannot be "unchanged" → quiet stays 0.
193        let c = cfg(1000, 500);
194        match step(&c, SettleState::start(), Duration::ZERO, ready(5)) {
195            SettleStep::KeepWaiting(st) => assert_eq!(st.quiet, Duration::ZERO),
196            other => panic!("first probe must keep waiting, got {other:?}"),
197        }
198    }
199
200    #[test]
201    fn stable_ready_accumulates_quiet_then_settles() {
202        let c = cfg(10_000, 500);
203        // 1st probe establishes the signature (quiet=0).
204        let st = match step(
205            &c,
206            SettleState::start(),
207            Duration::from_millis(100),
208            ready(5),
209        ) {
210            SettleStep::KeepWaiting(st) => st,
211            other => panic!("expected KeepWaiting, got {other:?}"),
212        };
213        // 2nd probe: unchanged + ready → quiet accrues 300ms (< 500 window).
214        let st = match step(&c, st, Duration::from_millis(300), ready(5)) {
215            SettleStep::KeepWaiting(st) => st,
216            other => panic!("expected KeepWaiting at 300ms quiet, got {other:?}"),
217        };
218        // 3rd probe: another 300ms quiet → 600ms ≥ 500ms window → Settled.
219        assert_eq!(
220            step(&c, st, Duration::from_millis(300), ready(5)),
221            SettleStep::Done(SettleOutcome::Settled)
222        );
223    }
224
225    #[test]
226    fn dom_mutation_resets_quiet() {
227        let c = cfg(10_000, 500);
228        let st = match step(
229            &c,
230            SettleState::start(),
231            Duration::from_millis(100),
232            ready(5),
233        ) {
234            SettleStep::KeepWaiting(st) => st,
235            o => panic!("{o:?}"),
236        };
237        let st = match step(&c, st, Duration::from_millis(400), ready(5)) {
238            SettleStep::KeepWaiting(st) => st,
239            o => panic!("{o:?}"),
240        };
241        // Signature changes (DOM still mutating) → quiet resets to 0.
242        match step(&c, st, Duration::from_millis(100), ready(9)) {
243            SettleStep::KeepWaiting(st) => assert_eq!(st.quiet, Duration::ZERO),
244            o => panic!("mutation must reset quiet, got {o:?}"),
245        }
246    }
247
248    #[test]
249    fn not_ready_resets_quiet() {
250        let c = cfg(10_000, 500);
251        let st = match step(
252            &c,
253            SettleState::start(),
254            Duration::from_millis(100),
255            ready(5),
256        ) {
257            SettleStep::KeepWaiting(st) => st,
258            o => panic!("{o:?}"),
259        };
260        let st = match step(&c, st, Duration::from_millis(400), ready(5)) {
261            SettleStep::KeepWaiting(st) => st,
262            o => panic!("{o:?}"),
263        };
264        // readyState regresses to loading (same sig) → not quiet → reset.
265        match step(&c, st, Duration::from_millis(100), loading(5)) {
266            SettleStep::KeepWaiting(st) => assert_eq!(st.quiet, Duration::ZERO),
267            o => panic!("not-ready must reset quiet, got {o:?}"),
268        }
269    }
270
271    #[test]
272    fn times_out_when_never_quiet() {
273        let c = cfg(1000, 500);
274        // A page that keeps mutating: each probe has a new signature → never quiet.
275        let mut st = SettleState::start();
276        let mut sig = 0;
277        let mut outcome = None;
278        for _ in 0..20 {
279            sig += 1;
280            match step(&c, st, Duration::from_millis(200), ready(sig)) {
281                SettleStep::Done(o) => {
282                    outcome = Some(o);
283                    break;
284                }
285                SettleStep::KeepWaiting(next) => st = next,
286            }
287        }
288        assert_eq!(outcome, Some(SettleOutcome::TimedOut));
289    }
290
291    #[test]
292    fn settle_takes_priority_over_timeout_on_same_probe() {
293        // total will cross the 1000ms timeout on the SAME probe that completes the
294        // idle window; settling must win.
295        let c = cfg(1000, 500);
296        let st = SettleState {
297            total: Duration::from_millis(900),
298            quiet: Duration::from_millis(400),
299            last_sig: Some(5),
300        };
301        // dt=200ms → total=1100 (≥timeout) AND quiet=600 (≥window). Settled wins.
302        assert_eq!(
303            step(&c, st, Duration::from_millis(200), ready(5)),
304            SettleStep::Done(SettleOutcome::Settled)
305        );
306    }
307
308    // --- async settle() driver (deterministic via zero-config edges) ---
309
310    #[tokio::test]
311    async fn settle_times_out_immediately_with_zero_timeout() {
312        // timeout=0, idle_window>0, a busy page → first fold yields TimedOut,
313        // no sleeps, no real-time dependence.
314        let c = cfg(0, 1000);
315        let out = settle(&c, || async { Ok(loading(1)) }).await.expect("ok");
316        assert_eq!(out, SettleOutcome::TimedOut);
317    }
318
319    #[tokio::test]
320    async fn settle_returns_settled_for_a_quiet_page() {
321        // A page that is immediately ready and stable settles within the window.
322        // idle_window small; the driver folds real (tiny) dt values until quiet
323        // accrues. poll_interval=0 keeps it fast; correctness of timing is proven
324        // by the pure step() tests — here we only assert the driver reaches it.
325        let c = SettleConfig {
326            timeout: Duration::from_secs(5),
327            idle_window: Duration::from_nanos(1),
328            poll_interval: Duration::ZERO,
329        };
330        let out = settle(&c, || async { Ok(ready(7)) }).await.expect("ok");
331        assert_eq!(out, SettleOutcome::Settled);
332    }
333
334    #[tokio::test]
335    async fn settle_propagates_probe_error() {
336        let c = SettleConfig::default();
337        let r = settle(&c, || async { Err(Error::Agent("probe boom".into())) }).await;
338        assert!(r.is_err(), "a probe error must propagate, not be swallowed");
339    }
340
341    // --- parse_dom_ready against the real captured output format ---
342
343    #[test]
344    fn parse_dom_ready_matches_only_complete() {
345        // The exact wrapper chrome-devtools-mcp emits (captured live this session).
346        let complete = "Script ran on page and returned:\n```json\n\"complete\"\n```";
347        assert!(parse_dom_ready(complete));
348        assert!(!parse_dom_ready(
349            "Script ran on page and returned:\n```json\n\"loading\"\n```"
350        ));
351        assert!(!parse_dom_ready(
352            "Script ran on page and returned:\n```json\n\"interactive\"\n```"
353        ));
354    }
355}