auditmysite 1.4.0

Resource-efficient WCAG 2.1 AA (+ select 2.2 criteria) Accessibility Checker in Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
//! Unused JavaScript (#106) and Unused CSS (#107) detection via CDP Coverage API.
//!
//! **Protocol flow:**
//! 1. Before navigation: call `prepare_coverage_collection` → enables the Profiler
//!    and starts precise JS coverage + CSS rule usage tracking.
//! 2. After page load (in `extract_snapshot`): call `take_coverage_results` →
//!    reads back JS and CSS coverage and returns a `CoverageAnalysis`.
//!
//! JS coverage uses `Profiler.startPreciseCoverage` / `Profiler.takePreciseCoverage`
//! (JavaScript protocol).  CSS coverage uses `CSS.startRuleUsageTracking` /
//! `CSS.stopRuleUsageTracking` (browser protocol).
//!
//! **Limitations:**
//! - JS coverage only covers scripts that ran *after* `startPreciseCoverage`.
//!   Parser-executed scripts that ran before navigation started may show 0 % usage.
//! - CSS `stopRuleUsageTracking` returns counts of rules seen and used at the time
//!   of the call.  Rules triggered only on hover/focus/scroll may be reported as
//!   unused in a headless audit.
//! - Data URIs and cross-origin scripts that fail to load are excluded.
//!
//! **Duplicate asset detection (#551):** `take_coverage_results` additionally
//! fetches the full source of already-loaded scripts/stylesheets via
//! `Debugger.getScriptSource` / `CSS.getStyleSheetText` — content already
//! resident in the browser's memory, so this adds no new network request —
//! and hashes it to detect the same file served under 2+ different URLs.
//! Purely advisory: never affects the performance score.

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;

/// Minimum size (bytes) a fetched script/stylesheet source must have to be
/// considered for duplicate-content detection (#551) — mirrors the same
/// threshold `unused_js`'s `ScriptCoverageEntry` filtering already uses, to
/// avoid noise from trivial snippets.
const MIN_DUPLICATE_ASSET_BYTES: usize = 1024;

/// Safety cap on how many `CSS.styleSheetAdded` events are drained per pass
/// — mirrors the "cap the fan-out" philosophy used elsewhere in this codebase
/// (e.g. `design_quality`'s `MAX_SAMPLE_TASKS`) rather than trusting an
/// unbounded page to stay small.
const MAX_DUPLICATE_STYLESHEET_EVENTS: usize = 500;

/// Same cap, for `Debugger.scriptParsed` events (#551) — see
/// `MAX_DUPLICATE_STYLESHEET_EVENTS`.
const MAX_DUPLICATE_SCRIPT_EVENTS: usize = 500;

/// Pre-navigation `CSS.styleSheetAdded` listeners, keyed by CDP target id.
///
/// `prepare_coverage_collection` must register this listener *before*
/// navigation — chromiumoxide's event streams only deliver events emitted
/// after subscription, so subscribing once the page has already loaded (i.e.
/// inside `take_coverage_results`) would silently miss every stylesheet.
/// `take_coverage_results` reads this back (and removes the entry) once per
/// viewport pass to resolve `style_sheet_id -> URL` for duplicate-CSS
/// detection (#551) — `CSS.getStyleSheetText` itself never returns a URL,
/// only `styleSheetAdded` does (see the CSS domain's own PDL doc comment).
/// Routed through a target-id-keyed static instead of threading a new
/// parameter through `ModuleContext`/the `AuditModule` catalog: nothing in
/// that machinery carries state from `prepare_coverage_collection` (called
/// directly in the pipeline, pre-navigation) to `take_coverage_results`
/// (called several layers away, from `PerformanceModule::collect`, with only
/// a `&Page`) — same constraint and same fix shape as `network::dns`'s
/// per-host cache.
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()))
}

