From 622b749ea6dc5e4f1bfbc7332d4fd8cfdb61eb4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn=20Seidel?= Date: Mon, 26 Oct 2026 20:37:28 +0100 Subject: [PATCH] fix(performance)!: reference measurement windows and per-metric collection state (#769, #770) LCP keeps the last candidate, also after load; TTI is the end of the last long task before the first 5 s quiet window after FCP; TBT sums long-task blocking over FCP->TTI from the raw task list. The tbt_zero_heavy_page warning is gone: a static page on a slow network has no long tasks. Observers, CDP metrics, navigation timing, the four core metrics, every sub-analysis, JS/CSS coverage and third-party isolation record their state. A failed read is no 0-byte page, no clean resource list and no 100 % JS used. Throttled profiles need CPU throttling, a cold cache and all four core metrics for a score and for canonical adoption. --- CHANGELOG.md | 4 + locales/de/report.ftl | 2 +- locales/en/report.ftl | 2 +- src/audit/collection.rs | 108 +++ src/audit/pipeline/assembly.rs | 87 --- src/audit/pipeline/mod.rs | 27 +- src/audit/pipeline/throttled.rs | 75 +- src/audit/report.rs | 10 +- src/output/builder/batch.rs | 2 +- .../single/module_details/performance.rs | 2 +- src/output/json.rs | 14 +- src/output/pdf/detail_modules/performance.rs | 20 +- src/output/pdf/tests.rs | 6 +- src/output/report_model.rs | 3 +- src/output/tests/report_invariants.rs | 6 +- src/performance/animations.rs | 26 +- src/performance/content_weight.rs | 27 +- src/performance/coverage.rs | 121 +++- src/performance/critical_chain.rs | 26 +- src/performance/minification.rs | 26 +- src/performance/mod.rs | 16 +- src/performance/module.rs | 98 +-- src/performance/render_blocking.rs | 29 +- src/performance/third_party.rs | 87 ++- src/performance/vitals.rs | 654 ++++++++++++------ src/util.rs | 59 -- tests/integration_test.rs | 139 +++- tests/report_consistency_tests.rs | 2 +- 28 files changed, 1112 insertions(+), 566 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a748526..ffa1feb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ and verification for each change is in the ### Changed +- **Performance:** Each core metric (`performance.lcp`, `fcp`, `cls`, `tbt`) and sub-analysis reports its collection state; a throttled profile without all four core metrics has no score (`score: null`) and is not adopted as the canonical measurement. +- **Performance:** LCP is the last candidate before the read, also one painted after `load`; TBT counts long tasks over FCP→TTI (Lighthouse window) instead of up to load + 100 ms. TTI is the end of the last long task before the first 5 s quiet window. - **Scoring:** A module that lost a collection its score depends on is shown as partial and no longer counts toward the overall score; derived indicators are not computed over a failed module. - **Scoring:** The overall score always uses the published module weights, including dual-viewport audits. Incomplete performance measurements and unchecked HTML conformance do not contribute; estimated Speed Index remains diagnostic. Principle coverage uses execution records instead of assuming every catalogued criterion passed. - **Security:** Security-only audits also collect the JavaScript library checks. The known Handlebars advisory range includes versions below 4.7.7 and describes conditional exploitability. @@ -42,11 +44,13 @@ and verification for each change is in the ### Removed +- **JSON:** The `tbt_zero_heavy_page` measurement warning. A static page on a slow network has a late LCP and no long tasks; TBT 0 is a valid measurement there. - **Library:** Breaking: the unfinished `audit::Baseline` API (`Baseline`, `BaselineDiff`, `BaselineViolation`, `WaivedViolation`). It was not wired into the CLI and identified findings by criterion and AX node id; a baseline comparison will build on the stable finding and pattern ids. - **JSON:** Breaking: `summary.template_clusters`, `summary.top_recurring_rules` and `internal_comparison.root_causes`. Recurring element patterns are `summary.root_causes` with their evidence grade; the former lists either duplicated it or called anything on two pages a "likely template or component". ### Fixed +- **Performance:** A failed resource or coverage read no longer counts as 0 bytes, no blocking resources or 100 % JS used (`unused_js.used_pct` is `null` without measured bytes); a throttled pass whose CPU throttling or cache disabling failed is not stored as that profile. - **Collection:** A request or script that did not answer no longer becomes a finding (e.g. "HTTP/1.1 only", "www not consolidated", "invalid manifest", "broken link") or a clean result (e.g. "no cookies", "no console errors", 100 image-efficiency points). - **Content visibility:** The SSR content-gap check loads the page itself instead of measuring the blank tab left after interactive journeys. - **Accessibility:** Text spacing, reflow, page title, on-input and orientation report a technical rule failure instead of passing when the page gives no answer. diff --git a/locales/de/report.ftl b/locales/de/report.ftl index fd275f8a..08cc1c8e 100644 --- a/locales/de/report.ftl +++ b/locales/de/report.ftl @@ -346,7 +346,6 @@ perf-lab-data-body = LCP, CLS und TBT werden lokal in Headless-Chrome unter eine perf-render-blocking-analysis = Render-Blocking Analyse perf-measurement-warnings-title = Messtechnische Einschränkungen perf-warning-lcp-missing = LCP nicht erfasst: Der PerformanceObserver hat keinen LCP-Eintrag geliefert (typisch bei starker Netzwerkdrosselung) — LCP-Score fehlt in der Bewertung. -perf-warning-tbt-zero = TBT = 0 ms bei schwerer Seite (LCP > 3 s): Headless-Chrome registriert keine Long Tasks — TBT ist wahrscheinlich unterschätzt. perf-warning-si-fallback = Speed Index entspricht LCP: Formelschätzung ohne unabhängigen Messwert (0,35 × FCP + 0,65 × LCP). perf-warning-tti-fallback = TTI entspricht LCP: Formelschätzung ohne direkten Interaktivitätswert. perf-warning-inp-missing = INP nicht messbar: Headless-Browser erzeugen keine Nutzereingaben — Interaktionslatenz konnte nicht erfasst werden. @@ -597,6 +596,7 @@ pdf-perf-sub-resources = Ressourcen & Datenmenge pdf-perf-sub-bottlenecks = Lade-Engpässe & Rendering pdf-perf-throttled-title = Performance unter gedrosselten Bedingungen pdf-perf-throttled-profile = Profil +pdf-perf-throttled-incomplete = Unvollständig: nicht alle Kernmetriken gemessen pdf-perf-cls-title = CLS — Layout-Verschiebungen pdf-perf-cls-value = Wert pdf-perf-cls-time = Zeitpunkt diff --git a/locales/en/report.ftl b/locales/en/report.ftl index 7f6b6cf3..256af0ad 100644 --- a/locales/en/report.ftl +++ b/locales/en/report.ftl @@ -346,7 +346,6 @@ perf-lab-data-body = LCP, CLS and TBT are measured locally in headless Chrome un perf-render-blocking-analysis = Render-blocking analysis perf-measurement-warnings-title = Measurement limitations perf-warning-lcp-missing = LCP not captured: the PerformanceObserver returned no LCP entry (typical under heavy network throttling) — LCP score is absent from the rating. -perf-warning-tbt-zero = TBT = 0 ms on a heavy page (LCP > 3 s): headless Chrome does not register long tasks — TBT is likely underestimated. perf-warning-si-fallback = Speed Index equals LCP: formula estimate without an independent measurement (0.35 × FCP + 0.65 × LCP). perf-warning-tti-fallback = TTI equals LCP: formula estimate without a direct interactivity value. perf-warning-inp-missing = INP not measurable: headless browsers generate no user input — interaction latency could not be captured. @@ -597,6 +596,7 @@ pdf-perf-sub-resources = Resources & Payload Size pdf-perf-sub-bottlenecks = Loading Bottlenecks & Rendering pdf-perf-throttled-title = Performance under throttled conditions pdf-perf-throttled-profile = Profile +pdf-perf-throttled-incomplete = Incomplete: not all core metrics measured pdf-perf-cls-title = CLS — Layout Shifts pdf-perf-cls-value = Shift pdf-perf-cls-time = Time diff --git a/src/audit/collection.rs b/src/audit/collection.rs index 58b44b2e..114d0dee 100644 --- a/src/audit/collection.rs +++ b/src/audit/collection.rs @@ -413,6 +413,22 @@ pub static COLLECTORS: &[&CollectorSpec] = &[ &crate::best_practices::console_errors::CONSOLE, &crate::best_practices::vulnerable_libs::LIBRARIES, &crate::design_quality::CANDIDATES, + &crate::performance::vitals::OBSERVERS, + &crate::performance::vitals::CDP_METRICS, + &crate::performance::vitals::NAVIGATION_TIMING, + &crate::performance::vitals::LCP, + &crate::performance::vitals::FCP, + &crate::performance::vitals::CLS, + &crate::performance::vitals::TBT, + &crate::performance::module::CONTENT_WEIGHT, + &crate::performance::module::RENDER_BLOCKING, + &crate::performance::module::THIRD_PARTY, + &crate::performance::module::CRITICAL_CHAIN, + &crate::performance::module::MINIFICATION, + &crate::performance::module::ANIMATIONS, + &crate::performance::coverage::JS_COVERAGE, + &crate::performance::coverage::CSS_COVERAGE, + &crate::performance::third_party::ISOLATION, &crate::dark_mode::STATIC_SUPPORT, &crate::dark_mode::PRINT_STYLESHEET, &crate::dark_mode::PRINT_LAYOUT, @@ -627,6 +643,58 @@ mod tests { "design_quality.candidates", crate::design_quality::counter_cases, ), + ( + "performance.observers", + crate::performance::vitals::counter_cases, + ), + ( + "performance.cdp_metrics", + crate::performance::vitals::counter_cases, + ), + ( + "performance.navigation_timing", + crate::performance::vitals::counter_cases, + ), + ("performance.lcp", crate::performance::vitals::counter_cases), + ("performance.fcp", crate::performance::vitals::counter_cases), + ("performance.cls", crate::performance::vitals::counter_cases), + ("performance.tbt", crate::performance::vitals::counter_cases), + ( + "performance.content_weight", + crate::performance::content_weight::counter_cases, + ), + ( + "performance.render_blocking", + crate::performance::render_blocking::counter_cases, + ), + ( + "performance.third_party", + crate::performance::third_party::counter_cases, + ), + ( + "performance.critical_chain", + crate::performance::critical_chain::counter_cases, + ), + ( + "performance.minification", + crate::performance::minification::counter_cases, + ), + ( + "performance.animations", + crate::performance::animations::counter_cases, + ), + ( + "performance.js_coverage", + crate::performance::coverage::counter_cases, + ), + ( + "performance.css_coverage", + crate::performance::coverage::counter_cases, + ), + ( + "performance.third_party_isolation", + crate::performance::third_party::counter_cases, + ), ("dark_mode.static_support", crate::dark_mode::counter_cases), ( "dark_mode.print_stylesheet", @@ -663,6 +731,46 @@ mod tests { } } + /// Plan 69 / #777: a lost collection makes the run partial, all of them + /// lost makes it failed — not "completed" with an empty, passing result. + #[test] + fn fold_marks_partial_and_failed_runs() { + use crate::audit::{ExecutionStatus, ModuleRun}; + let record = |collector: &str, state| CollectionRecord { + collector: collector.into(), + module: "best_practices".into(), + state, + method: CollectionMethod::JavaScript, + method_version: 1, + scoring: true, + reason_code: None, + gaps: vec![], + }; + let status = |states: [CollectionState; 2]| { + let mut runs = vec![ModuleRun { + module: "best_practices".into(), + status: ExecutionStatus::Completed, + ..Default::default() + }]; + fold_records( + &mut runs, + vec![ + record("best_practices.console", states[0]), + record("best_practices.libraries", states[1]), + ], + ); + (runs[0].status, runs[0].subchecks.len()) + }; + use CollectionState::{Empty, Failed, Measured, NotApplicable}; + assert_eq!(status([Measured, Empty]), (ExecutionStatus::Completed, 2)); + assert_eq!( + status([Measured, NotApplicable]), + (ExecutionStatus::Completed, 2) + ); + assert_eq!(status([Failed, Measured]), (ExecutionStatus::Partial, 2)); + assert_eq!(status([Failed, Failed]), (ExecutionStatus::Failed, 2)); + } + #[test] fn inventory_ids_are_unique_and_scoped_to_their_module() { let mut seen = std::collections::HashSet::new(); diff --git a/src/audit/pipeline/assembly.rs b/src/audit/pipeline/assembly.rs index a2a87542..2b756b3a 100644 --- a/src/audit/pipeline/assembly.rs +++ b/src/audit/pipeline/assembly.rs @@ -262,7 +262,6 @@ pub(super) fn aggregate_report( if let Some(performance) = snapshot.performance.clone() { report = report.with_performance(performance); - attach_performance_subchecks(&mut report); } if let Some(seo) = snapshot.seo.clone() { report = report.with_seo(seo); @@ -363,68 +362,6 @@ pub(super) fn reconcile_image_alt_count(report: &mut AuditReport) { } } -fn attach_performance_subchecks(report: &mut AuditReport) { - let Some(performance) = report.performance.as_ref() else { - return; - }; - let checks = [ - ("lcp", performance.vitals.lcp.is_some()), - ("fcp", performance.vitals.fcp.is_some()), - ("cls", performance.vitals.cls.is_some()), - ("tbt", performance.vitals.tbt.is_some()), - ("content_weight", performance.content_weight.is_some()), - ("render_blocking", performance.render_blocking.is_some()), - ("third_party", performance.third_party.is_some()), - ("critical_chain", performance.critical_chain.is_some()), - ("minification", performance.minification.is_some()), - ("animations", performance.animations.is_some()), - ("coverage", performance.coverage.is_some()), - ]; - // The score comes from the Web Vitals, not these sub-analyses: losing - // all of them leaves a partial run, not a failed one. - attach_subchecks(report, "performance", &checks, false); -} - -/// Records each sub-analysis of `module` as completed or failed. One failed -/// sub-analysis makes the module run `Partial`; when the sub-analyses are the -/// whole module (`whole_module`), all of them failing makes it `Failed` — an -/// empty result from a collector that never ran is no measurement (plan 69). -fn attach_subchecks( - report: &mut AuditReport, - module: &str, - checks: &[(&str, bool)], - whole_module: bool, -) { - if let Some(run) = report - .accessibility - .execution - .module_runs - .iter_mut() - .find(|run| run.module == module) - { - run.subchecks = checks - .iter() - .map(|(name, available)| crate::audit::SubcheckRun { - subcheck: (*name).to_string(), - status: if *available { - crate::audit::ExecutionStatus::Completed - } else { - crate::audit::ExecutionStatus::Failed - }, - reason_code: (!available).then(|| "subanalysis_unavailable".to_string()), - ..Default::default() - }) - .collect(); - if whole_module && checks.iter().all(|(_, available)| !available) { - run.status = crate::audit::ExecutionStatus::Failed; - run.reason_code = Some("all_subanalyses_unavailable".to_string()); - } else if checks.iter().any(|(_, available)| !available) { - run.status = crate::audit::ExecutionStatus::Partial; - run.reason_code = Some("one_or_more_subanalyses_unavailable".to_string()); - } - } -} - fn audit_scope_from_config(config: &PipelineConfig) -> crate::audit::AuditScope { let catalog = AuditCatalog::standard(); let mut requested_modules = vec!["accessibility".to_string()]; @@ -874,28 +811,4 @@ mod tests { }); report } - - /// Plan 69: a collector that never ran makes the run partial, none at - /// all makes it failed — not "completed" with an empty, passing result. - #[test] - fn subchecks_mark_partial_and_failed_runs() { - let status = |checks: &[(&str, bool)]| { - let mut report = report_with_run("best_practices"); - attach_subchecks(&mut report, "best_practices", checks, true); - let run = &report.accessibility.execution.module_runs[0]; - (run.status, run.subchecks.len()) - }; - assert!(matches!( - status(&[("console_errors", true), ("vulnerable_libraries", true)]), - (ExecutionStatus::Completed, 2) - )); - assert!(matches!( - status(&[("console_errors", false), ("vulnerable_libraries", true)]), - (ExecutionStatus::Partial, 2) - )); - assert!(matches!( - status(&[("console_errors", false), ("vulnerable_libraries", false)]), - (ExecutionStatus::Failed, 2) - )); - } } diff --git a/src/audit/pipeline/mod.rs b/src/audit/pipeline/mod.rs index 58d95e7f..0b259da6 100644 --- a/src/audit/pipeline/mod.rs +++ b/src/audit/pipeline/mod.rs @@ -615,14 +615,25 @@ async fn audit_on_page( }); if let Some((baseline_tbt_ms, origins)) = baseline { if !origins.is_empty() { - let impacts = crate::performance::isolate_third_party_impact( - page, - browser, - url, - &origins, - baseline_tbt_ms, - ) - .await; + let log = crate::audit::collection::CollectionLog::new(); + let impacts = log + .record( + &crate::performance::third_party::ISOLATION, + crate::performance::isolate_third_party_impact( + page, + browser, + url, + &origins, + baseline_tbt_ms, + ) + .await, + ) + .into_value() + .unwrap_or_default(); + crate::audit::collection::fold_records( + &mut report.accessibility.execution.module_runs, + log.into_records(), + ); if let Some(perf) = report.performance.as_mut() { if let Some(third_party) = perf.third_party.as_mut() { third_party.isolated_impact = impacts; diff --git a/src/audit/pipeline/throttled.rs b/src/audit/pipeline/throttled.rs index 1e61fed6..ed5b96f1 100644 --- a/src/audit/pipeline/throttled.rs +++ b/src/audit/pipeline/throttled.rs @@ -20,15 +20,23 @@ pub(super) fn attach_throttled_profile_subchecks( let subchecks: Vec = ThrottleProfile::AUTO_PROFILES .iter() .map(|profile| { - let completed = results.iter().any(|result| result.profile == *profile); + let result = results.iter().find(|result| result.profile == *profile); + let (status, reason_code) = match result { + Some(r) if r.score.is_some() => (crate::audit::ExecutionStatus::Completed, None), + // Measured, but without all core metrics (#769). + Some(_) => ( + crate::audit::ExecutionStatus::Partial, + Some("core_metrics_incomplete".to_string()), + ), + None => ( + crate::audit::ExecutionStatus::Failed, + Some("profile_measurement_failed".to_string()), + ), + }; crate::audit::SubcheckRun { subcheck: format!("throttled_profile:{}", profile.label()), - status: if completed { - crate::audit::ExecutionStatus::Completed - } else { - crate::audit::ExecutionStatus::Failed - }, - reason_code: (!completed).then(|| "profile_measurement_failed".to_string()), + status, + reason_code, ..Default::default() } }) @@ -43,7 +51,7 @@ pub(super) fn attach_throttled_profile_subchecks( { if subchecks .iter() - .any(|check| check.status == crate::audit::ExecutionStatus::Failed) + .any(|check| check.status != crate::audit::ExecutionStatus::Completed) && performance.status == crate::audit::ExecutionStatus::Completed { performance.status = crate::audit::ExecutionStatus::Partial; @@ -113,12 +121,22 @@ pub(super) async fn collect_throttled_performance( continue; } - if let Err(e) = throttle::apply_cpu_throttling(page, profile).await { - warn!("CPU throttle apply failed for {:?}: {}", profile, e); - } - - if let Err(e) = throttle::disable_cache(page).await { - warn!("Cache disable failed for {:?}: {}", profile, e); + // Without CPU throttling or with a warm cache the pass would be an + // unthrottled measurement stored under this profile's name (#769). + let setup_failure = match ( + throttle::apply_cpu_throttling(page, profile).await, + throttle::disable_cache(page).await, + ) { + (Err(e), _) => Some(format!("CPU throttle apply failed for {profile:?}: {e}")), + (_, Err(e)) => Some(format!("Cache disable failed for {profile:?}: {e}")), + _ => None, + }; + if let Some(failure) = setup_failure { + warn!("{failure}"); + let _ = throttle::enable_cache(page).await; + let _ = throttle::disable_throttling(page).await; + let _ = throttle::disable_cpu_throttling(page).await; + continue; } let failure = match prepare_vitals_collection(page).await { @@ -141,23 +159,23 @@ pub(super) async fn collect_throttled_performance( continue; } - match crate::performance::extract_web_vitals(page).await { + // The profile's per-metric states are folded into its own subcheck + // (`throttled_profile:*`), not into the headline run. + match crate::performance::extract_web_vitals( + page, + &crate::audit::collection::CollectionLog::new(), + ) + .await + { Ok(vitals) => { // Pass the headline content_weight so the size/JS/request caps // apply to throttled profiles too — otherwise a throttled // profile can out-score the headline (and Slow3G out-score // Fast3G) purely because its caps were skipped (#456). let score = calculate_performance_score(&vitals, content_weight); - // If LCP could not be measured under throttling (timeout or - // navigation pre-completion), the most important navigation - // metric is missing — do not let CLS/TBT alone push the score - // to 100. Cap to "AUSBAUFÄHIG" tier so the profile reflects - // that the measurement was incomplete. - let final_score = if vitals.lcp.is_none() { - score.overall.min(50) - } else { - score.overall - }; + // A profile score needs every core metric; a subset would be a + // renormalized number that hides what is missing (#769). + let final_score = score.has_core_metrics().then_some(score.overall); results.push(ThrottledPerfResult { profile, lcp_ms: vitals.lcp.as_ref().map(|v| v.value), @@ -166,10 +184,9 @@ pub(super) async fn collect_throttled_performance( score: final_score, }); // LhMobile = Lighthouse mobile preset → canonical perf measurement. - // Only adopt when LCP could actually be measured; otherwise fall - // back to the unthrottled mobile pass so we don't report a - // capped-to-50 score that reflects measurement failure. - if profile == ThrottleProfile::LhMobile && vitals.lcp.is_some() { + // Only adopt a complete measurement; otherwise the unthrottled + // mobile pass stays the headline. + if let (ThrottleProfile::LhMobile, Some(final_score)) = (profile, final_score) { let mut adopted_score = score.clone(); adopted_score.overall = final_score; adopted_score.grade = diff --git a/src/audit/report.rs b/src/audit/report.rs index ae7be245..a3d2f813 100644 --- a/src/audit/report.rs +++ b/src/audit/report.rs @@ -241,8 +241,9 @@ pub struct ThrottledPerfResult { pub tbt_ms: Option, /// CLS score pub cls: Option, - /// Aggregate performance score (0–100) - pub score: u32, + /// Aggregate performance score (0–100); `None` when the profile did not + /// measure all four core metrics. + pub score: Option, } /// Screenshot bytes captured during the audit (desktop + mobile viewports). @@ -543,8 +544,9 @@ pub struct PerformanceResults { #[serde(skip_serializing_if = "Option::is_none")] pub coverage: Option, /// Implausible or unmeasurable metrics detected during analysis (#291). - /// Keys: "tbt_zero_heavy_page", "speed_index_fallback_to_lcp", - /// "tti_fallback_to_lcp", "inp_not_measured". + /// Keys: "lcp_not_measured", "speed_index_fallback_to_lcp", + /// "tti_fallback_to_lcp", "inp_not_measured", "css_coverage_unavailable", + /// "css_coverage_partial", "js_coverage_unavailable". #[serde(default, skip_serializing_if = "Vec::is_empty")] pub measurement_warnings: Vec, } diff --git a/src/output/builder/batch.rs b/src/output/builder/batch.rs index 39968835..c1bf5431 100644 --- a/src/output/builder/batch.rs +++ b/src/output/builder/batch.rs @@ -2351,7 +2351,7 @@ mod minification_inconsistency_tests { .collect(), total_bytes: 1000, unused_bytes: 0, - used_pct: 100.0, + used_pct: Some(100.0), }, unused_css: UnusedCssAnalysis { total_rules: 0, diff --git a/src/output/builder/single/module_details/performance.rs b/src/output/builder/single/module_details/performance.rs index cec84b25..5acecfa9 100644 --- a/src/output/builder/single/module_details/performance.rs +++ b/src/output/builder/single/module_details/performance.rs @@ -449,7 +449,7 @@ pub(super) fn build_performance_details( }); let coverage = p.coverage.as_ref().map(|cov| CoveragePresentation { - js_used_pct: Some(cov.unused_js.used_pct), + js_used_pct: cov.unused_js.used_pct, js_unused_kb: Some(cov.unused_js.unused_bytes as f64 / 1024.0), css_used_pct: cov.unused_css.used_pct, css_total_rules: Some(cov.unused_css.total_rules), diff --git a/src/output/json.rs b/src/output/json.rs index 1d92ce50..bafafc3f 100644 --- a/src/output/json.rs +++ b/src/output/json.rs @@ -1243,23 +1243,23 @@ impl UnifiedReport { mobile_score: normalized_module_score(&ctx.normalized, "Mobile"), ux_score: normalized_module_score(&ctx.normalized, "UX"), journey_score: normalized_module_score(&ctx.normalized, "Journey"), + // Only an average over complete profiles: one without all core + // metrics leaves no comparable average (#769). performance_throttled_avg_score: { - let scores: Vec = ctx + let scores: Option> = ctx .raw_throttled_performance .iter() .map(|t| t.score) .collect(); - if scores.is_empty() { - None - } else { - Some(scores.iter().sum::() / scores.len() as u32) - } + scores + .filter(|scores| !scores.is_empty()) + .map(|scores| scores.iter().sum::() / scores.len() as u32) }, lh_mobile_score: ctx .raw_throttled_performance .iter() .find(|t| t.profile == crate::browser::ThrottleProfile::LhMobile) - .map(|t| t.score), + .and_then(|t| t.score), wcag_coverage: build_wcag_coverage_summary(&ctx.normalized), overall_score_weight_basis: overall_score_weight_basis(std::slice::from_ref( &ctx.normalized, diff --git a/src/output/pdf/detail_modules/performance.rs b/src/output/pdf/detail_modules/performance.rs index 03241ab5..713a8af0 100644 --- a/src/output/pdf/detail_modules/performance.rs +++ b/src/output/pdf/detail_modules/performance.rs @@ -393,7 +393,6 @@ pub(in crate::output::pdf) fn render_performance( .iter() .map(|key| match key.as_str() { "lcp_not_measured" => i18n.t("perf-warning-lcp-missing"), - "tbt_zero_heavy_page" => i18n.t("perf-warning-tbt-zero"), "speed_index_fallback_to_lcp" => i18n.t("perf-warning-si-fallback"), "tti_fallback_to_lcp" => i18n.t("perf-warning-tti-fallback"), "inp_not_measured" => i18n.t("perf-warning-inp-missing"), @@ -405,18 +404,19 @@ pub(in crate::output::pdf) fn render_performance( .iter() .any(|w| w == "lcp_not_measured") { - if let Some(lh) = perf + if let Some(lh_score) = perf .throttled_profiles .iter() .find(|p| p.profile_name == "LhMobile") + .and_then(|p| p.score) { - let gap = perf.score.saturating_sub(lh.score); + let gap = perf.score.saturating_sub(lh_score); if gap >= 15 { warning_items.push(i18n.t_args( "perf-warning-lh-mobile-gap", &[ ("desktop", perf.score.to_string()), - ("mobile", lh.score.to_string()), + ("mobile", lh_score.to_string()), ("gap", gap.to_string()), ], )); @@ -446,8 +446,14 @@ pub(in crate::output::pdf) fn render_performance( entry.lcp.clone(), entry.tbt.clone(), entry.cls.clone(), - entry.score.to_string(), - super::score_band_label(entry.score, i18n).to_string(), + entry + .score + .map(|score| score.to_string()) + .unwrap_or_else(|| "\u{2014}".to_string()), + entry + .score + .map(|score| super::score_band_label(score, i18n).to_string()) + .unwrap_or_else(|| i18n.t("pdf-perf-throttled-incomplete")), ]); } builder = builder.add_component(table); @@ -759,7 +765,7 @@ mod tests { lcp: lcp.to_string(), tbt: "0 ms".to_string(), cls: "0.000".to_string(), - score: 50, + score: Some(50), } } diff --git a/src/output/pdf/tests.rs b/src/output/pdf/tests.rs index c84ad43f..8ec796fe 100644 --- a/src/output/pdf/tests.rs +++ b/src/output/pdf/tests.rs @@ -1054,7 +1054,7 @@ mod tests { lcp_ms: Some(3200.0), tbt_ms: Some(180.0), cls: Some(0.03), - score: 72, + score: Some(72), }]; report = report.with_tech_stack(crate::tech_stack::TechStackAnalysis { detected: vec![crate::tech_stack::DetectedTech { @@ -1248,7 +1248,7 @@ mod tests { lcp_ms: Some(3200.0), tbt_ms: Some(180.0), cls: Some(0.03), - score: 72, + score: Some(72), }]; let config = ReportConfig { @@ -1337,7 +1337,7 @@ mod tests { scripts: vec![], total_bytes: 760_628, unused_bytes: 0, - used_pct: 100.0, + used_pct: Some(100.0), }, unused_css: crate::performance::UnusedCssAnalysis { total_rules: 0, diff --git a/src/output/report_model.rs b/src/output/report_model.rs index 0791d5dc..e2b2b52c 100644 --- a/src/output/report_model.rs +++ b/src/output/report_model.rs @@ -832,7 +832,8 @@ pub struct ThrottledPerfEntry { pub lcp: String, pub tbt: String, pub cls: String, - pub score: u32, + /// `None` when the profile did not measure all four core metrics. + pub score: Option, } /// Per-origin row for third-party attribution display diff --git a/src/output/tests/report_invariants.rs b/src/output/tests/report_invariants.rs index 0829b4c0..8882bcd2 100644 --- a/src/output/tests/report_invariants.rs +++ b/src/output/tests/report_invariants.rs @@ -364,7 +364,7 @@ fn measurement_warnings_present_in_json_when_set() { minification: None, animations: None, coverage: None, - measurement_warnings: vec!["tbt_zero_heavy_page".to_string()], + measurement_warnings: vec!["lcp_not_measured".to_string()], }); let normalized = crate::audit::normalize(&report); let unified = UnifiedReport::single(&normalized, &report); @@ -378,8 +378,8 @@ fn measurement_warnings_present_in_json_when_set() { ); assert_eq!( warnings[0].as_str(), - Some("tbt_zero_heavy_page"), - "first warning key must be 'tbt_zero_heavy_page'" + Some("lcp_not_measured"), + "first warning key must be 'lcp_not_measured'" ); } diff --git a/src/performance/animations.rs b/src/performance/animations.rs index 18e75ddd..a681d929 100644 --- a/src/performance/animations.rs +++ b/src/performance/animations.rs @@ -18,7 +18,6 @@ use chromiumoxide::Page; use serde::{Deserialize, Serialize}; use tracing::info; -use crate::error::{AuditError, Result}; use crate::util::truncate_ellipsis as truncate; /// A single non-composited animation or transition finding. @@ -46,7 +45,9 @@ pub struct AnimationAnalysis { } /// Detect non-composited CSS animations and transitions on the loaded page. -pub async fn analyze_non_composited_animations(page: &Page) -> Result { +pub async fn analyze_non_composited_animations( + page: &Page, +) -> crate::audit::collection::Collected { info!("Analyzing non-composited CSS animations..."); // Properties that trigger layout (expensive) when animated. @@ -143,14 +144,13 @@ pub async fn analyze_non_composited_animations(page: &Page) -> Result = match crate::audit::collection::evaluate_js(page, js) .await - .map_err(|e| AuditError::CdpError(format!("Non-composited animation JS failed: {e}")))?; - - let json_str = result.value().and_then(|v| v.as_str()).unwrap_or("[]"); - let raw: Vec = serde_json::from_str(json_str).unwrap_or_default(); + .into_measurement() + { + Ok(v) => v, + Err(failure) => return failure, + }; let findings: Vec = raw .into_iter() @@ -178,7 +178,7 @@ pub async fn analyze_non_composited_animations(page: &Page) -> Result>(serde_json::json!([])); +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/performance/content_weight.rs b/src/performance/content_weight.rs index f59e6e8c..a057e0d6 100644 --- a/src/performance/content_weight.rs +++ b/src/performance/content_weight.rs @@ -6,8 +6,6 @@ use chromiumoxide::Page; use serde::{Deserialize, Serialize}; use tracing::info; -use crate::error::{AuditError, Result}; - /// Content weight analysis results #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContentWeight { @@ -133,7 +131,9 @@ pub(super) fn format_bytes(bytes: u64) -> String { /// # Returns /// * `Ok(ContentWeight)` - The analysis results /// * `Err(AuditError)` - If analysis fails -pub async fn analyze_content_weight(page: &Page) -> Result { +pub async fn analyze_content_weight( + page: &Page, +) -> crate::audit::collection::Collected { info!("Analyzing content weight..."); // Get resource timing entries via JavaScript. @@ -159,14 +159,13 @@ pub async fn analyze_content_weight(page: &Page) -> Result { })() "#; - let js_result = page - .evaluate(js_code) + let resources: Vec = match crate::audit::collection::evaluate_js(page, js_code) .await - .map_err(|e| AuditError::CdpError(format!("Resource analysis failed: {}", e)))?; - - let json_str = js_result.value().and_then(|v| v.as_str()).unwrap_or("[]"); - - let resources: Vec = serde_json::from_str(json_str).unwrap_or_default(); + .into_measurement() + { + Ok(v) => v, + Err(failure) => return failure, + }; let mut breakdown = ResourceBreakdown::default(); let mut total_bytes: u64 = 0; @@ -217,7 +216,7 @@ pub async fn analyze_content_weight(page: &Page) -> Result { request_count ); - Ok(ContentWeight { + crate::audit::collection::Collected::Measured(ContentWeight { total_bytes, transfer_bytes, breakdown, @@ -403,6 +402,12 @@ fn carbon_rating(grams: f64) -> &'static str { } } +/// Counter-cases of this collector (#769): a page whose resource list could not be read is no page of 0 bytes. +#[cfg(test)] +pub(crate) fn counter_cases() { + crate::audit::collection::assert_js_contract::>(serde_json::json!([])); +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/performance/coverage.rs b/src/performance/coverage.rs index d6e59a25..bdbf18bb 100644 --- a/src/performance/coverage.rs +++ b/src/performance/coverage.rs @@ -149,8 +149,9 @@ pub struct UnusedJsAnalysis { pub total_bytes: u64, /// Total unused bytes pub unused_bytes: u64, - /// Overall JS usage percentage (0–100) - pub used_pct: f64, + /// Overall JS usage percentage (0–100); `None` when no script bytes + /// were measured — no scripts, or the profiler gave no coverage (#769). + pub used_pct: Option, } /// Unused CSS analysis (#107). @@ -288,7 +289,86 @@ pub async fn prepare_coverage_collection(page: &Page) -> Result<()> { } /// Read JS and CSS coverage results **after** the page has loaded. -pub async fn take_coverage_results(page: &Page) -> Result { +/// JS coverage via `Profiler.takePreciseCoverage`. +pub(crate) static JS_COVERAGE: crate::audit::collection::CollectorSpec = + crate::audit::collection::CollectorSpec { + id: "performance.js_coverage", + module: "performance", + method: crate::audit::collection::CollectionMethod::Cdp, + method_version: 1, + scoring: false, + }; + +/// CSS rule usage via `CSS.stopRuleUsageTracking`. +pub(crate) static CSS_COVERAGE: crate::audit::collection::CollectorSpec = + crate::audit::collection::CollectorSpec { + id: "performance.css_coverage", + module: "performance", + method: crate::audit::collection::CollectionMethod::Cdp, + method_version: 1, + scoring: false, + }; + +/// JS coverage: no answer from the profiler is a failure, an answer without +/// script bytes a valid empty measurement. +fn js_coverage_state( + js_measured: bool, + total_bytes: u64, +) -> crate::audit::collection::Collected<()> { + use crate::audit::collection::{reason, Collected}; + if !js_measured { + Collected::Failed(reason::CDP_COMMAND_FAILED) + } else if total_bytes == 0 { + Collected::Empty(()) + } else { + Collected::Measured(()) + } +} + +/// CSS coverage from its `measurement` label: rule tracking that never +/// answered is a failure, tracking without countable rules empty. +fn css_coverage_state(measurement: &str, tracked: bool) -> crate::audit::collection::Collected<()> { + use crate::audit::collection::{reason, Collected}; + match measurement { + "measured" => Collected::Measured(()), + "partial" => Collected::Partial { + value: (), + gaps: vec!["unreadable_stylesheet"], + }, + _ if tracked => Collected::Empty(()), + _ => Collected::Failed(reason::CDP_COMMAND_FAILED), + } +} + +/// Counter-cases of [`JS_COVERAGE`] and [`CSS_COVERAGE`] (#769): a profiler +/// without an answer is no "100 % used". +#[cfg(test)] +pub(crate) fn counter_cases() { + use crate::audit::collection::CollectionState; + assert_eq!(js_coverage_state(false, 0).state(), CollectionState::Failed); + assert_eq!(js_coverage_state(true, 0).state(), CollectionState::Empty); + assert_eq!( + js_coverage_state(true, 10).state(), + CollectionState::Measured + ); + assert_eq!( + css_coverage_state("not_available", false).state(), + CollectionState::Failed + ); + assert_eq!( + css_coverage_state("not_available", true).state(), + CollectionState::Empty + ); + assert_eq!( + css_coverage_state("partial", true).state(), + CollectionState::Partial + ); +} + +pub async fn take_coverage_results( + page: &Page, + log: &crate::audit::collection::CollectionLog, +) -> Result { info!("Taking JS and CSS coverage results..."); // Re-issue `Debugger.enable` defensively — `prepare_coverage_collection` @@ -323,11 +403,13 @@ pub async fn take_coverage_results(page: &Page) -> Result { } // ── JS coverage ─────────────────────────────────────────────────────────── - let js_coverage = match page.execute(TakePreciseCoverageParams::default()).await { - Ok(resp) => resp.result.result, + // A failed take is no coverage — not "every byte used" (#769). + let (js_coverage, js_measured) = match page.execute(TakePreciseCoverageParams::default()).await + { + Ok(resp) => (resp.result.result, true), Err(e) => { warn!("Profiler.takePreciseCoverage failed: {e}"); - vec![] + (vec![], false) } }; @@ -440,11 +522,10 @@ pub async fn take_coverage_results(page: &Page) -> Result { } } - let overall_used_pct = if total_bytes > 0 { + let overall_used_pct = (total_bytes > 0).then(|| { ((total_bytes - total_unused_bytes) as f64 / total_bytes as f64 * 100.0).clamp(0.0, 100.0) - } else { - 100.0 - }; + }); + log.record(&JS_COVERAGE, js_coverage_state(js_measured, total_bytes)); let unused_js = UnusedJsAnalysis { scripts, @@ -519,7 +600,7 @@ pub async fn take_coverage_results(page: &Page) -> Result { .filter(|r| r.used && read_sheets.contains(&r.style_sheet_id)) .count() as u32; let complete = read_sheets.len() == stylesheets.len(); - let (css_used_pct, css_measurement, measurement_warnings) = + let (css_used_pct, css_measurement, mut measurement_warnings) = if css_coverage.is_none() || total_rules == 0 { ( None, @@ -539,6 +620,14 @@ pub async fn take_coverage_results(page: &Page) -> Result { } }; + log.record( + &CSS_COVERAGE, + css_coverage_state(&css_measurement, css_coverage.is_some()), + ); + if !js_measured { + measurement_warnings.push("js_coverage_unavailable".to_string()); + } + let unused_css = UnusedCssAnalysis { total_rules, used_rules: used_rules.min(total_rules), @@ -551,8 +640,10 @@ pub async fn take_coverage_results(page: &Page) -> Result { 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, + "Coverage: JS {} used ({:.1} KB unused), CSS {} rules used ({}/{})", + overall_used_pct + .map(|v| format!("{v:.0}%")) + .unwrap_or_else(|| "not available".to_string()), total_unused_bytes as f64 / 1024.0, css_used_pct .map(|v| format!("{v:.0}%")) @@ -669,9 +760,9 @@ mod tests { scripts: vec![], total_bytes: 0, unused_bytes: 0, - used_pct: 100.0, + used_pct: None, }; - assert_eq!(analysis.used_pct, 100.0); + assert_eq!(analysis.used_pct, None); assert!(analysis.scripts.is_empty()); } diff --git a/src/performance/critical_chain.rs b/src/performance/critical_chain.rs index 38a0a971..29cd54ea 100644 --- a/src/performance/critical_chain.rs +++ b/src/performance/critical_chain.rs @@ -9,7 +9,6 @@ use chromiumoxide::Page; use serde::{Deserialize, Serialize}; use tracing::info; -use crate::error::{AuditError, Result}; use crate::util::truncate_ellipsis as truncate; /// A single node in the critical request chain. @@ -87,7 +86,9 @@ pub struct CriticalChain { } /// Build the critical request chain from PerformanceResourceTiming entries. -pub async fn analyze_critical_chain(page: &Page) -> Result { +pub async fn analyze_critical_chain( + page: &Page, +) -> crate::audit::collection::Collected { info!("Building critical request chain..."); let js = r#" @@ -107,14 +108,13 @@ pub async fn analyze_critical_chain(page: &Page) -> Result { })); })() "#; - - let result = page - .evaluate(js) + let raw: Vec = match crate::audit::collection::evaluate_js(page, js) .await - .map_err(|e| AuditError::CdpError(format!("Critical chain JS failed: {e}")))?; - - let json_str = result.value().and_then(|v| v.as_str()).unwrap_or("[]"); - let raw: Vec = serde_json::from_str(json_str).unwrap_or_default(); + .into_measurement() + { + Ok(v) => v, + Err(failure) => return failure, + }; let total_requests = raw.len() as u32; @@ -154,7 +154,7 @@ pub async fn analyze_critical_chain(page: &Page) -> Result { total_requests, max_depth, critical_path_ms ); - Ok(CriticalChain { + crate::audit::collection::Collected::Measured(CriticalChain { chains, total_requests, critical_path_bytes, @@ -265,6 +265,12 @@ struct RawResource { duration: f64, } +/// Counter-cases of this collector (#769): an unread resource list is no empty chain. +#[cfg(test)] +pub(crate) fn counter_cases() { + crate::audit::collection::assert_js_contract::>(serde_json::json!([])); +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/performance/minification.rs b/src/performance/minification.rs index 005ca758..35717027 100644 --- a/src/performance/minification.rs +++ b/src/performance/minification.rs @@ -14,7 +14,6 @@ use chromiumoxide::Page; use serde::{Deserialize, Serialize}; use tracing::info; -use crate::error::{AuditError, Result}; use crate::util::truncate_ellipsis as truncate; /// A single JS or CSS resource that appears to be unminified. @@ -67,7 +66,9 @@ pub struct MinificationAnalysis { } /// Detect unminified JS and CSS resources on the loaded page. -pub async fn analyze_minification(page: &Page) -> Result { +pub async fn analyze_minification( + page: &Page, +) -> crate::audit::collection::Collected { info!("Analyzing unminified JS/CSS assets..."); let js = r#" @@ -83,14 +84,13 @@ pub async fn analyze_minification(page: &Page) -> Result { })); })() "#; - - let result = page - .evaluate(js) + let entries: Vec = match crate::audit::collection::evaluate_js(page, js) .await - .map_err(|e| AuditError::CdpError(format!("Minification analysis JS failed: {e}")))?; - - let json_str = result.value().and_then(|v| v.as_str()).unwrap_or("[]"); - let entries: Vec = serde_json::from_str(json_str).unwrap_or_default(); + .into_measurement() + { + Ok(v) => v, + Err(failure) => return failure, + }; let mut unminified_scripts: Vec = Vec::new(); let mut unminified_styles: Vec = Vec::new(); @@ -184,7 +184,7 @@ pub async fn analyze_minification(page: &Page) -> Result { legacy_scripts.len() ); - Ok(MinificationAnalysis { + crate::audit::collection::Collected::Measured(MinificationAnalysis { unminified_scripts, unminified_styles, total_savings_bytes, @@ -263,6 +263,12 @@ fn legacy_wasted_bytes(decoded_bytes: u64, transfer_bytes: u64) -> u64 { } } +/// Counter-cases of this collector (#769): an unread asset list is no fully minified page. +#[cfg(test)] +pub(crate) fn counter_cases() { + crate::audit::collection::assert_js_contract::>(serde_json::json!([])); +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/performance/mod.rs b/src/performance/mod.rs index da2a41ab..8a95d020 100644 --- a/src/performance/mod.rs +++ b/src/performance/mod.rs @@ -2,16 +2,16 @@ //! //! Provides Core Web Vitals collection and performance scoring. -mod animations; -mod content_weight; -mod coverage; -mod critical_chain; -mod minification; +pub(crate) mod animations; +pub(crate) mod content_weight; +pub(crate) mod coverage; +pub(crate) mod critical_chain; +pub(crate) mod minification; pub mod module; -mod render_blocking; +pub(crate) mod render_blocking; mod scoring; -mod third_party; -mod vitals; +pub(crate) mod third_party; +pub(crate) mod vitals; pub use module::PerformanceModule; diff --git a/src/performance/module.rs b/src/performance/module.rs index 42a95087..e352f443 100644 --- a/src/performance/module.rs +++ b/src/performance/module.rs @@ -4,14 +4,16 @@ //! 1. `extract_web_vitals` is the gate — if vitals fail, the whole module //! yields `ModuleData::None` (no sub-analyses are run). //! 2. Seven sub-analyses (render-blocking, content-weight, third-party, -//! critical-chain, minification, animations, coverage) each log a -//! warning on failure and contribute `None` to the result struct. +//! critical-chain, minification, animations, coverage) each record their +//! collection state (#777) and contribute `None` when they did not +//! measure. //! 3. `measurement_warnings` combines vitals validation warnings with the //! coverage analysis's own warnings, preserving the previous order. use async_trait::async_trait; use tracing::warn; +use crate::audit::collection::{CollectionMethod, CollectorSpec}; use crate::audit::module::{AuditModule, ModuleContext, ModuleData}; use crate::audit::PerformanceResults; use crate::audit::PipelineConfig; @@ -42,8 +44,9 @@ impl AuditModule for PerformanceModule { async fn collect(&self, ctx: &ModuleContext<'_>) -> Result { let page = ctx.page; let url = ctx.url; + let log = ctx.collection; - let vitals = match extract_web_vitals(page).await { + let vitals = match extract_web_vitals(page, log).await { Ok(v) => v, Err(e) => { warn!("Performance analysis failed: {}", e); @@ -51,13 +54,11 @@ impl AuditModule for PerformanceModule { } }; - let content_weight = match analyze_content_weight(page).await { - Ok(cw) => Some(cw), - Err(e) => { - warn!("Content-weight analysis failed: {}", e); - None - } - }; + // Size, JS and request penalties come from the content weight: a + // failed read is no page of 0 bytes (#769). + let content_weight = log + .record(&CONTENT_WEIGHT, analyze_content_weight(page).await) + .into_value(); let score = calculate_performance_score(&vitals, content_weight.as_ref()); @@ -73,42 +74,25 @@ impl AuditModule for PerformanceModule { return Ok(ModuleData::None); } - let render_blocking = match analyze_render_blocking(page, url).await { - Ok(rb) => Some(rb), - Err(e) => { - warn!("Render-blocking analysis failed: {}", e); - None - } - }; - let third_party = match analyze_third_party_attribution(page, url).await { - Ok(tp) => Some(tp), - Err(e) => { - warn!("Third-party attribution analysis failed: {}", e); - None - } - }; - let critical_chain = match analyze_critical_chain(page).await { - Ok(cc) => Some(cc), - Err(e) => { - warn!("Critical chain analysis failed: {}", e); - None - } - }; - let minification = match analyze_minification(page).await { - Ok(m) => Some(m), - Err(e) => { - warn!("Minification analysis failed: {}", e); - None - } - }; - let animations = match analyze_non_composited_animations(page).await { - Ok(a) => Some(a), - Err(e) => { - warn!("Non-composited animation analysis failed: {}", e); - None - } - }; - let coverage = match take_coverage_results(page).await { + let render_blocking = log + .record(&RENDER_BLOCKING, analyze_render_blocking(page, url).await) + .into_value(); + let third_party = log + .record( + &THIRD_PARTY, + analyze_third_party_attribution(page, url).await, + ) + .into_value(); + let critical_chain = log + .record(&CRITICAL_CHAIN, analyze_critical_chain(page).await) + .into_value(); + let minification = log + .record(&MINIFICATION, analyze_minification(page).await) + .into_value(); + let animations = log + .record(&ANIMATIONS, analyze_non_composited_animations(page).await) + .into_value(); + let coverage = match take_coverage_results(page, log).await { Ok(c) => Some(c), Err(e) => { warn!("Coverage analysis failed: {}", e); @@ -135,3 +119,25 @@ impl AuditModule for PerformanceModule { }))) } } + +const fn resource_collector(id: &'static str, scoring: bool) -> CollectorSpec { + CollectorSpec { + id, + module: "performance", + method: CollectionMethod::JavaScript, + method_version: 1, + scoring, + } +} + +/// Resource Timing sizes; the score's size, JS and request penalties. +pub(crate) static CONTENT_WEIGHT: CollectorSpec = + resource_collector("performance.content_weight", true); +pub(crate) static RENDER_BLOCKING: CollectorSpec = + resource_collector("performance.render_blocking", false); +pub(crate) static THIRD_PARTY: CollectorSpec = resource_collector("performance.third_party", false); +pub(crate) static CRITICAL_CHAIN: CollectorSpec = + resource_collector("performance.critical_chain", false); +pub(crate) static MINIFICATION: CollectorSpec = + resource_collector("performance.minification", false); +pub(crate) static ANIMATIONS: CollectorSpec = resource_collector("performance.animations", false); diff --git a/src/performance/render_blocking.rs b/src/performance/render_blocking.rs index 66f5209a..68bbc26c 100644 --- a/src/performance/render_blocking.rs +++ b/src/performance/render_blocking.rs @@ -8,7 +8,6 @@ use serde::{Deserialize, Serialize}; use tracing::info; use url::Url; -use crate::error::{AuditError, Result}; use crate::util::truncate_ellipsis; /// A resource that blocks initial rendering. @@ -57,7 +56,7 @@ impl RenderBlockingAnalysis { pub async fn analyze_render_blocking( page: &Page, page_url: &str, -) -> Result { +) -> crate::audit::collection::Collected { info!("Analyzing render-blocking resources..."); let js = r#" @@ -99,14 +98,14 @@ pub async fn analyze_render_blocking( return JSON.stringify(result); })() "#; - - let js_result = page - .evaluate(js) - .await - .map_err(|e| AuditError::CdpError(format!("Render-blocking analysis 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 parsed = + match crate::audit::collection::evaluate_js::(page, js) + .await + .into_measurement() + { + Ok(object) => serde_json::Value::Object(object), + Err(failure) => return failure, + }; let timing = parsed["resourceTiming"] .as_object() @@ -193,7 +192,7 @@ pub async fn analyze_render_blocking( blocking_transfer_bytes / 1024, ); - Ok(RenderBlockingAnalysis { + crate::audit::collection::Collected::Measured(RenderBlockingAnalysis { blocking_scripts, blocking_css, blocking_transfer_bytes, @@ -280,6 +279,14 @@ pub fn render_blocking_suggestions( s } +/// Counter-cases of this collector (#769): an unread resource timing is no page without blocking resources. +#[cfg(test)] +pub(crate) fn counter_cases() { + crate::audit::collection::assert_js_contract::( + serde_json::json!({}), + ); +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/performance/third_party.rs b/src/performance/third_party.rs index a00bdf18..5830062d 100644 --- a/src/performance/third_party.rs +++ b/src/performance/third_party.rs @@ -88,7 +88,7 @@ impl ThirdPartyAttribution { pub async fn analyze_third_party_attribution( page: &Page, page_url: &str, -) -> Result { +) -> crate::audit::collection::Collected { info!("Analyzing third-party attribution..."); let js = r#" @@ -103,14 +103,13 @@ pub async fn analyze_third_party_attribution( })); })() "#; - - let result = page - .evaluate(js) + let entries: Vec = match crate::audit::collection::evaluate_js(page, js) .await - .map_err(|e| AuditError::CdpError(format!("Third-party attribution JS failed: {e}")))?; - - let json_str = result.value().and_then(|v| v.as_str()).unwrap_or("[]"); - let entries: Vec = serde_json::from_str(json_str).unwrap_or_default(); + .into_measurement() + { + Ok(v) => v, + Err(failure) => return failure, + }; let page_host = Url::parse(page_url) .ok() @@ -179,7 +178,7 @@ pub async fn analyze_third_party_attribution( total_bytes as f64 / 1024.0 ); - Ok(ThirdPartyAttribution { + crate::audit::collection::Collected::Measured(ThirdPartyAttribution { origins, total_origins, total_bytes, @@ -257,16 +256,32 @@ async fn set_blocked_urls(page: &Page, patterns: &[String]) -> Result<()> { /// Costly: one full page reload per isolated origin. Callers must only /// invoke this when both performance checking and the explicit /// `--isolate-third-party-impact` opt-in are active (see `PipelineConfig`). +/// One reload per blocked third-party origin (opt-in). +pub(crate) static ISOLATION: crate::audit::collection::CollectorSpec = + crate::audit::collection::CollectorSpec { + id: "performance.third_party_isolation", + module: "performance", + method: crate::audit::collection::CollectionMethod::Cdp, + method_version: 1, + scoring: false, + }; + +/// The impact of each isolated origin. An origin whose isolation pass +/// failed is missing from the list; the collection says so (partial or +/// failed) instead of dropping it silently (#769). pub async fn isolate_third_party_impact( page: &Page, browser: &BrowserManager, url: &str, origins: &[ThirdPartyOrigin], baseline_tbt_ms: f64, -) -> Vec { +) -> crate::audit::collection::Collected> { + use crate::audit::collection::CollectionLog; let mut results = Vec::new(); + let mut attempted = 0usize; for origin in select_origins_to_isolate(origins, MAX_ISOLATED_ORIGINS) { + attempted += 1; let pattern = format!("*{}*", origin.origin); info!("Isolating third-party impact for {}", origin.origin); @@ -290,7 +305,9 @@ pub async fn isolate_third_party_impact( continue; } - let without_tbt = match extract_web_vitals(page).await { + // The isolation pass's vitals are a comparison input, not report + // data; their per-metric records are not kept. + let without_tbt = match extract_web_vitals(page, &CollectionLog::new()).await { Ok(vitals) => vitals.tbt.map(|m| m.value), Err(e) => { warn!( @@ -315,7 +332,27 @@ pub async fn isolate_third_party_impact( } } - results + isolation_outcome(attempted, results) +} + +/// Every attempted origin isolated: measured; some: partial; none: failed. +fn isolation_outcome( + attempted: usize, + results: Vec, +) -> crate::audit::collection::Collected> { + use crate::audit::collection::{reason, Collected}; + if attempted == 0 { + Collected::NotApplicable("no_third_party_origin") + } else if results.is_empty() { + Collected::Failed(reason::CDP_COMMAND_FAILED) + } else if results.len() < attempted { + Collected::Partial { + value: results, + gaps: vec!["origin_isolation"], + } + } else { + Collected::Measured(results) + } } // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -404,6 +441,32 @@ fn classify_origin(host: &str) -> (Option, Option) { (Some(pair.0.to_string()), Some(pair.1.to_string())) } +/// Counter-cases of the third-party collectors (#769): an unread resource +/// list is no page without third parties, and a lost isolation pass leaves +/// the impact list partial instead of silently short. +#[cfg(test)] +pub(crate) fn counter_cases() { + use crate::audit::collection::CollectionState; + crate::audit::collection::assert_js_contract::>(serde_json::json!([])); + let impact = compute_isolated_impact("https://cdn.example", "*cdn*", 300.0, 100.0); + assert_eq!( + isolation_outcome(0, vec![]).state(), + CollectionState::NotApplicable + ); + assert_eq!( + isolation_outcome(2, vec![]).state(), + CollectionState::Failed + ); + assert_eq!( + isolation_outcome(2, vec![impact.clone()]).state(), + CollectionState::Partial + ); + assert_eq!( + isolation_outcome(1, vec![impact]).state(), + CollectionState::Measured + ); +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/performance/vitals.rs b/src/performance/vitals.rs index 40e2cb6c..9a0bb8da 100644 --- a/src/performance/vitals.rs +++ b/src/performance/vitals.rs @@ -5,10 +5,49 @@ use chromiumoxide::cdp::browser_protocol::performance::GetMetricsParams; use chromiumoxide::Page; use serde::{Deserialize, Serialize}; -use tracing::{debug, info, warn}; +use tracing::{debug, info}; +use crate::audit::collection::{ + evaluate_js, Collected, CollectionLog, CollectionMethod, CollectorSpec, +}; use crate::error::{AuditError, Result}; +const fn vitals_collector( + id: &'static str, + method: CollectionMethod, + scoring: bool, +) -> CollectorSpec { + CollectorSpec { + id, + module: "performance", + method, + method_version: 2, + scoring, + } +} + +/// The pre-injected PerformanceObserver globals (LCP, long tasks, shifts). +pub(crate) static OBSERVERS: CollectorSpec = + vitals_collector("performance.observers", CollectionMethod::JavaScript, true); +/// `Performance.getMetrics` (FCP, DOM size, heap). +pub(crate) static CDP_METRICS: CollectorSpec = + vitals_collector("performance.cdp_metrics", CollectionMethod::Cdp, true); +/// Navigation and paint timing (fallbacks, TTFB, load, domInteractive). +pub(crate) static NAVIGATION_TIMING: CollectorSpec = vitals_collector( + "performance.navigation_timing", + CollectionMethod::JavaScript, + false, +); +/// The four core lab metrics, each with its own state (#769). +pub(crate) static LCP: CollectorSpec = + vitals_collector("performance.lcp", CollectionMethod::JavaScript, true); +pub(crate) static FCP: CollectorSpec = + vitals_collector("performance.fcp", CollectionMethod::Cdp, true); +pub(crate) static CLS: CollectorSpec = + vitals_collector("performance.cls", CollectionMethod::JavaScript, true); +pub(crate) static TBT: CollectorSpec = + vitals_collector("performance.tbt", CollectionMethod::JavaScript, true); + /// Source element attribution for a single CLS layout-shift entry. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClsSource { @@ -59,11 +98,14 @@ pub struct WebVitals { /// First Input Delay (ms) - deprecated; always null in CDP/headless audits #[serde(skip_serializing_if = "Option::is_none")] pub fid: Option, - /// Total Blocking Time (ms) + /// Total Blocking Time (ms) over FCP→TTI, from every long task the + /// observer saw (#770); `None` without FCP or the long-task observer. pub tbt: Option, /// Speed Index — heuristic proxy: 0.35·FCP + 0.65·LCP (#137) pub speed_index: Option, - /// Time to Interactive — simplified: max(domInteractive, last long-task end) (#137) + /// Time to Interactive, lab proxy: end of the last long task before the + /// first 5 s quiet window after FCP, at least domInteractive (#137, #770). + /// Lighthouse also waits for network quiet; marked estimated. pub tti: Option, /// Interaction to Next Paint — only populated when interaction events fire during audit #[serde(skip_serializing_if = "Option::is_none")] @@ -184,60 +226,39 @@ impl WebVitals { pub async fn prepare_vitals_collection(page: &Page) -> Result<()> { let script = r#" (function() { + window.__ams_observers = true; window.__ams_tbt_available = false; window.__ams_cls_available = false; - // 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. + + // LCP observer — the latest candidate is the LCP. Candidates stop with + // the first user input, which a lab run never fires, so the value is the + // last candidate before the read (Lighthouse: before the trace ends). + // There is deliberately no load-event cut-off: a hero rendered after + // load *is* the largest contentful paint (#770). 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; - } + 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(); + // Long tasks, raw. TTI and TBT are computed from this list in Rust over + // the reference window FCP→TTI (#770). + window.__ams_long_tasks = window.__ams_long_tasks || []; + window.__ams_long_task_seen = window.__ams_long_task_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; - } + if (window.__ams_long_task_seen.has(e.startTime)) continue; + window.__ams_long_task_seen.add(e.startTime); + window.__ams_long_tasks.push({ start: e.startTime, duration: e.duration }); } }).observe({ type: 'longtask', buffered: true }); window.__ams_tbt_available = PerformanceObserver.supportedEntryTypes.includes('longtask'); @@ -324,60 +345,114 @@ pub async fn prepare_vitals_collection(page: &Page) -> Result<()> { /// Read the pre-injected globals after navigation to collect LCP, TBT, CLS, TTI, INP. /// /// Flushes and disconnects the LCP observer to capture any buffered entries. -async fn collect_preinjected_vitals(page: &Page) -> Result { - let js = r#" +/// The observers' globals. `null` when they were never installed — then +/// nothing was observed, which is a failed collection, not a page without +/// long tasks or shifts (#769). +const READ_OBSERVERS_JS: &str = r#" (function() { + if (window.__ams_observers !== true) return null; // 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 = 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, + lcp: window.__ams_lcp || 0, + longTasks: window.__ams_long_tasks || [], tbtAvailable: window.__ams_tbt_available === true, cls: window.__ams_cls || 0, clsAvailable: window.__ams_cls_available === true, - lastTaskEnd: window.__ams_last_task_end || 0, clsShifts: window.__ams_cls_shifts || [], inpDuration: window.__ams_inp || 0 }); })() "#; - let result = page - .evaluate(js) +async fn collect_preinjected_vitals(page: &Page) -> Collected { + evaluate_js::(page, READ_OBSERVERS_JS) .await - .map_err(|e| AuditError::CdpError(format!("Preinjected vitals read failed: {}", e)))?; - - let parsed = - crate::util::decode_collector_object(result.value(), "Performance observer extraction")?; - - 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), - tbt_available: parsed["tbtAvailable"].as_bool().unwrap_or(false), - cls_available: parsed["clsAvailable"].as_bool().unwrap_or(false), - 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), - }) + .map(PreinjectedVitals::from) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawObservers { + lcp: f64, + long_tasks: Vec, + tbt_available: bool, + cls: f64, + cls_available: bool, + cls_shifts: serde_json::Value, + inp_duration: f64, +} + +impl From for PreinjectedVitals { + fn from(raw: RawObservers) -> Self { + Self { + lcp: raw.lcp, + long_tasks: raw.long_tasks, + tbt_available: raw.tbt_available, + cls_available: raw.cls_available, + cls: raw.cls, + cls_shifts: parse_cls_shifts(&raw.cls_shifts), + inp_duration: raw.inp_duration, + } + } +} + +/// One long task as the `longtask` observer reports it (ms since navigation). +#[derive(Debug, Clone, Copy, PartialEq, Deserialize)] +pub(super) struct LongTask { + pub start: f64, + pub duration: f64, +} + +impl LongTask { + fn end(&self) -> f64 { + self.start + self.duration + } +} + +/// Lighthouse's quiet window: TTI is the end of the last long task before +/// the first 5 s without long tasks after FCP. +const TTI_QUIET_WINDOW_MS: f64 = 5_000.0; +/// A task blocks the main thread for the part of it beyond 50 ms. +const BLOCKING_THRESHOLD_MS: f64 = 50.0; + +/// Time to Interactive as a lab proxy of the Lighthouse definition: the end +/// of the last long task before the first 5 s quiet window after FCP, and +/// at least FCP and `domInteractive`. Lighthouse also requires network +/// quiet, which this does not observe — the value stays marked estimated. +pub(super) fn estimate_tti(fcp: f64, tasks: &[LongTask], dom_interactive: f64) -> f64 { + let mut ordered: Vec<&LongTask> = tasks.iter().filter(|t| t.end() > fcp).collect(); + ordered.sort_by(|a, b| a.start.total_cmp(&b.start)); + let mut tti = fcp; + for task in ordered { + if task.start - tti >= TTI_QUIET_WINDOW_MS { + break; + } + tti = tti.max(task.end()); + } + tti.max(dom_interactive) +} + +/// Total Blocking Time over the reference window FCP→TTI: each long task is +/// clipped to the window, and the part of the clipped task beyond 50 ms +/// blocks. Tasks before FCP or after TTI do not count (#770). +pub(super) fn total_blocking_time(fcp: f64, tti: f64, tasks: &[LongTask]) -> f64 { + tasks + .iter() + .map(|task| { + let clipped = task.end().min(tti) - task.start.max(fcp); + (clipped - BLOCKING_THRESHOLD_MS).max(0.0) + }) + .sum() } /// Parse CLS shift entries from the JSON value returned by the browser. @@ -484,12 +559,11 @@ fn parse_shift_rect(val: &serde_json::Value) -> Option { /// Values collected by the pre-injected PerformanceObserver script. struct PreinjectedVitals { lcp: f64, - tbt: f64, + /// Every long task observed since navigation. + long_tasks: Vec, tbt_available: bool, cls_available: bool, cls: f64, - /// End time of the last long task (ms from navigation start); 0 = no long tasks - last_task_end: f64, /// Per-shift CLS entries (#135) cls_shifts: Vec, /// Max event/interaction duration captured by event-timing or first-input (#134) @@ -514,10 +588,11 @@ pub async fn finalize_lcp(page: &Page) -> Result> { /// **Phase 1 – LCP**: polls `window.__ams_lcp` every 250 ms (up to ~3 s) /// until an LCP entry is captured. /// -/// **Phase 2 – TBT settle**: JS bundles execute *after* LCP fires, so Long Tasks -/// are not yet visible when LCP > 0. Polls `window.__ams_tbt` every 200 ms and -/// breaks only when the value is stable for three consecutive reads (≥ 600 ms -/// unchanged). Max 5 s — matches Lighthouse's quiet-window heuristic. +/// **Phase 2 – CPU quiet**: JS bundles execute *after* LCP fires, so long +/// tasks are not yet visible when LCP > 0. Polls the long-task count every +/// 200 ms and stops once it has not changed for 1 s — Lighthouse's +/// `cpuQuietThresholdMs` — or after 8 s. Late tasks inside that window are +/// observed; whether they count is decided by the FCP→TTI window (#770). async fn wait_for_vitals_stable(page: &Page) { // Phase 1: wait for first LCP entry for _ in 0..11u8 { @@ -533,29 +608,26 @@ async fn wait_for_vitals_stable(page: &Page) { tokio::time::sleep(std::time::Duration::from_millis(250)).await; } - // Phase 2: wait for TBT to stop growing (Long Tasks settle after LCP) - let mut last_tbt = -1.0_f64; + // Phase 2: wait until no new long task appeared for 1 s. + let mut last_count = -1.0_f64; let mut stable_ticks: u8 = 0; - for _ in 0..25u8 { - let tbt = page - .evaluate("window.__ams_tbt || 0") + for _ in 0..40u8 { + let count = page + .evaluate("(window.__ams_long_tasks || []).length") .await .ok() .and_then(|r| r.value().and_then(|v| v.as_f64())) .unwrap_or(0.0); - if (tbt - last_tbt).abs() < 1.0 { + if count == last_count { stable_ticks += 1; - if stable_ticks >= 3 { - debug!( - "TBT stable at {:.0}ms after {} settle ticks", - tbt, stable_ticks - ); + if stable_ticks >= 5 { + debug!("Main thread quiet with {count} long tasks"); break; } } else { stable_ticks = 0; } - last_tbt = tbt; + last_count = count; tokio::time::sleep(std::time::Duration::from_millis(200)).await; } } @@ -568,47 +640,39 @@ async fn wait_for_vitals_stable(page: &Page) { /// # Returns /// * `Ok(WebVitals)` - The extracted performance metrics /// * `Err(AuditError)` - If extraction fails -pub async fn extract_web_vitals(page: &Page) -> Result { +pub async fn extract_web_vitals(page: &Page, log: &CollectionLog) -> Result { info!("Extracting Core Web Vitals..."); wait_for_vitals_stable(page).await; let mut vitals = WebVitals::default(); - // Read pre-injected observer globals first (Lighthouse-style, captures from first paint). - // Falls back gracefully when prepare_vitals_collection was not called. - 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, - tbt_available: false, - cls_available: false, - cls: 0.0, - last_task_end: 0.0, - cls_shifts: vec![], - inp_duration: 0.0, - } + // Read pre-injected observer globals first (Lighthouse-style, captures + // from first paint). Without them LCP can still come from CDP, but CLS + // and TBT cannot be measured at all. + let observers = log + .record(&OBSERVERS, collect_preinjected_vitals(page).await) + .into_value(); + let observed = observers.is_some(); + let preinjected = observers.unwrap_or(PreinjectedVitals { + lcp: 0.0, + long_tasks: vec![], + tbt_available: false, + cls_available: false, + cls: 0.0, + cls_shifts: vec![], + inp_duration: 0.0, }); - let last_task_end = preinjected.last_task_end; - // Apply pre-injected LCP and TBT when non-zero (0 = not captured / not injected) + // LCP from the observer (0 = no candidate captured). 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); } - // TBT: 0 ms is a valid perfect score (no long tasks), always include like CLS. - vitals.tbt = preinjected - .tbt_available - .then(|| VitalMetric::new(preinjected.tbt, 200.0, 600.0)); - debug!("TBT (preinjected): {:.0}ms", preinjected.tbt); - // CLS: 0.0 is a valid perfect score, always apply. Use the session-window - // maximum (the current CWV definition) computed from the per-shift list, - // not the cumulative sum the observer accumulates — the latter overcounts - // pages that shift repeatedly during lazy-loading. + // CLS: 0.0 is a valid perfect score when the observer ran. Use the + // session-window maximum (the current CWV definition) computed from the + // per-shift list, not the cumulative sum the observer accumulates — the + // latter overcounts pages that shift repeatedly during lazy-loading. let cls_session = session_window_cls(&preinjected.cls_shifts); vitals.cls = preinjected .cls_available @@ -619,7 +683,7 @@ pub async fn extract_web_vitals(page: &Page) -> Result { ); // CLS attribution (#135) - vitals.cls_attribution = preinjected.cls_shifts; + vitals.cls_attribution = preinjected.cls_shifts.clone(); // INP (#134): event-timing or first-input duration; 0 = no interaction captured. // In headless audits interactions rarely fire, so 0 means "not measurable" and @@ -630,10 +694,22 @@ pub async fn extract_web_vitals(page: &Page) -> Result { } // Get performance metrics via CDP for FCP, DCL, heap, node count - let metrics_response = page - .execute(GetMetricsParams::default()) - .await - .map_err(|e| AuditError::CdpError(format!("Failed to get metrics: {}", e)))?; + let metrics_response = match page.execute(GetMetricsParams::default()).await { + Ok(response) => { + log.record(&CDP_METRICS, Collected::Measured(())); + response + } + Err(e) => { + log.record( + &CDP_METRICS, + Collected::<()>::Failed(crate::audit::collection::reason::CDP_COMMAND_FAILED), + ); + return Err(AuditError::CdpError(format!( + "Failed to get metrics: {}", + e + ))); + } + }; for metric in &metrics_response.metrics { let value = metric.value; @@ -669,62 +745,61 @@ pub async fn extract_web_vitals(page: &Page) -> Result { } } - // Extract additional metrics via JavaScript (FCP fallback, TTFB, load time) - 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); - } + // Navigation and paint timing (FCP/LCP fallback, TTFB, load, domInteractive). + let js_metrics = log + .record(&NAVIGATION_TIMING, extract_js_metrics(page).await) + .into_value(); + let mut dom_interactive_ms = 0.0; + if let Some(js_metrics) = js_metrics { + if vitals.fcp.is_none() { + vitals.fcp = js_metrics.vitals.fcp; + } + if vitals.lcp.is_none() { + vitals.lcp = js_metrics.vitals.lcp; + } + vitals.ttfb = js_metrics.vitals.ttfb; + vitals.load_time = js_metrics.vitals.load_time; + if vitals.dom_content_loaded.is_none() { + vitals.dom_content_loaded = js_metrics + .vitals + .dom_content_loaded + .filter(|dcl| *dcl > 0.0 && *dcl < 300_000.0); } + dom_interactive_ms = js_metrics.dom_interactive; } - // TTI (#137): max(domInteractive, last_long_task_end). - // domInteractive = domContentLoadedEventStart (navigation timing). - // Thresholds from Lighthouse: good ≤3800ms, poor >7300ms. - 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); + // TTI and TBT over the reference window FCP→TTI (#770). Both need FCP; + // TBT also needs the long-task observer. + if let Some(fcp) = vitals.fcp.as_ref().map(|m| m.value) { + let tti_ms = estimate_tti(fcp, &preinjected.long_tasks, dom_interactive_ms); + if tti_ms > 0.0 && tti_ms < 300_000.0 { + vitals.tti = Some(VitalMetric::new(tti_ms, 3800.0, 7300.0).estimated()); + debug!("TTI (lab proxy): {:.0}ms", tti_ms); + } + if preinjected.tbt_available { + let tbt = total_blocking_time(fcp, tti_ms, &preinjected.long_tasks); + vitals.tbt = Some(VitalMetric::new(tbt, 200.0, 600.0)); + debug!("TBT (FCP→TTI): {:.0}ms", tbt); + } } - // Speed Index (#137): heuristic proxy = 0.35·FCP + 0.65·LCP. - // Real Speed Index requires video-frame analysis; this approximation - // correlates well for typical content-heavy pages. - // Thresholds from Lighthouse: good ≤3400ms, poor >5800ms. + // Speed Index (#137): heuristic proxy = 0.35·FCP + 0.65·LCP. Real Speed + // Index requires video-frame analysis; this estimate is diagnostic and + // carries no score weight. 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); } + record_metric_states(log, &vitals, observed, &preinjected); + info!( - "Web Vitals extracted: LCP={:?}ms, FCP={:?}ms, CLS={:?}, TTI={:?}ms, INP={:?}ms", + "Web Vitals extracted: LCP={:?}ms, FCP={:?}ms, CLS={:?}, TBT={:?}ms, 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.tbt.as_ref().map(|v| v.value as i64), vitals.tti.as_ref().map(|v| v.value as i64), vitals.inp.as_ref().map(|v| v.value as i64), ); @@ -732,8 +807,73 @@ pub async fn extract_web_vitals(page: &Page) -> Result { Ok(vitals) } -/// Extract metrics via JavaScript Performance API -async fn extract_js_metrics(page: &Page) -> Result { +/// One record per core metric: measured, failed (with why) or unsupported +/// by the browser (#769). +fn record_metric_states( + log: &CollectionLog, + vitals: &WebVitals, + observed: bool, + preinjected: &PreinjectedVitals, +) { + let state = |metric: &Option, missing: Collected<()>| match metric { + Some(m) if m.value.is_finite() && m.value >= 0.0 => Collected::Measured(()), + Some(_) => Collected::Failed("invalid_metric_value"), + None => missing, + }; + let observer_missing = |supported: bool| { + if !observed { + Collected::Failed("observer_not_installed") + } else if !supported { + Collected::Unsupported("entry_type_not_supported") + } else { + Collected::Failed("metric_not_captured") + } + }; + log.record( + &LCP, + state(&vitals.lcp, Collected::Failed("metric_not_captured")), + ); + log.record( + &FCP, + state(&vitals.fcp, Collected::Failed("metric_not_captured")), + ); + log.record( + &CLS, + state(&vitals.cls, observer_missing(preinjected.cls_available)), + ); + log.record( + &TBT, + state( + &vitals.tbt, + if vitals.fcp.is_none() && observed && preinjected.tbt_available { + Collected::Failed("fcp_not_captured") + } else { + observer_missing(preinjected.tbt_available) + }, + ), + ); +} + +/// Navigation and paint timing read via the Performance API. +struct JsMetrics { + vitals: WebVitals, + dom_interactive: f64, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawNavigationTiming { + ttfb: Option, + load_time: Option, + dom_content_loaded: Option, + dom_interactive: Option, + fcp: Option, + lcp: Option, +} + +/// Extract metrics via JavaScript Performance API. A missing or malformed +/// result is a failed collection, not a page without timing (#769). +async fn extract_js_metrics(page: &Page) -> Collected { let js_code = r#" (() => { const result = {}; @@ -742,6 +882,7 @@ async fn extract_js_metrics(page: &Page) -> Result { const nav = performance.getEntriesByType('navigation')[0]; if (nav) { result.ttfb = nav.responseStart - nav.requestStart; + result.domInteractive = nav.domInteractive; if (nav.loadEventEnd > 0) { result.loadTime = nav.loadEventEnd - nav.startTime; } @@ -768,34 +909,22 @@ async fn extract_js_metrics(page: &Page) -> Result { })() "#; - let js_result = page - .evaluate(js_code) + evaluate_js::(page, 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) + .map(|raw| { + let vitals = WebVitals { + lcp: raw.lcp.map(|lcp| VitalMetric::new(lcp, 2500.0, 4000.0)), + fcp: raw.fcp.map(|fcp| VitalMetric::new(fcp, 1800.0, 3000.0)), + ttfb: raw.ttfb.map(|ttfb| VitalMetric::new(ttfb, 800.0, 1800.0)), + load_time: raw.load_time, + dom_content_loaded: raw.dom_content_loaded, + ..Default::default() + }; + JsMetrics { + vitals, + dom_interactive: raw.dom_interactive.unwrap_or(0.0), + } + }) } /// Detect implausible or structurally unmeasurable metrics in a headless CDP audit. @@ -805,7 +934,6 @@ async fn extract_js_metrics(page: &Page) -> Result { /// /// Warning keys (issue #291): /// - `"lcp_not_measured"` — LCP absent (observer missed it, e.g. under LhMobile throttle) -/// - `"tbt_zero_heavy_page"` — TBT=0 while LCP>3000ms (physically impossible) /// - `"speed_index_fallback_to_lcp"` — Speed Index equals LCP (no independent value) /// - `"tti_fallback_to_lcp"` — TTI equals LCP (no independent value) /// - `"inp_not_measured"` — INP absent (headless has no user interaction) @@ -816,11 +944,8 @@ pub fn validate_metrics(vitals: &WebVitals) -> Vec { 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()); - } - } + // No "TBT = 0 on a slow page" warning: a static page on a slow network + // has a late LCP and no long tasks at all (#770). if let (Some(si), Some(lcp)) = (&vitals.speed_index, &vitals.lcp) { if (si.value - lcp.value).abs() < f64::EPSILON { @@ -841,10 +966,121 @@ pub fn validate_metrics(vitals: &WebVitals) -> Vec { warnings } +/// Counter-cases of the vitals collectors (#769): observers that never ran +/// leave CLS and TBT failed, not zero; a browser without the entry type +/// leaves them unsupported; the script result decodes strictly. +#[cfg(test)] +pub(crate) fn counter_cases() { + use crate::audit::collection::{assert_js_contract, CollectionState}; + assert_js_contract::(serde_json::json!({ + "lcp": 0, "longTasks": [], "tbtAvailable": true, "cls": 0, + "clsAvailable": true, "clsShifts": [], "inpDuration": 0 + })); + assert_js_contract::(serde_json::json!({})); + + let states = |vitals: &WebVitals, observed: bool, supported: bool| { + let log = CollectionLog::new(); + let preinjected = PreinjectedVitals { + lcp: 0.0, + long_tasks: vec![], + tbt_available: supported, + cls_available: supported, + cls: 0.0, + cls_shifts: vec![], + inp_duration: 0.0, + }; + record_metric_states(&log, vitals, observed, &preinjected); + log.into_records() + .into_iter() + .map(|r| (r.collector, r.state, r.reason_code)) + .collect::>() + }; + let none = WebVitals::default(); + for (collector, state, reason) in states(&none, false, false) { + assert_eq!(state, CollectionState::Failed, "{collector}"); + if collector == "performance.cls" || collector == "performance.tbt" { + assert_eq!(reason.as_deref(), Some("observer_not_installed")); + } + } + let unsupported = states(&none, true, false); + assert!(unsupported + .iter() + .any(|(c, s, _)| c == "performance.cls" && *s == CollectionState::Unsupported)); + let measured = WebVitals { + lcp: Some(VitalMetric::new(1000.0, 2500.0, 4000.0)), + fcp: Some(VitalMetric::new(500.0, 1800.0, 3000.0)), + cls: Some(VitalMetric::new(0.0, 0.1, 0.25)), + tbt: Some(VitalMetric::new(0.0, 200.0, 600.0)), + ..Default::default() + }; + assert!(states(&measured, true, true) + .iter() + .all(|(_, s, _)| *s == CollectionState::Measured)); + let invalid = WebVitals { + cls: Some(VitalMetric::new(f64::NAN, 0.1, 0.25)), + ..measured + }; + assert!(states(&invalid, true, true) + .iter() + .any(|(c, s, _)| c == "performance.cls" && *s == CollectionState::Failed)); +} + #[cfg(test)] mod tests { use super::*; + fn task(start: f64, duration: f64) -> LongTask { + LongTask { start, duration } + } + + #[test] + fn tti_ends_with_the_last_task_before_a_quiet_window() { + // FCP 1000; tasks end at 1300 and 2500; a task 6 s later lies after + // the first 5 s quiet window and does not move TTI. + let tasks = [ + task(1200.0, 100.0), + task(2300.0, 200.0), + task(8000.0, 300.0), + ]; + assert_eq!(estimate_tti(1000.0, &tasks, 900.0), 2500.0); + // No long task after FCP: TTI is FCP (or domInteractive, if later). + assert_eq!(estimate_tti(1000.0, &[task(200.0, 300.0)], 1200.0), 1200.0); + } + + #[test] + fn tbt_counts_only_the_fcp_to_tti_window() { + let tasks = [ + task(0.0, 400.0), // before FCP (500): not counted + task(450.0, 150.0), // clipped to 500–600: 100 ms → 50 blocking + task(1000.0, 120.0), // 70 blocking + task(9000.0, 500.0), // after TTI: not counted + ]; + let tti = estimate_tti(500.0, &tasks, 0.0); + assert_eq!(tti, 1120.0); + assert_eq!(total_blocking_time(500.0, tti, &tasks), 120.0); + } + + #[test] + fn late_long_task_after_load_still_blocks() { + // #770: a task long after `load` but before the quiet window counts — + // the old load+100 ms cut-off dropped it. + let tasks = [task(3_000.0, 250.0)]; + let tti = estimate_tti(800.0, &tasks, 600.0); + assert_eq!(total_blocking_time(800.0, tti, &tasks), 200.0); + } + + #[test] + fn slow_static_page_is_no_tbt_warning() { + // #770: a late LCP without long tasks is a valid measurement. + let vitals = WebVitals { + lcp: Some(VitalMetric::new(4_500.0, 2500.0, 4000.0)), + tbt: Some(VitalMetric::new(0.0, 200.0, 600.0)), + inp: Some(VitalMetric::new(50.0, 200.0, 500.0)), + ..Default::default() + }; + assert!(validate_metrics(&vitals).is_empty()); + } + #[test] fn test_vital_metric_good() { let metric = VitalMetric::new(1500.0, 2500.0, 4000.0); @@ -966,24 +1202,6 @@ mod tests { ); } - #[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; diff --git a/src/util.rs b/src/util.rs index 8a676b56..286661b5 100644 --- a/src/util.rs +++ b/src/util.rs @@ -5,65 +5,6 @@ use std::time::Duration; use reqwest::header::{HeaderMap, HeaderValue}; use reqwest::Client; -/// Decode a collector's JSON string. Missing, malformed or scalar results -/// are technical failures, never a successfully measured empty document. -pub(crate) fn decode_collector( - value: Option<&serde_json::Value>, - context: &str, -) -> crate::error::Result { - let fail = |reason: &str| crate::error::AuditError::CdpError(format!("{context}: {reason}")); - let text = value - .and_then(serde_json::Value::as_str) - .ok_or_else(|| fail("collector returned no JSON string"))?; - let decoded: serde_json::Value = - serde_json::from_str(text).map_err(|_| fail("collector returned invalid JSON"))?; - if !decoded.is_object() && !decoded.is_array() { - return Err(fail("collector returned no structured result")); - } - serde_json::from_value(decoded) - .map_err(|_| fail("collector result does not match its contract")) -} - -pub(crate) fn decode_collector_object( - value: Option<&serde_json::Value>, - context: &str, -) -> crate::error::Result { - let decoded: serde_json::Value = decode_collector(value, context)?; - if !decoded.is_object() { - return Err(crate::error::AuditError::CdpError(format!( - "{context}: collector returned no object" - ))); - } - Ok(decoded) -} - -#[cfg(test)] -mod collector_contract_tests { - use super::*; - - #[test] - fn foundation_collector_failure_is_not_empty_success() { - for value in [ - None, - Some(serde_json::Value::Null), - Some(serde_json::json!("bad JSON")), - Some(serde_json::json!("null")), - Some(serde_json::json!("[]")), - ] { - assert!(decode_collector_object(value.as_ref(), "test").is_err()); - } - assert_eq!( - decode_collector_object(Some(&serde_json::json!("{}")), "test").unwrap(), - serde_json::json!({}) - ); - assert!( - decode_collector::>(Some(&serde_json::json!("[]")), "test") - .unwrap() - .is_empty() - ); - } -} - pub const BROWSER_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; /// Build a browser-like header set that passes basic bot-detection heuristics. diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 5c49e041..5884f3b0 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -48,7 +48,8 @@ async fn foundation_missing_observers_are_not_perfect_zero_metrics() { let manager = ci_browser().await; let unprepared = manager.new_page().await.unwrap(); manager.navigate(&unprepared, &fixture.url).await.unwrap(); - let missing = extract_web_vitals(&unprepared).await.unwrap(); + let log = auditmysite::audit::collection::CollectionLog::new(); + let missing = extract_web_vitals(&unprepared, &log).await.unwrap(); assert!( missing.tbt.is_none(), "no observer must not mean zero blocking time" @@ -57,12 +58,41 @@ async fn foundation_missing_observers_are_not_perfect_zero_metrics() { missing.cls.is_none(), "no observer must not mean zero layout shift" ); + // #769: the state of each metric says why it is missing. + let records = log.into_records(); + let state = |id: &str| { + records + .iter() + .find(|r| r.collector == id) + .map(|r| (r.state, r.reason_code.clone())) + .unwrap_or_else(|| panic!("no record for {id}: {records:?}")) + }; + use auditmysite::audit::collection::CollectionState; + assert_eq!(state("performance.observers").0, CollectionState::Failed); + for metric in ["performance.tbt", "performance.cls"] { + assert_eq!( + state(metric), + ( + CollectionState::Failed, + Some("observer_not_installed".to_string()) + ) + ); + } unprepared.close().await.unwrap(); let prepared = manager.new_page().await.unwrap(); prepare_vitals_collection(&prepared).await.unwrap(); manager.navigate(&prepared, &fixture.url).await.unwrap(); - let measured = extract_web_vitals(&prepared).await.unwrap(); + let log = auditmysite::audit::collection::CollectionLog::new(); + let measured = extract_web_vitals(&prepared, &log).await.unwrap(); + assert!(log + .into_records() + .iter() + .filter( + |r| ["performance.tbt", "performance.cls", "performance.fcp"] + .contains(&r.collector.as_str()) + ) + .all(|r| r.state == CollectionState::Measured)); assert!( measured.tbt.is_some(), "supported observer must preserve a measured zero" @@ -75,6 +105,111 @@ async fn foundation_missing_observers_are_not_perfect_zero_metrics() { fixture.stop(); } +/// Load a fixture with the vitals observers and return its web vitals. +async fn vitals_of( + manager: &BrowserManager, + html: String, + slow_network: bool, +) -> auditmysite::performance::WebVitals { + use auditmysite::performance::{extract_web_vitals, prepare_vitals_collection}; + let fixture = common::fixture_server::serve_html(html); + let page = manager.new_page().await.unwrap(); + if slow_network { + auditmysite::browser::throttle::apply_throttling( + &page, + auditmysite::browser::ThrottleProfile::Slow3G, + ) + .await + .unwrap(); + } + prepare_vitals_collection(&page).await.unwrap(); + manager.navigate(&page, &fixture.url).await.unwrap(); + let vitals = extract_web_vitals(&page, &auditmysite::audit::collection::CollectionLog::new()) + .await + .unwrap(); + page.close().await.unwrap(); + fixture.stop(); + vitals +} + +/// #770: LCP is the last candidate, also one painted after `load` — the old +/// load+100 ms cut-off kept the small initial text as LCP. +#[tokio::test] +#[ignore = "needs Chrome"] +async fn lcp_includes_a_hero_rendered_after_load() { + let manager = ci_browser().await; + let vitals = vitals_of( + &manager, + r#"Late hero

Loading

+ "# + .into(), + false, + ) + .await; + let load = vitals.load_time.expect("load time"); + let lcp = vitals.lcp.expect("lcp").value; + assert!( + lcp > load + 400.0, + "LCP {lcp} must include the late hero (load {load})" + ); +} + +/// #770: a long task after `load` but before the quiet window blocks — the +/// old cut-off dropped every task that started after load+100 ms. +#[tokio::test] +#[ignore = "needs Chrome"] +async fn tbt_counts_a_long_task_after_load() { + let manager = ci_browser().await; + let vitals = vitals_of( + &manager, + r#"Late task

Late task

+ "# + .into(), + false, + ) + .await; + let tbt = vitals.tbt.expect("tbt").value; + assert!( + tbt >= 150.0, + "the 300 ms task after load must block: TBT {tbt}" + ); +} + +/// #770: a static page on a slow network has a late LCP and no long tasks. +/// TBT 0 is a measurement, and no warning calls it implausible. +#[tokio::test] +#[ignore = "needs Chrome"] +async fn slow_static_page_measures_zero_blocking_time() { + let manager = ci_browser().await; + // A render-blocking stylesheet on the throttled connection holds the + // first paint until it has arrived (the fixture answers every path with + // the same ~110 KB document). No script runs at any point. + let body = "Static text without any script. ".repeat(3500); + let vitals = vitals_of( + &manager, + format!( + "Slow static\ +

{body}

" + ), + true, + ) + .await; + assert_eq!(vitals.tbt.as_ref().map(|m| m.value), Some(0.0)); + let lcp = vitals.lcp.as_ref().expect("lcp").value; + assert!(lcp > 1000.0, "slow transfer must delay LCP: {lcp}"); + assert!(auditmysite::performance::validate_metrics(&vitals) + .iter() + .all(|w| !w.contains("tbt"))); +} + #[tokio::test] #[ignore = "needs Chrome"] async fn foundation_desktop_critical_caps_raw_and_exported_accessibility() { diff --git a/tests/report_consistency_tests.rs b/tests/report_consistency_tests.rs index 2c8ac65f..2adb248e 100644 --- a/tests/report_consistency_tests.rs +++ b/tests/report_consistency_tests.rs @@ -1589,7 +1589,7 @@ fn test_json_report_includes_report_artifact_fields() { lcp_ms: Some(3200.0), tbt_ms: Some(180.0), cls: Some(0.03), - score: 72, + score: Some(72), }]; report.patterns = Some(auditmysite::patterns::PatternAnalysis { recognized: vec![auditmysite::patterns::RecognizedPattern {