use std::time::Duration;
use chromiumoxide::cdp::js_protocol::runtime::EvaluateParams;
use chromiumoxide::Page;
use serde::{Deserialize, Serialize};
use crate::error::{AuditError, Result};
pub const DEFAULT_SETTLE_MS: u64 = 150;
pub const DEFAULT_STABILITY_BUDGET_MS: u64 = 1_500;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StabilityProvenance {
pub viewport: String,
pub status: StabilityStatus,
pub waited_ms: u64,
pub mutation_count: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StabilityStatus {
Stable,
ReadySignal,
#[default]
BudgetExhausted,
OngoingAnimation,
Fallback,
}
pub async fn wait_for_page_stability(
page: &Page,
viewport: &str,
budget_ms: u64,
) -> StabilityProvenance {
let budget_ms = budget_ms.clamp(200, 10_000);
let expression = format!(
r#"new Promise(resolve => {{
const started = performance.now();
let mutations = 0;
let addedElementNodes = 0;
const targets = new Set();
let quietTimer;
let done = false;
const finish = (status, reason) => {{
if (done) return;
done = true;
observer.disconnect();
clearTimeout(quietTimer);
clearTimeout(budgetTimer);
resolve({{ status, waited_ms: Math.round(performance.now() - started), mutation_count: mutations, reason }});
}};
const observer = new MutationObserver(records => {{
mutations += records.length;
for (const r of records) {{
if (targets.size < 25) targets.add(r.target);
if (r.type === 'childList') {{
for (const n of r.addedNodes) {{
if (n.nodeType === 1) addedElementNodes++;
}}
}}
}}
clearTimeout(quietTimer);
quietTimer = setTimeout(() => finish('stable', null), 200);
}});
observer.observe(document.documentElement, {{subtree:true, childList:true, attributes:true, characterData:true}});
const budgetTimer = setTimeout(() => {{
let hasRunningAnimation = false;
try {{
hasRunningAnimation = typeof document.getAnimations === 'function' &&
document.getAnimations({{subtree:true}}).some(a => a.playState === 'running');
}} catch (e) {{}}
const boundedTargets = targets.size > 0 && targets.size <= 6;
const noContentGrowth = addedElementNodes === 0;
if (boundedTargets && (noContentGrowth || hasRunningAnimation)) {{
finish('ongoing_animation', 'DOM mutations stayed confined to a small, stable set of elements without content growth, consistent with a running animation');
}} else {{
finish('budget_exhausted', 'DOM did not remain quiet within the configured budget');
}}
}}, {budget_ms});
if (window.__AUDITMYSITE_READY__ === true || document.documentElement.dataset.auditReady === 'true') {{
finish('ready_signal', null);
}} else {{
quietTimer = setTimeout(() => finish('stable', null), 200);
}}
}})"#
);
let params = match EvaluateParams::builder()
.expression(expression)
.await_promise(true)
.return_by_value(true)
.build()
{
Ok(params) => params,
Err(error) => {
tokio::time::sleep(Duration::from_millis(DEFAULT_SETTLE_MS)).await;
return StabilityProvenance {
viewport: viewport.to_string(),
status: StabilityStatus::Fallback,
waited_ms: DEFAULT_SETTLE_MS,
mutation_count: 0,
reason: Some(format!("Stability script could not be built: {error}")),
};
}
};
match tokio::time::timeout(Duration::from_millis(budget_ms + 500), page.execute(params)).await {
Ok(Ok(result)) => {
let value = result.result.result.value.clone().unwrap_or_default();
let status = match value.get("status").and_then(serde_json::Value::as_str) {
Some("stable") => StabilityStatus::Stable,
Some("ready_signal") => StabilityStatus::ReadySignal,
Some("ongoing_animation") => StabilityStatus::OngoingAnimation,
_ => StabilityStatus::BudgetExhausted,
};
StabilityProvenance {
viewport: viewport.to_string(),
status,
waited_ms: value
.get("waited_ms")
.and_then(serde_json::Value::as_u64)
.unwrap_or(budget_ms),
mutation_count: value
.get("mutation_count")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0),
reason: value
.get("reason")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
}
}
_ => StabilityProvenance {
viewport: viewport.to_string(),
status: StabilityStatus::Fallback,
waited_ms: budget_ms,
mutation_count: 0,
reason: Some("Stability evaluation failed or timed out".to_string()),
},
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AnimationSettle {
pub settled: bool,
pub waited_ms: u64,
pub running: u64,
}
impl AnimationSettle {
fn from_value(value: &serde_json::Value) -> Self {
let running = value
.get("running")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
Self {
settled: value.get("settled").and_then(serde_json::Value::as_bool) == Some(true)
&& running == 0,
waited_ms: value
.get("waited_ms")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0),
running,
}
}
}
pub async fn wait_for_finite_animations(page: &Page, budget_ms: u64) -> AnimationSettle {
let budget_ms = budget_ms.clamp(200, 10_000);
let expression = format!(
r#"new Promise(resolve => {{
if (typeof document.getAnimations !== 'function') {{
resolve({{ settled: true, waited_ms: 0, running: 0 }});
return;
}}
const started = performance.now();
const remaining = a => {{
const t = a.effect.getComputedTiming();
return (t.endTime - (t.localTime || 0)) / Math.abs(a.playbackRate || 1);
}};
const step = () => {{
const elapsed = performance.now() - started;
const running = document.getAnimations().filter(a =>
a.playState === 'running' && a.effect && isFinite(a.effect.getComputedTiming().endTime));
const left = {budget_ms} - elapsed;
const waitable = running.filter(a => remaining(a) <= left);
if (running.length === 0 || waitable.length === 0 || left <= 0) {{
resolve({{ settled: running.length === 0, waited_ms: Math.round(elapsed), running: running.length }});
return;
}}
Promise.race([
Promise.all(waitable.map(a => a.finished.catch(() => null))),
new Promise(r => setTimeout(r, left))
]).then(() => requestAnimationFrame(() => requestAnimationFrame(step)));
}};
step();
}})"#
);
let params = match EvaluateParams::builder()
.expression(expression)
.await_promise(true)
.return_by_value(true)
.build()
{
Ok(params) => params,
Err(_) => return AnimationSettle::from_value(&serde_json::Value::Null),
};
match tokio::time::timeout(Duration::from_millis(budget_ms + 500), page.execute(params)).await {
Ok(Ok(result)) => {
AnimationSettle::from_value(&result.result.result.value.clone().unwrap_or_default())
}
_ => AnimationSettle::from_value(&serde_json::Value::Null),
}
}
pub async fn wait_for_stable(page: &Page, duration_ms: u64) -> Result<()> {
let js = "new Promise(function(r) { requestAnimationFrame(function() { requestAnimationFrame(r); }); })";
let params = EvaluateParams::builder()
.expression(js.to_string())
.await_promise(true)
.build()
.map_err(|e| AuditError::InteractionFailed {
reason: format!("settle build failed: {e}"),
})?;
let outcome = tokio::time::timeout(
Duration::from_millis(duration_ms + 500),
page.execute(params),
)
.await;
if !matches!(outcome, Ok(Ok(_))) {
tokio::time::sleep(Duration::from_millis(duration_ms)).await;
}
Ok(())
}
pub async fn settle(page: &Page) -> Result<()> {
wait_for_stable(page, DEFAULT_SETTLE_MS).await
}
pub const JOURNEY_SETTLE_BUDGET_MS: u64 = 600;
pub async fn settle_after_action(page: &Page) -> StabilityProvenance {
wait_for_page_stability(page, "journey", JOURNEY_SETTLE_BUDGET_MS).await
}
#[cfg(test)]
mod tests {
use super::AnimationSettle;
use serde_json::json;
#[test]
fn animation_settle_reads_settled_result() {
let settle = AnimationSettle::from_value(
&json!({ "settled": true, "waited_ms": 812, "running": 0 }),
);
assert_eq!(
settle,
AnimationSettle {
settled: true,
waited_ms: 812,
running: 0
}
);
}
#[test]
fn animation_settle_with_running_animations_is_not_settled() {
let settle = AnimationSettle::from_value(
&json!({ "settled": false, "waited_ms": 1500, "running": 2 }),
);
assert!(!settle.settled);
assert_eq!(settle.running, 2);
}
#[test]
fn animation_settle_without_result_is_not_settled() {
assert!(!AnimationSettle::from_value(&serde_json::Value::Null).settled);
}
}