use std::collections::{HashMap, HashSet};
use std::sync::OnceLock;
use chromiumoxide::cdp::browser_protocol::css::{
EnableParams as CssEnableParams, EventStyleSheetAdded, GetStyleSheetTextParams,
StartRuleUsageTrackingParams, StopRuleUsageTrackingParams, StyleSheetId,
};
use chromiumoxide::cdp::browser_protocol::dom::EnableParams as DomEnableParams;
use chromiumoxide::cdp::browser_protocol::target::TargetId;
use chromiumoxide::cdp::js_protocol::debugger::{
EnableParams as DebuggerEnableParams, EventScriptParsed, GetScriptSourceParams,
};
use chromiumoxide::cdp::js_protocol::profiler::{
EnableParams as ProfilerEnableParams, StartPreciseCoverageParams, TakePreciseCoverageParams,
};
use chromiumoxide::cdp::js_protocol::runtime::ScriptId;
use chromiumoxide::listeners::EventStream;
use chromiumoxide::Page;
use futures::{FutureExt, StreamExt};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
use tracing::{info, warn};
use crate::error::{AuditError, Result};
use crate::util::truncate_ellipsis as truncate;
const MIN_DUPLICATE_ASSET_BYTES: usize = 1024;
const MAX_DUPLICATE_STYLESHEET_EVENTS: usize = 500;
const MAX_DUPLICATE_SCRIPT_EVENTS: usize = 500;
static STYLESHEET_LISTENERS: OnceLock<Mutex<HashMap<TargetId, EventStream<EventStyleSheetAdded>>>> =
OnceLock::new();
fn stylesheet_listeners() -> &'static Mutex<HashMap<TargetId, EventStream<EventStyleSheetAdded>>> {
STYLESHEET_LISTENERS.get_or_init(|| Mutex::new(HashMap::new()))
}
static SCRIPT_PARSED_LISTENERS: OnceLock<Mutex<HashMap<TargetId, EventStream<EventScriptParsed>>>> =
OnceLock::new();
fn script_parsed_listeners() -> &'static Mutex<HashMap<TargetId, EventStream<EventScriptParsed>>> {
SCRIPT_PARSED_LISTENERS.get_or_init(|| Mutex::new(HashMap::new()))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScriptCoverageEntry {
pub url: String,
pub total_bytes: u64,
pub unused_bytes: u64,
pub used_pct: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnusedJsAnalysis {
pub scripts: Vec<ScriptCoverageEntry>,
pub total_bytes: u64,
pub unused_bytes: u64,
pub used_pct: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnusedCssAnalysis {
pub total_rules: u32,
pub used_rules: u32,
pub used_pct: Option<f64>,
pub measurement: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DuplicateAssetGroup {
pub kind: String,
pub urls: Vec<String>,
pub bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoverageAnalysis {
pub unused_js: UnusedJsAnalysis,
pub unused_css: UnusedCssAnalysis,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub measurement_warnings: Vec<String>,
#[serde(default)]
pub duplicate_assets: Vec<DuplicateAssetGroup>,
}
pub async fn prepare_coverage_collection(page: &Page) -> Result<()> {
page.execute(ProfilerEnableParams::default())
.await
.map_err(|e| AuditError::CdpError(format!("Profiler.enable failed: {e}")))?;
page.execute(
StartPreciseCoverageParams::builder()
.call_count(false)
.detailed(true)
.allow_triggered_updates(false)
.build(),
)
.await
.map_err(|e| AuditError::CdpError(format!("Profiler.startPreciseCoverage failed: {e}")))?;
page.execute(StartRuleUsageTrackingParams::default())
.await
.map_err(|e| AuditError::CdpError(format!("CSS.startRuleUsageTracking failed: {e}")))?;
if let Err(e) = page.execute(DomEnableParams::default()).await {
warn!("DOM.enable failed, skipping duplicate CSS detection: {e}");
} else if let Err(e) = page.execute(CssEnableParams::default()).await {
warn!("CSS.enable failed, skipping duplicate CSS detection: {e}");
} else {
match page.event_listener::<EventStyleSheetAdded>().await {
Ok(stream) => {
stylesheet_listeners()
.lock()
.await
.insert(page.target_id().clone(), stream);
}
Err(e) => warn!("CSS.styleSheetAdded listener setup failed: {e}"),
}
}
match page.execute(DebuggerEnableParams::default()).await {
Ok(_) => match page.event_listener::<EventScriptParsed>().await {
Ok(stream) => {
script_parsed_listeners()
.lock()
.await
.insert(page.target_id().clone(), stream);
}
Err(e) => warn!("Debugger.scriptParsed listener setup failed: {e}"),
},
Err(e) => warn!("Debugger.enable failed, skipping duplicate JS detection: {e}"),
}
Ok(())
}
pub async fn take_coverage_results(page: &Page) -> Result<CoverageAnalysis> {
info!("Taking JS and CSS coverage results...");
let debugger_enabled = match page.execute(DebuggerEnableParams::default()).await {
Ok(_) => true,
Err(e) => {
warn!("Debugger.enable failed, skipping duplicate JS detection: {e}");
false
}
};
let mut parsed_script_ids: HashSet<ScriptId> = HashSet::new();
if let Some(mut stream) = script_parsed_listeners()
.lock()
.await
.remove(page.target_id())
{
while let Some(Some(event)) = stream.next().now_or_never() {
parsed_script_ids.insert(event.script_id.clone());
if parsed_script_ids.len() >= MAX_DUPLICATE_SCRIPT_EVENTS {
break;
}
}
}
let js_coverage = match page.execute(TakePreciseCoverageParams::default()).await {
Ok(resp) => resp.result.result,
Err(e) => {
warn!("Profiler.takePreciseCoverage failed: {e}");
vec![]
}
};
let mut scripts: Vec<ScriptCoverageEntry> = Vec::new();
let mut total_bytes: u64 = 0;
let mut total_unused_bytes: u64 = 0;
let mut js_duplicate_candidates: Vec<(ScriptId, String)> = Vec::new();
for script in &js_coverage {
let url = script.url.as_str();
if url.is_empty()
|| url.starts_with("chrome-extension://")
|| url.starts_with("data:")
|| url.contains("extensions::")
{
continue;
}
let ranges = &script.functions;
if ranges.is_empty() {
continue;
}
let mut covered_ranges: Vec<(i64, i64)> = Vec::new();
let mut script_end: i64 = 0;
for func in ranges {
for range in &func.ranges {
if range.count > 0 {
covered_ranges.push((range.start_offset, range.end_offset));
}
if range.end_offset > script_end {
script_end = range.end_offset;
}
}
}
if script_end <= 0 {
continue;
}
covered_ranges.sort_by_key(|r| r.0);
let mut used_bytes: i64 = 0;
let mut cursor = 0i64;
for (start, end) in covered_ranges {
let start = start.max(cursor);
if end > start {
used_bytes += end - start;
cursor = end;
}
}
let script_bytes = script_end as u64;
let unused = script_bytes.saturating_sub(used_bytes as u64);
let used_pct = if script_bytes > 0 {
(used_bytes as f64 / script_bytes as f64 * 100.0).clamp(0.0, 100.0)
} else {
0.0
};
total_bytes += script_bytes;
total_unused_bytes += unused;
if script_bytes >= 1024 {
scripts.push(ScriptCoverageEntry {
url: truncate(url, 120),
total_bytes: script_bytes,
unused_bytes: unused,
used_pct,
});
js_duplicate_candidates.push((script.script_id.clone(), url.to_string()));
}
}
scripts.sort_by_key(|s| std::cmp::Reverse(s.unused_bytes));
let mut js_duplicate_entries: Vec<(String, String, String)> = Vec::new();
if debugger_enabled {
for (script_id, url) in &js_duplicate_candidates {
if !parsed_script_ids.contains(script_id) {
continue;
}
match page
.execute(GetScriptSourceParams::new(script_id.clone()))
.await
{
Ok(resp) => js_duplicate_entries.push((
script_id.inner().clone(),
url.clone(),
resp.result.script_source,
)),
Err(e) => {
tracing::debug!("Debugger.getScriptSource failed for {url}: {e}")
}
}
}
}
let overall_used_pct = if total_bytes > 0 {
((total_bytes - total_unused_bytes) as f64 / total_bytes as f64 * 100.0).clamp(0.0, 100.0)
} else {
100.0
};
let unused_js = UnusedJsAnalysis {
scripts,
total_bytes,
unused_bytes: total_unused_bytes,
used_pct: overall_used_pct,
};
let css_coverage = match page.execute(StopRuleUsageTrackingParams::default()).await {
Ok(resp) => resp.result.rule_usage,
Err(e) => {
warn!("CSS.stopRuleUsageTracking failed: {e}");
vec![]
}
};
let total_rules = css_coverage.len() as u32;
let used_rules = css_coverage.iter().filter(|r| r.used).count() as u32;
let (css_used_pct, css_measurement, measurement_warnings) = if total_rules > 0 {
(
Some((used_rules as f64 / total_rules as f64 * 100.0).clamp(0.0, 100.0)),
"measured".to_string(),
vec![],
)
} else {
(
None,
"not_available".to_string(),
vec!["css_coverage_unavailable".to_string()],
)
};
let unused_css = UnusedCssAnalysis {
total_rules,
used_rules,
used_pct: css_used_pct,
measurement: css_measurement,
};
let mut css_duplicate_entries: Vec<(String, String, String)> = Vec::new();
if let Some(mut stream) = stylesheet_listeners().lock().await.remove(page.target_id()) {
let mut stylesheet_urls: HashMap<StyleSheetId, String> = HashMap::new();
while let Some(Some(event)) = stream.next().now_or_never() {
let url = event.header.source_url.clone();
if !url.is_empty()
&& !url.starts_with("chrome-extension://")
&& !url.starts_with("data:")
{
stylesheet_urls
.entry(event.header.style_sheet_id.clone())
.or_insert(url);
}
if stylesheet_urls.len() >= MAX_DUPLICATE_STYLESHEET_EVENTS {
break;
}
}
if !stylesheet_urls.is_empty() {
let mut seen_ids: HashSet<StyleSheetId> = HashSet::new();
for rule in &css_coverage {
let style_sheet_id = rule.style_sheet_id.clone();
if !seen_ids.insert(style_sheet_id.clone()) {
continue;
}
let Some(url) = stylesheet_urls.get(&style_sheet_id) else {
continue;
};
match page
.execute(GetStyleSheetTextParams::new(style_sheet_id.clone()))
.await
{
Ok(resp) => css_duplicate_entries.push((
style_sheet_id.inner().clone(),
url.clone(),
resp.result.text,
)),
Err(e) => warn!("CSS.getStyleSheetText failed for {url}: {e}"),
}
}
}
}
let mut duplicate_assets = group_duplicate_assets("script", js_duplicate_entries);
duplicate_assets.extend(group_duplicate_assets("stylesheet", css_duplicate_entries));
duplicate_assets.sort_by_key(|g| std::cmp::Reverse(g.bytes));
info!(
"Coverage: JS {:.0}% used ({:.1} KB unused), CSS {} rules used ({}/{})",
overall_used_pct,
total_unused_bytes as f64 / 1024.0,
css_used_pct
.map(|v| format!("{v:.0}%"))
.unwrap_or_else(|| "not available".to_string()),
used_rules,
total_rules,
);
Ok(CoverageAnalysis {
unused_js,
unused_css,
measurement_warnings,
duplicate_assets,
})
}
fn group_duplicate_assets(
kind: &str,
entries: Vec<(String, String, String)>,
) -> Vec<DuplicateAssetGroup> {
let mut by_id: HashMap<String, (String, String)> = HashMap::new();
for (id, url, text) in entries {
by_id.entry(id).or_insert((url, text));
}
let mut by_url: HashMap<String, String> = HashMap::new();
for (url, text) in by_id.into_values() {
by_url.entry(url).or_insert(text);
}
let mut groups: HashMap<String, (Vec<String>, u64)> = HashMap::new();
for (url, text) in by_url {
if text.len() < MIN_DUPLICATE_ASSET_BYTES {
continue;
}
let hash = sha256_hex(&text);
let group = groups
.entry(hash)
.or_insert_with(|| (Vec::new(), text.len() as u64));
group.0.push(url);
}
let mut result: Vec<DuplicateAssetGroup> = groups
.into_values()
.filter(|(urls, _)| urls.len() >= 2)
.map(|(mut urls, bytes)| {
urls.sort();
DuplicateAssetGroup {
kind: kind.to_string(),
urls,
bytes,
}
})
.collect();
result.sort_by_key(|g| std::cmp::Reverse(g.bytes));
result
}
fn sha256_hex(text: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(text.as_bytes());
hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_unused_js_zero_bytes() {
let analysis = UnusedJsAnalysis {
scripts: vec![],
total_bytes: 0,
unused_bytes: 0,
used_pct: 100.0,
};
assert_eq!(analysis.used_pct, 100.0);
assert!(analysis.scripts.is_empty());
}
#[test]
fn test_unused_css_all_used() {
let analysis = UnusedCssAnalysis {
total_rules: 50,
used_rules: 50,
used_pct: Some(100.0),
measurement: "measured".to_string(),
};
assert_eq!(analysis.used_pct, Some(100.0));
assert_eq!(analysis.total_rules, analysis.used_rules);
}
#[test]
fn test_unused_css_unavailable_is_not_zero_percent() {
let analysis = UnusedCssAnalysis {
total_rules: 0,
used_rules: 0,
used_pct: None,
measurement: "not_available".to_string(),
};
assert_eq!(analysis.used_pct, None);
assert_eq!(analysis.measurement, "not_available");
}
#[test]
fn test_truncate() {
let long = "a".repeat(200);
let result = truncate(&long, 120);
assert!(result.len() <= 123);
}
fn big_text(marker: &str) -> String {
format!("{marker}{}", "x".repeat(MIN_DUPLICATE_ASSET_BYTES))
}
#[test]
fn test_group_duplicate_assets_finds_two_urls_same_content() {
let text = big_text("shared");
let entries = vec![
(
"id-1".to_string(),
"https://a.example/app.js".to_string(),
text.clone(),
),
(
"id-2".to_string(),
"https://a.example/vendor/app.js".to_string(),
text.clone(),
),
];
let groups = group_duplicate_assets("script", entries);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].kind, "script");
assert_eq!(groups[0].bytes, text.len() as u64);
assert_eq!(
groups[0].urls,
vec![
"https://a.example/app.js".to_string(),
"https://a.example/vendor/app.js".to_string(),
]
);
}
#[test]
fn test_group_duplicate_assets_single_url_is_not_a_duplicate() {
let entries = vec![(
"id-1".to_string(),
"https://a.example/app.js".to_string(),
big_text("solo"),
)];
assert!(group_duplicate_assets("script", entries).is_empty());
}
#[test]
fn test_group_duplicate_assets_different_content_is_not_grouped() {
let entries = vec![
(
"id-1".to_string(),
"https://a.example/a.js".to_string(),
big_text("one"),
),
(
"id-2".to_string(),
"https://a.example/b.js".to_string(),
big_text("two"),
),
];
assert!(group_duplicate_assets("script", entries).is_empty());
}
#[test]
fn test_group_duplicate_assets_below_size_threshold_is_ignored() {
let text = "tiny".to_string();
let entries = vec![
(
"id-1".to_string(),
"https://a.example/a.css".to_string(),
text.clone(),
),
(
"id-2".to_string(),
"https://a.example/b.css".to_string(),
text,
),
];
assert!(group_duplicate_assets("stylesheet", entries).is_empty());
}
#[test]
fn test_group_duplicate_assets_same_id_repeated_counts_once() {
let text = big_text("repeat");
let entries = vec![
(
"id-1".to_string(),
"https://a.example/app.js".to_string(),
text.clone(),
),
(
"id-1".to_string(),
"https://a.example/app.js".to_string(),
text,
),
];
assert!(group_duplicate_assets("script", entries).is_empty());
}
#[test]
fn test_group_duplicate_assets_sorted_by_bytes_descending() {
let small = big_text("small");
let large = format!("{small}{}", "y".repeat(2048));
let entries = vec![
(
"id-1".to_string(),
"https://a.example/small-a.js".to_string(),
small.clone(),
),
(
"id-2".to_string(),
"https://a.example/small-b.js".to_string(),
small,
),
(
"id-3".to_string(),
"https://a.example/large-a.js".to_string(),
large.clone(),
),
(
"id-4".to_string(),
"https://a.example/large-b.js".to_string(),
large,
),
];
let groups = group_duplicate_assets("script", entries);
assert_eq!(groups.len(), 2);
assert!(groups[0].bytes > groups[1].bytes);
}
}