/// Pre-navigation `Debugger.scriptParsed` listeners, keyed by CDP target id.
///
/// Same shape and same reason as `STYLESHEET_LISTENERS` above, and for a
/// related but distinct problem: unlike stylesheets (which never carry a URL
/// via `CSS.getStyleSheetText`, so the listener's only job is resolving one),
/// `Profiler.ScriptCoverage` already gives us each script's URL directly.
/// This listener instead confirms *which* script ids the Debugger domain has
/// actually registered a retrievable source for — live testing against a
/// real site showed that calling `Debugger.getScriptSource` for a
/// `Profiler.ScriptCoverage.script_id` fails with "No script for id" even
/// right after `Debugger.enable`, for effectively every script, not just
/// short-lived ones — the enable call's documented "retroactively backfills
/// already-parsed scripts" behavior does not reliably apply synchronously.
/// Draining this listener's buffered `scriptParsed` events before attempting
/// `getScriptSource` (mirroring the stylesheet listener's drain pattern)
/// avoids firing calls we already know will fail.
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()))
}

/// Coverage summary for a single JS script.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScriptCoverageEntry {
    /// Script URL (truncated to 120 chars)
    pub url: String,
    /// Total bytes in the script
    pub total_bytes: u64,
    /// Bytes that were NOT executed during the audit
    pub unused_bytes: u64,
    /// Percentage of the script that was executed (0–100)
    pub used_pct: f64,
}

/// Unused JavaScript analysis (#106).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnusedJsAnalysis {
    /// Per-script breakdown, sorted by unused_bytes descending
    pub scripts: Vec<ScriptCoverageEntry>,
    /// Total bytes across all measured scripts
    pub total_bytes: u64,
    /// Total unused bytes
    pub unused_bytes: u64,
    /// Overall JS usage percentage (0–100)
    pub used_pct: f64,
}

/// Unused CSS analysis (#107).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnusedCssAnalysis {
    /// Total CSS rules observed by the browser
    pub total_rules: u32,
    /// Rules that matched at least one element during the audit
    pub used_rules: u32,
    /// Percentage of rules that were used (0–100), or null when CDP returned no CSS data.
    pub used_pct: Option<f64>,
    /// Measurement state for CSS rule usage data.
    pub measurement: String,
}

/// A group of 2+ distinct URLs serving byte-identical content (#551) —
/// purely advisory/informational, does not affect the performance score.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DuplicateAssetGroup {
    /// "script" or "stylesheet"
    pub kind: String,
    /// 2+ distinct URLs serving byte-identical content
    pub urls: Vec<String>,
    /// Size of one copy (all copies are byte-identical)
    pub bytes: u64,
}

/// Combined coverage analysis returned to the pipeline.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoverageAnalysis {
    /// Unused JavaScript (#106)
    pub unused_js: UnusedJsAnalysis,
    /// Unused CSS (#107)
    pub unused_css: UnusedCssAnalysis,
    /// Coverage-specific measurement warnings.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub measurement_warnings: Vec<String>,
    /// Same file served under 2+ different URLs (#551), sorted by `bytes`
    /// descending (most wasteful first). Purely advisory — never affects the
    /// performance score.
    #[serde(default)]
    pub duplicate_assets: Vec<DuplicateAssetGroup>,
}

/// Enable JS and CSS coverage collection **before** the page navigates.
///
/// Must be called after `prepare_vitals_collection` and before `browser.navigate()`.
/// Errors are non-fatal: the pipeline logs a warning and omits coverage from results.
pub async fn prepare_coverage_collection(page: &Page) -> Result<()> {
    // Enable the JS Profiler domain
    page.execute(ProfilerEnableParams::default())
        .await
        .map_err(|e| AuditError::CdpError(format!("Profiler.enable failed: {e}")))?;

    // Start precise JS coverage (block-level granularity, no call counts needed)
    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}")))?;

    // Start CSS rule usage tracking
    page.execute(StartRuleUsageTrackingParams::default())
        .await
        .map_err(|e| AuditError::CdpError(format!("CSS.startRuleUsageTracking failed: {e}")))?;

    // `CSS.enable` requires the DOM domain to already be enabled (per CDP
    // spec — confirmed live: it otherwise fails with "DOM agent needs to be
    // enabled first"). Nothing else in this codebase issues a raw
    // `DOM.enable` (AXTree extraction goes through chromiumoxide's
    // higher-level `Page` API, which doesn't need it), so this is a new,
    // explicit prerequisite just for the CSS agent. Best-effort: if it
    // fails, `CSS.enable` below will fail too and duplicate-CSS detection
    // is silently skipped.
    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 {
        // Register a `styleSheetAdded` listener *before* navigation, so
        // `take_coverage_results` can later resolve `style_sheet_id -> URL`
        // for duplicate-CSS detection (#551). Best-effort/non-fatal:
        // duplicate CSS detection is silently skipped if this fails.
        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}"),
        }
    }

    // Enable the Debugger domain *before* navigation too (#551), and
    // register a `scriptParsed` listener right after — mirroring the CSS
    // stylesheet listener above. `Debugger.enable`'s documented "retroactively
    // backfills already-parsed scripts" behavior turned out, live, not to make
    // every backfilled script's source reliably retrievable via
    // `Debugger.getScriptSource` right away (confirmed against a real site:
    // "No script for id" for effectively every script, not just short-lived
    // ones). `take_coverage_results` instead drains this listener's buffered
    // events to know which script ids the Debugger domain has actually
    // registered before attempting `getScriptSource`, avoiding calls we
    // already know will fail. Best-effort/non-fatal: duplicate JS detection
    // is silently skipped if either step fails.
    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(())
}

