use std::future::Future;
use std::time::{Duration, Instant};
use crate::error::Error;
#[derive(Debug, Clone)]
pub struct SettleConfig {
pub timeout: Duration,
pub idle_window: Duration,
pub poll_interval: Duration,
}
impl Default for SettleConfig {
fn default() -> Self {
Self {
timeout: Duration::from_secs(10),
idle_window: Duration::from_millis(500),
poll_interval: Duration::from_millis(100),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Probe {
pub dom_ready: bool,
pub dom_signature: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettleOutcome {
Settled,
TimedOut,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct SettleState {
total: Duration,
quiet: Duration,
last_sig: Option<u64>,
}
impl SettleState {
pub(crate) fn start() -> Self {
Self {
total: Duration::ZERO,
quiet: Duration::ZERO,
last_sig: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SettleStep {
KeepWaiting(SettleState),
Done(SettleOutcome),
}
pub(crate) fn step(cfg: &SettleConfig, mut st: SettleState, dt: Duration, p: Probe) -> SettleStep {
st.total = st.total.saturating_add(dt);
let unchanged = st.last_sig == Some(p.dom_signature);
let quiet_now = p.dom_ready && unchanged;
if quiet_now {
st.quiet = st.quiet.saturating_add(dt);
} else {
st.quiet = Duration::ZERO;
}
st.last_sig = Some(p.dom_signature);
if st.quiet >= cfg.idle_window {
SettleStep::Done(SettleOutcome::Settled)
} else if st.total >= cfg.timeout {
SettleStep::Done(SettleOutcome::TimedOut)
} else {
SettleStep::KeepWaiting(st)
}
}
pub async fn settle<F, Fut>(cfg: &SettleConfig, mut probe: F) -> Result<SettleOutcome, Error>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<Probe, Error>>,
{
let mut st = SettleState::start();
let mut last = Instant::now();
loop {
let p = probe().await?;
let now = Instant::now();
let dt = now.saturating_duration_since(last);
last = now;
match step(cfg, st, dt, p) {
SettleStep::Done(outcome) => return Ok(outcome),
SettleStep::KeepWaiting(next) => st = next,
}
tokio::time::sleep(cfg.poll_interval).await;
}
}
pub fn parse_dom_ready(eval_output: &str) -> bool {
eval_output.contains("\"complete\"")
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(timeout_ms: u64, idle_ms: u64) -> SettleConfig {
SettleConfig {
timeout: Duration::from_millis(timeout_ms),
idle_window: Duration::from_millis(idle_ms),
poll_interval: Duration::ZERO,
}
}
fn ready(sig: u64) -> Probe {
Probe {
dom_ready: true,
dom_signature: sig,
}
}
fn loading(sig: u64) -> Probe {
Probe {
dom_ready: false,
dom_signature: sig,
}
}
#[test]
fn first_probe_is_never_quiet() {
let c = cfg(1000, 500);
match step(&c, SettleState::start(), Duration::ZERO, ready(5)) {
SettleStep::KeepWaiting(st) => assert_eq!(st.quiet, Duration::ZERO),
other => panic!("first probe must keep waiting, got {other:?}"),
}
}
#[test]
fn stable_ready_accumulates_quiet_then_settles() {
let c = cfg(10_000, 500);
let st = match step(
&c,
SettleState::start(),
Duration::from_millis(100),
ready(5),
) {
SettleStep::KeepWaiting(st) => st,
other => panic!("expected KeepWaiting, got {other:?}"),
};
let st = match step(&c, st, Duration::from_millis(300), ready(5)) {
SettleStep::KeepWaiting(st) => st,
other => panic!("expected KeepWaiting at 300ms quiet, got {other:?}"),
};
assert_eq!(
step(&c, st, Duration::from_millis(300), ready(5)),
SettleStep::Done(SettleOutcome::Settled)
);
}
#[test]
fn dom_mutation_resets_quiet() {
let c = cfg(10_000, 500);
let st = match step(
&c,
SettleState::start(),
Duration::from_millis(100),
ready(5),
) {
SettleStep::KeepWaiting(st) => st,
o => panic!("{o:?}"),
};
let st = match step(&c, st, Duration::from_millis(400), ready(5)) {
SettleStep::KeepWaiting(st) => st,
o => panic!("{o:?}"),
};
match step(&c, st, Duration::from_millis(100), ready(9)) {
SettleStep::KeepWaiting(st) => assert_eq!(st.quiet, Duration::ZERO),
o => panic!("mutation must reset quiet, got {o:?}"),
}
}
#[test]
fn not_ready_resets_quiet() {
let c = cfg(10_000, 500);
let st = match step(
&c,
SettleState::start(),
Duration::from_millis(100),
ready(5),
) {
SettleStep::KeepWaiting(st) => st,
o => panic!("{o:?}"),
};
let st = match step(&c, st, Duration::from_millis(400), ready(5)) {
SettleStep::KeepWaiting(st) => st,
o => panic!("{o:?}"),
};
match step(&c, st, Duration::from_millis(100), loading(5)) {
SettleStep::KeepWaiting(st) => assert_eq!(st.quiet, Duration::ZERO),
o => panic!("not-ready must reset quiet, got {o:?}"),
}
}
#[test]
fn times_out_when_never_quiet() {
let c = cfg(1000, 500);
let mut st = SettleState::start();
let mut sig = 0;
let mut outcome = None;
for _ in 0..20 {
sig += 1;
match step(&c, st, Duration::from_millis(200), ready(sig)) {
SettleStep::Done(o) => {
outcome = Some(o);
break;
}
SettleStep::KeepWaiting(next) => st = next,
}
}
assert_eq!(outcome, Some(SettleOutcome::TimedOut));
}
#[test]
fn settle_takes_priority_over_timeout_on_same_probe() {
let c = cfg(1000, 500);
let st = SettleState {
total: Duration::from_millis(900),
quiet: Duration::from_millis(400),
last_sig: Some(5),
};
assert_eq!(
step(&c, st, Duration::from_millis(200), ready(5)),
SettleStep::Done(SettleOutcome::Settled)
);
}
#[tokio::test]
async fn settle_times_out_immediately_with_zero_timeout() {
let c = cfg(0, 1000);
let out = settle(&c, || async { Ok(loading(1)) }).await.expect("ok");
assert_eq!(out, SettleOutcome::TimedOut);
}
#[tokio::test]
async fn settle_returns_settled_for_a_quiet_page() {
let c = SettleConfig {
timeout: Duration::from_secs(5),
idle_window: Duration::from_nanos(1),
poll_interval: Duration::ZERO,
};
let out = settle(&c, || async { Ok(ready(7)) }).await.expect("ok");
assert_eq!(out, SettleOutcome::Settled);
}
#[tokio::test]
async fn settle_propagates_probe_error() {
let c = SettleConfig::default();
let r = settle(&c, || async { Err(Error::Agent("probe boom".into())) }).await;
assert!(r.is_err(), "a probe error must propagate, not be swallowed");
}
#[test]
fn parse_dom_ready_matches_only_complete() {
let complete = "Script ran on page and returned:\n```json\n\"complete\"\n```";
assert!(parse_dom_ready(complete));
assert!(!parse_dom_ready(
"Script ran on page and returned:\n```json\n\"loading\"\n```"
));
assert!(!parse_dom_ready(
"Script ran on page and returned:\n```json\n\"interactive\"\n```"
));
}
}