use chromiumoxide::cdp::browser_protocol::performance::GetMetricsParams;
use chromiumoxide::Page;
use serde::{Deserialize, Serialize};
use tracing::{debug, info, warn};
use crate::error::{AuditError, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClsSource {
pub node: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_rect: Option<ShiftRect>,
#[serde(skip_serializing_if = "Option::is_none")]
pub current_rect: Option<ShiftRect>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShiftRect {
pub top: f64,
pub left: f64,
pub bottom: f64,
pub right: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClsShift {
pub value: f64,
pub start_time_ms: f64,
pub sources: Vec<ClsSource>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WebVitals {
pub lcp: Option<VitalMetric>,
pub fcp: Option<VitalMetric>,
pub cls: Option<VitalMetric>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cls_attribution: Vec<ClsShift>,
pub ttfb: Option<VitalMetric>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fid: Option<VitalMetric>,
pub tbt: Option<VitalMetric>,
pub speed_index: Option<VitalMetric>,
pub tti: Option<VitalMetric>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inp: Option<VitalMetric>,
pub dom_content_loaded: Option<f64>,
pub load_time: Option<f64>,
pub dom_nodes: Option<i64>,
pub js_heap_size: Option<i64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum MeasurementContext {
#[default]
LabHeadless,
EstimatedLab,
LabThrottledMobile,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VitalMetric {
pub value: f64,
pub rating: String,
pub target: f64,
#[serde(default)]
pub measurement: MeasurementContext,
}
impl VitalMetric {
pub fn new(value: f64, good_threshold: f64, poor_threshold: f64) -> Self {
let rating = if value <= good_threshold {
"good"
} else if value <= poor_threshold {
"needs-improvement"
} else {
"poor"
};
Self {
value,
rating: rating.to_string(),
target: good_threshold,
measurement: MeasurementContext::LabHeadless,
}
}
pub fn estimated(mut self) -> Self {
self.measurement = MeasurementContext::EstimatedLab;
self
}
pub fn is_good(&self) -> bool {
self.rating == "good"
}
pub fn is_estimated(&self) -> bool {
self.measurement == MeasurementContext::EstimatedLab
}
pub fn into_throttled_mobile(mut self) -> Self {
if self.measurement == MeasurementContext::LabHeadless {
self.measurement = MeasurementContext::LabThrottledMobile;
}
self
}
}
impl WebVitals {
pub fn good_count(&self) -> usize {
[&self.lcp, &self.fcp, &self.cls, &self.tbt]
.iter()
.filter(|v| v.as_ref().map(|m| m.is_good()).unwrap_or(false))
.count()
}
pub fn available_count(&self) -> usize {
[&self.lcp, &self.fcp, &self.cls, &self.tbt]
.iter()
.filter(|v| v.is_some())
.count()
}
}
pub async fn prepare_vitals_collection(page: &Page) -> Result<()> {
let script = r#"
(function() {
// Load-window boundary: LCP candidates and long tasks are only counted up
// to (load event + 100ms grace), mirroring how Lighthouse bounds these
// metrics to the load phase. A headless audit fires no user-input or
// visibility-change event — the signals that normally stop LCP growth — so
// without this boundary the observers keep absorbing post-load paints
// (carousels, lazy hero swaps) and idle long tasks (analytics, late
// hydration), inflating LCP/TBT/TTI. Until load fires the boundary is
// +Infinity, so everything during loading is counted.
if (typeof window.__ams_load_ts === 'undefined') window.__ams_load_ts = Infinity;
window.addEventListener('load', function() {
window.__ams_load_ts = performance.now() + 100;
}, { once: true });
// LCP observer — keep the latest contentful paint within the load window.
window.__ams_lcp = window.__ams_lcp || 0;
try {
var _lcpObs = new PerformanceObserver(function(list) {
var entries = list.getEntries();
for (var i = 0; i < entries.length; i++) {
// LCP candidates grow monotonically in size; the last one that
// rendered within the load window is the LCP.
if (entries[i].startTime <= window.__ams_load_ts) {
window.__ams_lcp = entries[i].startTime;
}
}
});
_lcpObs.observe({ type: 'largest-contentful-paint', buffered: true });
window.__ams_lcp_obs = _lcpObs;
} catch(e) {}
// TBT + TTI via Long Tasks — sum blocking time; track last task end for TTI
window.__ams_tbt = window.__ams_tbt || 0;
window.__ams_last_task_end = window.__ams_last_task_end || 0;
window.__ams_tbt_seen = window.__ams_tbt_seen || new Set();
try {
new PerformanceObserver(function(list) {
var entries = list.getEntries();
for (var i = 0; i < entries.length; i++) {
var e = entries[i];
if (window.__ams_tbt_seen.has(e.startTime)) continue;
window.__ams_tbt_seen.add(e.startTime);
// Only count long tasks that began within the load window; TBT
// is the blocking time during load (FCP→TTI), not the total
// over the whole time we keep the page open.
if (e.startTime > window.__ams_load_ts) continue;
if (e.duration > 50) {
window.__ams_tbt += e.duration - 50;
}
var end = e.startTime + e.duration;
if (end > window.__ams_last_task_end) {
window.__ams_last_task_end = end;
}
}
}).observe({ type: 'longtask', buffered: true });
} catch(e) {}
// CLS — accumulate total and capture per-shift attribution (#135)
window.__ams_cls = window.__ams_cls || 0;
window.__ams_cls_shifts = window.__ams_cls_shifts || [];
window.__ams_cls_seen = window.__ams_cls_seen || new Set();
try {
new PerformanceObserver(function(list) {
var entries = list.getEntries();
for (var i = 0; i < entries.length; i++) {
var e = entries[i];
if (window.__ams_cls_seen.has(e.startTime)) continue;
window.__ams_cls_seen.add(e.startTime);
if (!e.hadRecentInput) {
window.__ams_cls += e.value;
var sources = [];
try {
if (e.sources) {
for (var j = 0; j < e.sources.length; j++) {
var s = e.sources[j];
var nodeDesc = 'unknown';
try {
if (s.node) {
nodeDesc = s.node.nodeName;
if (s.node.id) nodeDesc += '#' + s.node.id;
else if (s.node.className && typeof s.node.className === 'string') {
var cls = s.node.className.trim().split(/\s+/)[0];
if (cls) nodeDesc += '.' + cls;
}
}
} catch(_) {}
sources.push({
node: nodeDesc,
previousRect: s.previousRect ? {top: s.previousRect.top, left: s.previousRect.left, bottom: s.previousRect.bottom, right: s.previousRect.right} : null,
currentRect: s.currentRect ? {top: s.currentRect.top, left: s.currentRect.left, bottom: s.currentRect.bottom, right: s.currentRect.right} : null
});
}
}
} catch(_) {}
window.__ams_cls_shifts.push({value: e.value, startTime: e.startTime, sources: sources});
}
}
}).observe({ type: 'layout-shift', buffered: true });
} catch(e) {}
// INP via event-timing (#134) — captures real interaction durations
window.__ams_inp = window.__ams_inp || 0;
try {
new PerformanceObserver(function(list) {
var entries = list.getEntries();
for (var i = 0; i < entries.length; i++) {
var e = entries[i];
if (e.duration > window.__ams_inp) {
window.__ams_inp = e.duration;
}
}
}).observe({ type: 'event', durationThreshold: 16, buffered: true });
} catch(e) {}
// Also observe first-input for environments that don't support event-timing
try {
new PerformanceObserver(function(list) {
var entries = list.getEntries();
for (var i = 0; i < entries.length; i++) {
var e = entries[i];
var delay = e.processingStart - e.startTime;
if (delay > window.__ams_inp) window.__ams_inp = delay;
}
}).observe({ type: 'first-input', buffered: true });
} catch(e) {}
})();
"#;
page.evaluate_on_new_document(script)
.await
.map_err(|e| AuditError::CdpError(format!("Failed to inject vitals observer: {}", e)))?;
Ok(())
}
async fn collect_preinjected_vitals(page: &Page) -> Result<PreinjectedVitals> {
let js = r#"
(function() {
// Finalize LCP — flush and disconnect observer
var lcp = 0;
try {
if (window.__ams_lcp_obs) {
var recs = window.__ams_lcp_obs.takeRecords();
var bound = window.__ams_load_ts || Infinity;
for (var i = 0; i < recs.length; i++) {
// Honor the load-window boundary here too, so a buffered
// post-load paint can't override the in-window LCP.
if (recs[i].startTime <= bound) {
window.__ams_lcp = recs[i].startTime;
}
}
window.__ams_lcp_obs.disconnect();
}
lcp = window.__ams_lcp || 0;
} catch(e) {}
return JSON.stringify({
lcp: lcp,
tbt: window.__ams_tbt || 0,
cls: window.__ams_cls || 0,
lastTaskEnd: window.__ams_last_task_end || 0,
clsShifts: window.__ams_cls_shifts || [],
inpDuration: window.__ams_inp || 0
});
})()
"#;
let result = page
.evaluate(js)
.await
.map_err(|e| AuditError::CdpError(format!("Preinjected vitals read failed: {}", e)))?;
let json_str = result.value().and_then(|v| v.as_str()).unwrap_or("{}");
let parsed: serde_json::Value = serde_json::from_str(json_str).unwrap_or_default();
let cls_shifts = parse_cls_shifts(&parsed["clsShifts"]);
Ok(PreinjectedVitals {
lcp: parsed["lcp"].as_f64().unwrap_or(0.0),
tbt: parsed["tbt"].as_f64().unwrap_or(0.0),
cls: parsed["cls"].as_f64().unwrap_or(0.0),
last_task_end: parsed["lastTaskEnd"].as_f64().unwrap_or(0.0),
cls_shifts,
inp_duration: parsed["inpDuration"].as_f64().unwrap_or(0.0),
})
}
fn parse_cls_shifts(val: &serde_json::Value) -> Vec<ClsShift> {
val.as_array()
.map(|arr| {
arr.iter()
.filter_map(|entry| {
let value = entry["value"].as_f64()?;
let start_time_ms = entry["startTime"].as_f64().unwrap_or(0.0);
let sources = entry["sources"]
.as_array()
.map(|srcs| {
srcs.iter()
.map(|s| {
let node = s["node"].as_str().unwrap_or("unknown").to_string();
let previous_rect = parse_shift_rect(&s["previousRect"]);
let current_rect = parse_shift_rect(&s["currentRect"]);
ClsSource {
node,
previous_rect,
current_rect,
}
})
.collect()
})
.unwrap_or_default();
Some(ClsShift {
value,
start_time_ms,
sources,
})
})
.collect()
})
.unwrap_or_default()
}
pub(super) fn session_window_cls(shifts: &[ClsShift]) -> f64 {
let mut ordered: Vec<&ClsShift> = shifts.iter().collect();
ordered.sort_by(|a, b| a.start_time_ms.total_cmp(&b.start_time_ms));
let mut max_window = 0.0_f64;
let mut window_sum = 0.0_f64;
let mut window_start = 0.0_f64;
let mut prev_ts = 0.0_f64;
let mut in_window = false;
for shift in ordered {
let ts = shift.start_time_ms;
if !in_window || ts - prev_ts > 1000.0 || ts - window_start > 5000.0 {
window_sum = shift.value;
window_start = ts;
in_window = true;
} else {
window_sum += shift.value;
}
prev_ts = ts;
if window_sum > max_window {
max_window = window_sum;
}
}
max_window
}
pub fn mark_throttled_mobile(vitals: &mut WebVitals) {
fn tag(slot: &mut Option<VitalMetric>) {
if let Some(metric) = slot.take() {
*slot = Some(metric.into_throttled_mobile());
}
}
tag(&mut vitals.lcp);
tag(&mut vitals.fcp);
tag(&mut vitals.cls);
tag(&mut vitals.tbt);
}
fn parse_shift_rect(val: &serde_json::Value) -> Option<ShiftRect> {
if val.is_null() {
return None;
}
Some(ShiftRect {
top: val["top"].as_f64().unwrap_or(0.0),
left: val["left"].as_f64().unwrap_or(0.0),
bottom: val["bottom"].as_f64().unwrap_or(0.0),
right: val["right"].as_f64().unwrap_or(0.0),
})
}
struct PreinjectedVitals {
lcp: f64,
tbt: f64,
cls: f64,
last_task_end: f64,
cls_shifts: Vec<ClsShift>,
inp_duration: f64,
}
pub async fn finalize_lcp(page: &Page) -> Result<Option<f64>> {
let result = page
.evaluate("window.__ams_lcp || 0")
.await
.map_err(|e| AuditError::CdpError(format!("LCP read failed: {}", e)))?;
let val = result.value().and_then(|v| v.as_f64()).unwrap_or(0.0);
Ok(if val > 0.0 { Some(val) } else { None })
}
async fn wait_for_vitals_stable(page: &Page) {
for _ in 0..11u8 {
let lcp = page
.evaluate("window.__ams_lcp || 0")
.await
.ok()
.and_then(|r| r.value().and_then(|v| v.as_f64()))
.unwrap_or(0.0);
if lcp > 0.0 {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
let mut last_tbt = -1.0_f64;
let mut stable_ticks: u8 = 0;
for _ in 0..25u8 {
let tbt = page
.evaluate("window.__ams_tbt || 0")
.await
.ok()
.and_then(|r| r.value().and_then(|v| v.as_f64()))
.unwrap_or(0.0);
if (tbt - last_tbt).abs() < 1.0 {
stable_ticks += 1;
if stable_ticks >= 3 {
debug!(
"TBT stable at {:.0}ms after {} settle ticks",
tbt, stable_ticks
);
break;
}
} else {
stable_ticks = 0;
}
last_tbt = tbt;
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
}
pub async fn extract_web_vitals(page: &Page) -> Result<WebVitals> {
info!("Extracting Core Web Vitals...");
wait_for_vitals_stable(page).await;
let mut vitals = WebVitals::default();
let preinjected = collect_preinjected_vitals(page).await.unwrap_or_else(|e| {
warn!(
"Pre-injected vitals unavailable ({}), falling back to CDP",
e
);
PreinjectedVitals {
lcp: 0.0,
tbt: 0.0,
cls: 0.0,
last_task_end: 0.0,
cls_shifts: vec![],
inp_duration: 0.0,
}
});
let last_task_end = preinjected.last_task_end;
if preinjected.lcp > 0.0 && preinjected.lcp < 300_000.0 {
vitals.lcp = Some(VitalMetric::new(preinjected.lcp, 2500.0, 4000.0));
debug!("LCP (preinjected): {:.0}ms", preinjected.lcp);
}
vitals.tbt = Some(VitalMetric::new(preinjected.tbt, 200.0, 600.0));
debug!("TBT (preinjected): {:.0}ms", preinjected.tbt);
let cls_session = session_window_cls(&preinjected.cls_shifts);
vitals.cls = Some(VitalMetric::new(cls_session, 0.1, 0.25));
debug!(
"CLS session-window: {:.4} (cumulative was {:.4})",
cls_session, preinjected.cls
);
vitals.cls_attribution = preinjected.cls_shifts;
if preinjected.inp_duration > 0.0 {
vitals.inp = Some(VitalMetric::new(preinjected.inp_duration, 200.0, 500.0).estimated());
debug!("INP (event-timing): {:.0}ms", preinjected.inp_duration);
}
let metrics_response = page
.execute(GetMetricsParams::default())
.await
.map_err(|e| AuditError::CdpError(format!("Failed to get metrics: {}", e)))?;
for metric in &metrics_response.metrics {
let value = metric.value;
match metric.name.as_str() {
"FirstContentfulPaint" => {
let ms = value * 1000.0;
if ms > 0.0 && ms < 300_000.0 {
vitals.fcp = Some(VitalMetric::new(ms, 1800.0, 3000.0));
debug!("FCP (CDP): {:.0}ms", ms);
}
}
"LargestContentfulPaint" if vitals.lcp.is_none() => {
let ms = value * 1000.0;
if ms > 0.0 && ms < 300_000.0 {
vitals.lcp = Some(VitalMetric::new(ms, 2500.0, 4000.0));
debug!("LCP (CDP fallback): {:.0}ms", ms);
}
}
"DomContentLoaded" => {
let ms = value * 1000.0;
if ms > 0.0 && ms < 300_000.0 {
vitals.dom_content_loaded = Some(ms);
}
}
"JSHeapUsedSize" => {
vitals.js_heap_size = Some(value as i64);
}
"Nodes" => {
vitals.dom_nodes = Some(value as i64);
}
_ => {}
}
}
let js_metrics = extract_js_metrics(page).await?;
if vitals.fcp.is_none() && js_metrics.fcp.is_some() {
vitals.fcp = js_metrics.fcp;
}
if vitals.lcp.is_none() && js_metrics.lcp.is_some() {
vitals.lcp = js_metrics.lcp;
}
if js_metrics.ttfb.is_some() {
vitals.ttfb = js_metrics.ttfb;
}
if js_metrics.load_time.is_some() {
vitals.load_time = js_metrics.load_time;
}
if vitals.dom_content_loaded.is_none() {
if let Some(js_dcl) = js_metrics.dom_content_loaded {
if js_dcl > 0.0 && js_dcl < 300_000.0 {
vitals.dom_content_loaded = Some(js_dcl);
}
}
}
let dom_interactive_ms: f64 = page
.evaluate("(function(){ var n = performance.getEntriesByType('navigation')[0]; return n ? n.domInteractive : 0; })()")
.await
.ok()
.and_then(|r| r.value().and_then(|v| v.as_f64()))
.unwrap_or(0.0);
let mut tti_ms = dom_interactive_ms.max(last_task_end);
if let Some(ref lcp_metric) = vitals.lcp {
tti_ms = tti_ms.max(lcp_metric.value);
}
if tti_ms > 0.0 && tti_ms < 300_000.0 {
vitals.tti = Some(VitalMetric::new(tti_ms, 3800.0, 7300.0).estimated());
debug!("TTI (approx): {:.0}ms", tti_ms);
}
if let (Some(fcp), Some(lcp)) = (&vitals.fcp, &vitals.lcp) {
let si = 0.35 * fcp.value + 0.65 * lcp.value;
vitals.speed_index = Some(VitalMetric::new(si, 3400.0, 5800.0).estimated());
debug!("Speed Index (heuristic): {:.0}ms", si);
}
info!(
"Web Vitals extracted: LCP={:?}ms, FCP={:?}ms, CLS={:?}, TTI={:?}ms, INP={:?}ms",
vitals.lcp.as_ref().map(|v| v.value as i64),
vitals.fcp.as_ref().map(|v| v.value as i64),
vitals.cls.as_ref().map(|v| v.value),
vitals.tti.as_ref().map(|v| v.value as i64),
vitals.inp.as_ref().map(|v| v.value as i64),
);
Ok(vitals)
}
async fn extract_js_metrics(page: &Page) -> Result<WebVitals> {
let js_code = r#"
(() => {
const result = {};
// Navigation Timing
const nav = performance.getEntriesByType('navigation')[0];
if (nav) {
result.ttfb = nav.responseStart - nav.requestStart;
if (nav.loadEventEnd > 0) {
result.loadTime = nav.loadEventEnd - nav.startTime;
}
if (nav.domContentLoadedEventEnd > 0) {
result.domContentLoaded = nav.domContentLoadedEventEnd - nav.startTime;
}
}
// Paint Timing
const paints = performance.getEntriesByType('paint');
for (const paint of paints) {
if (paint.name === 'first-contentful-paint') {
result.fcp = paint.startTime;
}
}
// LCP (if available)
const lcpEntries = performance.getEntriesByType('largest-contentful-paint');
if (lcpEntries.length > 0) {
result.lcp = lcpEntries[lcpEntries.length - 1].startTime;
}
return JSON.stringify(result);
})()
"#;
let js_result = page
.evaluate(js_code)
.await
.map_err(|e| AuditError::CdpError(format!("JS metrics extraction failed: {}", e)))?;
let json_str = js_result.value().and_then(|v| v.as_str()).unwrap_or("{}");
let parsed: serde_json::Value = serde_json::from_str(json_str).unwrap_or_default();
let mut vitals = WebVitals::default();
if let Some(lcp) = parsed["lcp"].as_f64() {
vitals.lcp = Some(VitalMetric::new(lcp, 2500.0, 4000.0));
}
if let Some(fcp) = parsed["fcp"].as_f64() {
vitals.fcp = Some(VitalMetric::new(fcp, 1800.0, 3000.0));
}
if let Some(ttfb) = parsed["ttfb"].as_f64() {
vitals.ttfb = Some(VitalMetric::new(ttfb, 800.0, 1800.0));
}
if let Some(load_time) = parsed["loadTime"].as_f64() {
vitals.load_time = Some(load_time);
}
if let Some(dcl) = parsed["domContentLoaded"].as_f64() {
vitals.dom_content_loaded = Some(dcl);
}
Ok(vitals)
}
pub fn validate_metrics(vitals: &WebVitals) -> Vec<String> {
let mut warnings = Vec::new();
if vitals.lcp.is_none() {
warnings.push("lcp_not_measured".to_string());
}
if let (Some(tbt), Some(lcp)) = (&vitals.tbt, &vitals.lcp) {
if tbt.value == 0.0 && lcp.value > 3000.0 {
warnings.push("tbt_zero_heavy_page".to_string());
}
}
if let (Some(si), Some(lcp)) = (&vitals.speed_index, &vitals.lcp) {
if (si.value - lcp.value).abs() < f64::EPSILON {
warnings.push("speed_index_fallback_to_lcp".to_string());
}
}
if let (Some(tti), Some(lcp)) = (&vitals.tti, &vitals.lcp) {
if (tti.value - lcp.value).abs() < f64::EPSILON {
warnings.push("tti_fallback_to_lcp".to_string());
}
}
if vitals.inp.is_none() {
warnings.push("inp_not_measured".to_string());
}
warnings
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_vital_metric_good() {
let metric = VitalMetric::new(1500.0, 2500.0, 4000.0);
assert!(metric.is_good());
assert_eq!(metric.rating, "good");
}
#[test]
fn test_vital_metric_needs_improvement() {
let metric = VitalMetric::new(3000.0, 2500.0, 4000.0);
assert!(!metric.is_good());
assert_eq!(metric.rating, "needs-improvement");
}
#[test]
fn test_vital_metric_poor() {
let metric = VitalMetric::new(5000.0, 2500.0, 4000.0);
assert!(!metric.is_good());
assert_eq!(metric.rating, "poor");
}
#[test]
fn test_measurement_context_defaults_to_lab_headless() {
let metric = VitalMetric::new(1500.0, 2500.0, 4000.0);
assert_eq!(metric.measurement, MeasurementContext::LabHeadless);
assert!(!metric.is_estimated());
let json = serde_json::to_string(&metric).unwrap();
assert!(json.contains("\"measurement\":\"lab_headless\""));
}
#[test]
fn test_estimated_marks_metric_as_estimated_lab() {
let metric = VitalMetric::new(3000.0, 3800.0, 7300.0).estimated();
assert_eq!(metric.measurement, MeasurementContext::EstimatedLab);
assert!(metric.is_estimated());
let json = serde_json::to_string(&metric).unwrap();
assert!(json.contains("\"measurement\":\"estimated_lab\""));
}
#[test]
fn test_web_vitals_good_count() {
let vitals = WebVitals {
lcp: Some(VitalMetric::new(2000.0, 2500.0, 4000.0)),
fcp: Some(VitalMetric::new(1500.0, 1800.0, 3000.0)),
cls: Some(VitalMetric::new(0.05, 0.1, 0.25)),
..Default::default()
};
assert_eq!(vitals.good_count(), 3);
assert_eq!(vitals.available_count(), 3);
}
fn shift(value: f64, start_time_ms: f64) -> ClsShift {
ClsShift {
value,
start_time_ms,
sources: vec![],
}
}
#[test]
fn session_window_cls_matches_cwv_definition() {
assert_eq!(session_window_cls(&[]), 0.0);
let one_window = [shift(0.1, 500.0), shift(0.2, 1000.0), shift(0.05, 1500.0)];
assert!((session_window_cls(&one_window) - 0.35).abs() < 1e-9);
let two_windows = [
shift(0.1, 0.0),
shift(0.1, 500.0),
shift(0.4, 3000.0), ];
assert!((session_window_cls(&two_windows) - 0.4).abs() < 1e-9);
let long_run: Vec<ClsShift> = (0..10).map(|i| shift(0.1, i as f64 * 800.0)).collect();
let cls = session_window_cls(&long_run);
assert!(cls < 1.0, "expected windowed value <1.0, got {cls}");
assert!(cls >= 0.7 - 1e-9, "first window should sum ~0.7, got {cls}");
}
#[test]
fn mark_throttled_mobile_tags_direct_metrics_only() {
let mut v = WebVitals {
lcp: Some(VitalMetric::new(3800.0, 2500.0, 4000.0)),
cls: Some(VitalMetric::new(0.05, 0.1, 0.25)),
tbt: Some(VitalMetric::new(450.0, 200.0, 600.0)),
speed_index: Some(VitalMetric::new(4200.0, 3400.0, 5800.0).estimated()),
..Default::default()
};
mark_throttled_mobile(&mut v);
assert_eq!(
v.lcp.unwrap().measurement,
MeasurementContext::LabThrottledMobile
);
assert_eq!(
v.cls.unwrap().measurement,
MeasurementContext::LabThrottledMobile
);
assert_eq!(
v.tbt.unwrap().measurement,
MeasurementContext::LabThrottledMobile
);
assert_eq!(
v.speed_index.unwrap().measurement,
MeasurementContext::EstimatedLab
);
}
#[test]
fn validate_metrics_tbt_zero_heavy_page() {
let vitals = WebVitals {
tbt: Some(VitalMetric::new(0.0, 200.0, 600.0)),
lcp: Some(VitalMetric::new(4500.0, 2500.0, 4000.0)),
..Default::default()
};
let warnings = validate_metrics(&vitals);
assert!(
warnings.contains(&"tbt_zero_heavy_page".to_string()),
"must warn when TBT=0 and LCP>3000ms"
);
assert!(
warnings.contains(&"inp_not_measured".to_string()),
"inp_not_measured always present in headless"
);
}
#[test]
fn validate_metrics_speed_index_fallback() {
let lcp_val = 3200.0;
let vitals = WebVitals {
lcp: Some(VitalMetric::new(lcp_val, 2500.0, 4000.0)),
speed_index: Some(VitalMetric::new(lcp_val, 3800.0, 5800.0).estimated()),
..Default::default()
};
let warnings = validate_metrics(&vitals);
assert!(
warnings.contains(&"speed_index_fallback_to_lcp".to_string()),
"must warn when SpeedIndex == LCP"
);
}
#[test]
fn validate_metrics_no_warnings_on_good_data() {
let vitals = WebVitals {
tbt: Some(VitalMetric::new(120.0, 200.0, 600.0)),
lcp: Some(VitalMetric::new(2000.0, 2500.0, 4000.0)),
speed_index: Some(VitalMetric::new(1800.0, 3800.0, 5800.0).estimated()),
tti: Some(VitalMetric::new(3500.0, 3800.0, 7300.0).estimated()),
inp: Some(VitalMetric::new(150.0, 200.0, 500.0).estimated()),
..Default::default()
};
let warnings = validate_metrics(&vitals);
assert!(
!warnings.contains(&"tbt_zero_heavy_page".to_string()),
"no tbt_zero_heavy_page when TBT>0"
);
assert!(
!warnings.contains(&"speed_index_fallback_to_lcp".to_string()),
"no speed_index_fallback when SI != LCP"
);
assert!(
!warnings.contains(&"inp_not_measured".to_string()),
"no inp_not_measured when INP is present"
);
}
#[test]
fn validate_metrics_inp_always_warned_in_headless() {
let vitals = WebVitals::default(); let warnings = validate_metrics(&vitals);
assert!(
warnings.contains(&"inp_not_measured".to_string()),
"inp_not_measured must be present when inp is None"
);
}
#[test]
fn validate_metrics_lcp_not_measured_when_absent() {
let vitals = WebVitals::default(); let warnings = validate_metrics(&vitals);
assert!(
warnings.contains(&"lcp_not_measured".to_string()),
"lcp_not_measured must be present when lcp is None (#285)"
);
}
#[test]
fn validate_metrics_no_lcp_warning_when_lcp_present() {
let vitals = WebVitals {
lcp: Some(VitalMetric::new(2000.0, 2500.0, 4000.0)),
..Default::default()
};
let warnings = validate_metrics(&vitals);
assert!(
!warnings.contains(&"lcp_not_measured".to_string()),
"no lcp_not_measured when lcp is present"
);
}
}