/// Read JS and CSS coverage results **after** the page has loaded.
pub async fn take_coverage_results(page: &Page) -> Result<CoverageAnalysis> {
    info!("Taking JS and CSS coverage results...");

    // Re-issue `Debugger.enable` defensively — `prepare_coverage_collection`
    // already enables it before navigation (see its own comment for why
    // that timing matters), this is just idempotent insurance in case that
    // earlier call didn't run for some reason. Non-fatal: duplicate JS
    // detection is silently skipped if this fails.
    let debugger_enabled = match page.execute(DebuggerEnableParams::default()).await {
        Ok(_) => true,
        Err(e) => {
            warn!("Debugger.enable failed, skipping duplicate JS detection: {e}");
            false
        }
    };

    // Drain the `scriptParsed` listener registered in
    // `prepare_coverage_collection` to find out which script ids the
    // Debugger domain has actually registered a retrievable source for
    // (#551) — see that function's comment for why this gate exists.
    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;
            }
        }
    }

    // ── JS coverage ───────────────────────────────────────────────────────────
    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;
    // Candidates for duplicate-content detection (#551), collected alongside
    // the existing unused-bytes computation so the (already filtered) script
    // list isn't walked a second time.
    let mut js_duplicate_candidates: Vec<(ScriptId, String)> = Vec::new();

    for script in &js_coverage {
        let url = script.url.as_str();

        // Skip internal Chrome scripts, data URIs, and extensions
        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;
        }

        // Determine covered byte ranges from all function ranges
        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;
        }

        // Merge covered ranges and compute used bytes
        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;

        // Only include scripts with meaningful size
        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));

    // Fetch full source text for duplicate-content detection (#551) — these
    // scripts are already resident in the browser's memory, so this is a
    // local CDP round-trip, not a new network download. Only attempt this
    // for script ids `parsed_script_ids` actually confirmed via a
    // `scriptParsed` event — calling `getScriptSource` for an unconfirmed id
    // reliably fails ("No script for id"), so skip those quietly rather than
    // firing (and warning on) calls already known to fail.
    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,
    };

    // ── CSS coverage ──────────────────────────────────────────────────────────
    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,
    };

    // Duplicate CSS detection (#551): resolve style_sheet_id -> URL from the
    // pre-navigation `styleSheetAdded` listener registered in
    // `prepare_coverage_collection`, then fetch each unique stylesheet's text
    // (already resident in the browser's memory, not a new download) to hash it.
    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,
    })
}

/// Groups fetched source texts by content hash, returning one
/// `DuplicateAssetGroup` per hash shared by 2+ distinct URLs (#551).
/// `entries` is `(id, url, text)` — `id` dedupes the same script/stylesheet
/// id appearing more than once in the raw coverage list (e.g. a script that
/// ran more than once) before grouping by URL, so the same URL is never
/// counted twice. Pure/no I/O so it's testable without a CDP connection.
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 {
        // Above MIN_DUPLICATE_ASSET_BYTES so it isn't filtered out as trivial.
        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() {
        // Same script id appearing twice in the raw coverage list (e.g. it ran
        // more than once) must not turn a single URL into a fake 2-URL group.
        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);
    }
}