use chromiumoxide::cdp::browser_protocol::emulation::SetScriptExecutionDisabledParams;
use chromiumoxide::Page;
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::browser::BrowserManager;
use crate::error::{AuditError, Result};
pub const SSR_GAP_RATIO_THRESHOLD: f64 = 0.5;
pub const MIN_MEASURABLE_CONTENT_CHARS: usize = 200;
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct SsrContentGap {
pub with_js_content_chars: usize,
pub without_js_content_chars: usize,
pub ratio: f64,
pub has_gap: bool,
}
pub fn compute_ssr_content_gap(with_js_chars: usize, without_js_chars: usize) -> SsrContentGap {
let ratio = if with_js_chars == 0 {
0.0
} else {
without_js_chars as f64 / with_js_chars as f64
};
let has_gap = with_js_chars >= MIN_MEASURABLE_CONTENT_CHARS && ratio < SSR_GAP_RATIO_THRESHOLD;
SsrContentGap {
with_js_content_chars: with_js_chars,
without_js_content_chars: without_js_chars,
ratio,
has_gap,
}
}
const CONTENT_LENGTH_JS: &str = "(document.body ? document.body.innerText : '').trim().length";
async fn read_visible_content_chars(page: &Page) -> Result<usize> {
let result = page
.evaluate(CONTENT_LENGTH_JS)
.await
.map_err(|e| AuditError::CdpError(format!("Visible-content length read failed: {e}")))?;
Ok(result.value().and_then(|v| v.as_u64()).unwrap_or(0) as usize)
}
async fn set_script_execution_disabled(page: &Page, disabled: bool) -> Result<()> {
page.execute(SetScriptExecutionDisabledParams::new(disabled))
.await
.map_err(|e| {
AuditError::CdpError(format!("Emulation.setScriptExecutionDisabled failed: {e}"))
})?;
Ok(())
}
pub async fn measure_ssr_content_gap(
page: &Page,
browser: &BrowserManager,
url: &str,
) -> Option<SsrContentGap> {
let with_js_chars = match read_visible_content_chars(page).await {
Ok(chars) => chars,
Err(e) => {
warn!(
"Failed to read JS-enabled content length for SSR check: {}",
e
);
return None;
}
};
if let Err(e) = set_script_execution_disabled(page, true).await {
warn!("Failed to disable script execution for SSR check: {}", e);
return None;
}
let without_js_chars = match browser.navigate(page, url).await {
Ok(()) => match read_visible_content_chars(page).await {
Ok(chars) => Some(chars),
Err(e) => {
warn!(
"Failed to read JS-disabled content length for SSR check: {}",
e
);
None
}
},
Err(e) => {
warn!("Navigation with JS disabled failed for SSR check: {}", e);
None
}
};
if let Err(e) = set_script_execution_disabled(page, false).await {
warn!(
"Failed to re-enable script execution after SSR check: {}",
e
);
}
without_js_chars.map(|chars| compute_ssr_content_gap(with_js_chars, chars))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn flags_a_large_content_drop_as_a_gap() {
let gap = compute_ssr_content_gap(2000, 200);
assert_eq!(gap.ratio, 0.1);
assert!(gap.has_gap);
}
#[test]
fn does_not_flag_a_small_content_drop() {
let gap = compute_ssr_content_gap(2000, 1600);
assert_eq!(gap.ratio, 0.8);
assert!(!gap.has_gap);
}
#[test]
fn boundary_ratio_just_below_threshold_flags_a_gap() {
let gap = compute_ssr_content_gap(1000, 499);
assert!(gap.ratio < SSR_GAP_RATIO_THRESHOLD);
assert!(gap.has_gap);
}
#[test]
fn boundary_ratio_at_threshold_does_not_flag_a_gap() {
let gap = compute_ssr_content_gap(1000, 500);
assert_eq!(gap.ratio, SSR_GAP_RATIO_THRESHOLD);
assert!(!gap.has_gap);
}
#[test]
fn near_empty_with_js_page_never_flags_a_gap() {
let gap = compute_ssr_content_gap(50, 5);
assert!(!gap.has_gap);
}
#[test]
fn zero_with_js_content_does_not_divide_by_zero() {
let gap = compute_ssr_content_gap(0, 0);
assert_eq!(gap.ratio, 0.0);
assert!(!gap.has_gap);
}
}