1use std::future::Future;
23use std::time::{Duration, Instant};
24
25use crate::error::Error;
26
27#[derive(Debug, Clone)]
29pub struct SettleConfig {
30 pub timeout: Duration,
33 pub idle_window: Duration,
36 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct Probe {
53 pub dom_ready: bool,
55 pub dom_signature: u64,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum SettleOutcome {
64 Settled,
66 TimedOut,
68}
69
70#[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 pub(crate) fn start() -> Self {
81 Self {
82 total: Duration::ZERO,
83 quiet: Duration::ZERO,
84 last_sig: None,
85 }
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub(crate) enum SettleStep {
92 KeepWaiting(SettleState),
94 Done(SettleOutcome),
96}
97
98pub(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 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
130pub 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
154pub 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 #[test]
191 fn first_probe_is_never_quiet() {
192 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 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 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 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 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 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 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 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 assert_eq!(
303 step(&c, st, Duration::from_millis(200), ready(5)),
304 SettleStep::Done(SettleOutcome::Settled)
305 );
306 }
307
308 #[tokio::test]
311 async fn settle_times_out_immediately_with_zero_timeout() {
312 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 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 #[test]
344 fn parse_dom_ready_matches_only_complete() {
345 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}