use std::time::{Duration, Instant};
use chromiumoxide::Page;
use tracing::{debug, info, warn};
use super::artifacts::{
content_hash, save_artifacts, AuditArtifacts, CacheMeta, FetchArtifact, SnapshotArtifact,
};
use super::normalize;
use super::report::{
AuditReport, DualViewportResults, PerformanceResults, ViewportAuditData, ViewportScoreSet,
ViewportScores,
};
use crate::accessibility::{enrich_violations_with_page, extract_ax_tree, AXTree};
use crate::audit::scoring::AccessibilityScorer;
use crate::browser::{throttle, BrowserManager, ThrottleProfile};
use crate::cli::{Args, WcagLevel};
use crate::dark_mode::{analyze_dark_mode, DarkModeAnalysis};
use crate::error::Result;
use crate::journey::{analyze_journey, JourneyAnalysis};
use crate::mobile::{analyze_mobile_friendliness, MobileFriendliness};
use crate::performance::{
analyze_content_weight, analyze_render_blocking, calculate_performance_score,
extract_web_vitals, prepare_vitals_collection,
};
use crate::security::{analyze_security, SecurityAnalysis};
use crate::seo::{analyze_seo, SeoAnalysis};
use crate::ux::{analyze_ux, UxAnalysis};
use crate::wcag::{self, Violation, WcagResults};
#[derive(Debug, Clone, Copy)]
enum Viewport {
Desktop,
Mobile,
}
async fn set_viewport(page: &Page, viewport: Viewport) -> Result<()> {
use chromiumoxide::cdp::browser_protocol::emulation::SetDeviceMetricsOverrideParams;
let params = match viewport {
Viewport::Desktop => SetDeviceMetricsOverrideParams::builder()
.mobile(false)
.width(1280_i64)
.height(800_i64)
.device_scale_factor(1.0_f64)
.build()
.unwrap(),
Viewport::Mobile => SetDeviceMetricsOverrideParams::builder()
.mobile(true)
.width(390_i64)
.height(844_i64)
.device_scale_factor(2.0_f64)
.build()
.unwrap(),
};
page.execute(params)
.await
.map_err(|e| crate::error::AuditError::NavigationFailed {
url: "viewport-set".to_string(),
reason: e.to_string(),
})?;
tokio::time::sleep(Duration::from_millis(200)).await;
Ok(())
}
#[derive(Debug, Clone)]
struct SnapshotData {
ax_tree: AXTree,
performance: Option<PerformanceResults>,
seo: Option<SeoAnalysis>,
security: Option<SecurityAnalysis>,
mobile: Option<MobileFriendliness>,
ux: Option<UxAnalysis>,
journey: Option<JourneyAnalysis>,
dark_mode: Option<DarkModeAnalysis>,
tech_stack: Option<crate::tech_stack::TechStackAnalysis>,
}
#[derive(Debug, Clone)]
pub struct PipelineConfig {
pub wcag_level: WcagLevel,
pub timeout_secs: u64,
pub verbose: bool,
pub check_performance: bool,
pub check_seo: bool,
pub check_security: bool,
pub check_mobile: bool,
pub check_dark_mode: bool,
pub check_stack: bool,
pub persist_artifacts: bool,
pub capture_screenshots: bool,
}
impl From<&Args> for PipelineConfig {
fn from(args: &Args) -> Self {
let full_audit = args.full_audit_enabled();
Self {
wcag_level: args.level,
timeout_secs: args.effective_timeout(),
verbose: args.verbose,
check_performance: (full_audit || args.performance) && !args.skip_performance,
check_seo: full_audit || args.seo,
check_security: full_audit || args.security,
check_mobile: (full_audit || args.mobile) && !args.skip_mobile,
check_dark_mode: true,
check_stack: full_audit || args.stack,
persist_artifacts: true,
capture_screenshots: args.url.is_some()
&& matches!(args.format, None | Some(crate::cli::OutputFormat::Pdf)),
}
}
}
pub async fn run_single_audit(
url: &str,
browser: &BrowserManager,
config: &PipelineConfig,
) -> Result<AuditReport> {
let start_time = Instant::now();
info!("Starting audit for: {}", url);
let page = browser.new_page().await?;
debug!("Created new page");
let mut report = audit_page(&page, url, config, browser).await?;
if config.check_performance {
report.throttled_performance =
collect_throttled_performance(&page, url, browser, config).await;
}
if config.capture_screenshots {
match capture_page_screenshots(&page).await {
Ok(shots) => {
report.page_screenshots = Some(shots);
report.screenshot_status = crate::audit::ScreenshotStatus::Captured;
}
Err(e) => {
warn!("Screenshot capture failed (continuing without): {}", e);
report.screenshot_status = crate::audit::ScreenshotStatus::Failed(e.to_string());
}
}
} else {
report.screenshot_status = crate::audit::ScreenshotStatus::NotRequested;
}
let duration = start_time.elapsed();
info!(
"Audit completed for {} in {:?} (score: {})",
url, duration, report.score
);
Ok(report)
}
pub async fn audit_page(
page: &Page,
url: &str,
config: &PipelineConfig,
browser: &BrowserManager,
) -> Result<AuditReport> {
let start_time = Instant::now();
let security: Option<SecurityAnalysis> = if config.check_security {
match analyze_security(url).await {
Ok(s) => Some(s),
Err(e) => {
warn!("Security analysis failed: {}", e);
None
}
}
} else {
None
};
info!("Desktop pass starting for {}", url);
set_viewport(page, Viewport::Desktop).await?;
if config.check_performance {
if let Err(e) = prepare_vitals_collection(page).await {
warn!("Vitals observer injection failed (desktop): {}", e);
}
}
browser.navigate(page, url).await?;
let desktop_config = PipelineConfig {
check_performance: config.check_performance,
check_seo: false,
check_security: false,
check_mobile: false,
check_dark_mode: true,
check_stack: false, ..config.clone()
};
let desktop_snap = extract_snapshot(page, url, &desktop_config).await?;
let desktop_wcag = run_rules(page, &desktop_snap, config).await;
info!("Mobile pass starting for {}", url);
set_viewport(page, Viewport::Mobile).await?;
if config.check_performance {
if let Err(e) = prepare_vitals_collection(page).await {
warn!("Vitals observer injection failed (mobile): {}", e);
}
}
browser.navigate(page, url).await?;
let mobile_config = PipelineConfig {
check_performance: config.check_performance,
check_seo: config.check_seo,
check_security: false,
check_mobile: config.check_mobile,
check_dark_mode: false, check_stack: config.check_stack,
..config.clone()
};
let mobile_snap = extract_snapshot(page, url, &mobile_config).await?;
let mut mobile_wcag = run_rules(page, &mobile_snap, config).await;
if matches!(config.wcag_level, WcagLevel::AA | WcagLevel::AAA) {
info!("Running reflow check at 320 CSS px...");
let reflow_violations = wcag::check_reflow_with_page(page).await;
if !reflow_violations.is_empty() {
info!("Found reflow violation at 320px");
}
mobile_wcag.extend_findings(reflow_violations);
let _ = set_viewport(page, Viewport::Mobile).await;
}
let merged_wcag = merge_wcag_violations(&desktop_wcag, &mobile_wcag);
let desktop_acc = AccessibilityScorer::calculate_score(&desktop_wcag.violations);
let mobile_acc = AccessibilityScorer::calculate_score(&mobile_wcag.violations);
let desktop_perf_score = desktop_snap.performance.as_ref().map(|p| p.score.overall);
let mobile_perf_score = mobile_snap.performance.as_ref().map(|p| p.score.overall);
let mobile_seo_score = mobile_snap.seo.as_ref().map(|s| s.score);
let mobile_mf_score = mobile_snap.mobile.as_ref().map(|m| m.score);
let desktop_overall = compute_viewport_overall(desktop_acc, desktop_perf_score, None, None);
let mobile_overall = compute_viewport_overall(
mobile_acc,
mobile_perf_score,
mobile_seo_score,
mobile_mf_score,
);
let weighted_overall =
(mobile_overall as f64 * 0.7 + desktop_overall as f64 * 0.3).round() as u32;
let viewport_scores = ViewportScores {
desktop: ViewportScoreSet {
accessibility: desktop_acc.round() as u32,
performance: desktop_perf_score,
overall: desktop_overall,
},
mobile: ViewportScoreSet {
accessibility: mobile_acc.round() as u32,
performance: mobile_perf_score,
overall: mobile_overall,
},
weighted_overall,
};
let dual_viewport = DualViewportResults {
desktop: ViewportAuditData {
wcag_results: desktop_wcag,
accessibility_score: desktop_acc,
performance: desktop_snap.performance.clone(),
seo: None,
mobile: None,
ux: None,
journey: None,
},
mobile: ViewportAuditData {
wcag_results: mobile_wcag,
accessibility_score: mobile_acc,
performance: mobile_snap.performance.clone(),
seo: mobile_snap.seo.clone(),
mobile: mobile_snap.mobile.clone(),
ux: mobile_snap.ux.clone(),
journey: mobile_snap.journey.clone(),
},
};
let primary_snap = SnapshotData {
ax_tree: mobile_snap.ax_tree.clone(),
performance: mobile_snap.performance.clone(),
seo: mobile_snap.seo.clone(),
security,
mobile: mobile_snap.mobile.clone(),
ux: mobile_snap.ux.clone(),
journey: mobile_snap.journey.clone(),
dark_mode: desktop_snap.dark_mode.clone(), tech_stack: mobile_snap.tech_stack.clone(),
};
let mut report = aggregate_report(
url,
config,
&primary_snap,
merged_wcag,
start_time.elapsed().as_millis() as u64,
);
report.dual_viewport = Some(dual_viewport);
report.viewport_scores = Some(viewport_scores);
if config.persist_artifacts {
persist_artifacts(url, &primary_snap, &report);
}
Ok(report)
}
fn compute_viewport_overall(
acc: f32,
perf: Option<u32>,
seo: Option<u32>,
mobile: Option<u32>,
) -> u32 {
let mut weighted = acc as f64 * 40.0;
let mut total = 40.0;
if let Some(p) = perf {
weighted += p as f64 * 20.0;
total += 20.0;
}
if let Some(s) = seo {
weighted += s as f64 * 20.0;
total += 20.0;
}
if let Some(m) = mobile {
weighted += m as f64 * 10.0;
total += 10.0;
}
(weighted / total).round() as u32
}
fn merge_wcag_violations(desktop: &WcagResults, mobile: &WcagResults) -> WcagResults {
fn dedup_key(v: &Violation) -> (&str, String) {
let id = v
.selector
.as_deref()
.filter(|s| !s.is_empty())
.map(|s| s.to_owned())
.unwrap_or_else(|| v.node_id.clone());
(v.rule.as_str(), id)
}
let mut merged: Vec<Violation> = Vec::new();
let mut desktop_matched = vec![false; desktop.violations.len()];
for mv in &mobile.violations {
let mk = dedup_key(mv);
let match_idx = desktop.violations.iter().position(|dv| dedup_key(dv) == mk);
if let Some(idx) = match_idx {
desktop_matched[idx] = true;
let mut shared = mv.clone();
shared.tags.push("both-viewports".to_string());
merged.push(shared);
} else {
let mut mobile_only = mv.clone();
mobile_only.tags.push("mobile-only".to_string());
merged.push(mobile_only);
}
}
for (i, dv) in desktop.violations.iter().enumerate() {
if !desktop_matched[i] {
let mut desktop_only = dv.clone();
desktop_only.tags.push("desktop-only".to_string());
merged.push(desktop_only);
}
}
let mut warnings = mobile.warnings.clone();
warnings.extend(desktop.warnings.iter().cloned());
warnings.dedup_by(|a, b| a.message == b.message);
let mut positives = mobile.positives.clone();
positives.extend(desktop.positives.iter().cloned());
positives.dedup_by(|a, b| a.message == b.message);
let mut not_testables = mobile.not_testables.clone();
not_testables.extend(desktop.not_testables.iter().cloned());
not_testables.dedup_by(|a, b| a.message == b.message);
WcagResults {
violations: merged,
warnings,
positives,
not_testables,
passes: mobile.passes.max(desktop.passes),
incomplete: mobile.incomplete.max(desktop.incomplete),
nodes_checked: mobile.nodes_checked.max(desktop.nodes_checked),
}
}
async fn capture_page_screenshots(
page: &Page,
) -> crate::error::Result<crate::audit::report::PageScreenshots> {
use chromiumoxide::cdp::browser_protocol::emulation::{
ClearDeviceMetricsOverrideParams, SetDeviceMetricsOverrideParams,
};
use chromiumoxide::page::ScreenshotParams;
page.execute(
SetDeviceMetricsOverrideParams::builder()
.mobile(false)
.width(1280_i64)
.height(960_i64)
.device_scale_factor(1.0_f64)
.build()
.unwrap(),
)
.await
.map_err(|e| crate::error::AuditError::NavigationFailed {
url: "viewport-desktop".to_string(),
reason: e.to_string(),
})?;
tokio::time::sleep(Duration::from_millis(350)).await;
let _ = page
.evaluate(
"window.scrollTo(0,0);\
document.documentElement.scrollTop=0;\
document.body.scrollTop=0;\
if(document.scrollingElement)document.scrollingElement.scrollTop=0;",
)
.await;
tokio::time::sleep(Duration::from_millis(150)).await;
let desktop = page
.screenshot(ScreenshotParams::default())
.await
.map_err(|e| crate::error::AuditError::NavigationFailed {
url: "screenshot-desktop".to_string(),
reason: e.to_string(),
})?;
page.execute(
SetDeviceMetricsOverrideParams::builder()
.mobile(true)
.width(390_i64)
.height(844_i64)
.device_scale_factor(2.0_f64)
.build()
.unwrap(),
)
.await
.map_err(|e| crate::error::AuditError::NavigationFailed {
url: "viewport-mobile".to_string(),
reason: e.to_string(),
})?;
tokio::time::sleep(Duration::from_millis(450)).await;
let _ = page
.evaluate(
"window.scrollTo(0,0);\
document.documentElement.scrollTop=0;\
document.body.scrollTop=0;\
if(document.scrollingElement)document.scrollingElement.scrollTop=0;",
)
.await;
tokio::time::sleep(Duration::from_millis(200)).await;
let mobile = page
.screenshot(ScreenshotParams::default())
.await
.map_err(|e| crate::error::AuditError::NavigationFailed {
url: "screenshot-mobile".to_string(),
reason: e.to_string(),
})?;
let _ = page.execute(ClearDeviceMetricsOverrideParams {}).await;
Ok(crate::audit::report::PageScreenshots { desktop, mobile })
}
async fn extract_snapshot(page: &Page, url: &str, config: &PipelineConfig) -> Result<SnapshotData> {
debug!("Extracting Accessibility Tree...");
let ax_tree = extract_ax_tree(page).await?;
info!("Extracted {} nodes from AXTree", ax_tree.len());
let performance = if config.check_performance {
match extract_web_vitals(page).await {
Ok(vitals) => {
let score = calculate_performance_score(&vitals);
let render_blocking = match analyze_render_blocking(page, url).await {
Ok(rb) => Some(rb),
Err(e) => {
warn!("Render-blocking analysis failed: {}", e);
None
}
};
let content_weight = match analyze_content_weight(page).await {
Ok(cw) => Some(cw),
Err(e) => {
warn!("Content-weight analysis failed: {}", e);
None
}
};
Some(PerformanceResults {
vitals,
score,
render_blocking,
content_weight,
})
}
Err(e) => {
warn!("Performance analysis failed: {}", e);
None
}
}
} else {
None
};
let seo = if config.check_seo {
match analyze_seo(page, url).await {
Ok(seo) => Some(seo),
Err(e) => {
warn!("SEO analysis failed: {}", e);
None
}
}
} else {
None
};
let security = None;
let mobile = if config.check_mobile {
match analyze_mobile_friendliness(page).await {
Ok(mobile) => Some(mobile),
Err(e) => {
warn!("Mobile analysis failed: {}", e);
None
}
}
} else {
None
};
let ux = if config.check_mobile || config.check_seo {
Some(analyze_ux(&ax_tree))
} else {
None
};
let journey = if config.check_mobile || config.check_seo {
Some(analyze_journey(&ax_tree))
} else {
None
};
let dark_mode = if config.check_dark_mode {
match analyze_dark_mode(page, config.wcag_level).await {
Ok(dm) => Some(dm),
Err(e) => {
warn!("Dark mode analysis failed: {}", e);
None
}
}
} else {
None
};
let tech_stack = if config.check_stack {
match crate::tech_stack::analyze_tech_stack(page, url).await {
Ok(ts) => Some(ts),
Err(e) => {
warn!("Tech stack analysis failed: {}", e);
None
}
}
} else {
None
};
Ok(SnapshotData {
ax_tree,
performance,
seo,
security,
mobile,
ux,
journey,
dark_mode,
tech_stack,
})
}
async fn run_rules(page: &Page, snapshot: &SnapshotData, config: &PipelineConfig) -> WcagResults {
debug!("Running WCAG checks at level {}...", config.wcag_level);
let mut wcag_results = wcag::check_all(&snapshot.ax_tree, config.wcag_level);
if wcag_results.violations.iter().any(|v| v.rule == "3.1.1") {
let has_lang = page
.evaluate(
"document.documentElement.getAttribute('lang') || \
document.documentElement.getAttribute('xml:lang') || ''",
)
.await
.ok()
.and_then(|r| r.value().and_then(|v| v.as_str().map(|s| s.to_owned())))
.map(|lang| {
let l = lang.trim().to_lowercase();
l.len() >= 2 && l.chars().all(|c| c.is_ascii_alphabetic() || c == '-')
})
.unwrap_or(false);
if has_lang {
wcag_results.violations.retain(|v| v.rule != "3.1.1");
wcag_results.passes += 1;
}
}
let aria_hidden_focus_violations = wcag::rules::check_aria_hidden_focus(page).await;
if !aria_hidden_focus_violations.is_empty() {
info!(
"Found {} aria-hidden-focus violations",
aria_hidden_focus_violations.len()
);
}
wcag_results.extend_findings(aria_hidden_focus_violations);
let click_handler_violations = wcag::check_click_handlers_with_page(page).await;
if !click_handler_violations.is_empty() {
info!(
"Found {} inline click-handler violations",
click_handler_violations.len()
);
}
wcag_results.extend_findings(click_handler_violations);
let timing_violations = wcag::check_timing_with_page(page).await;
if !timing_violations.is_empty() {
info!("Found {} meta-refresh violations", timing_violations.len());
}
wcag_results.extend_findings(timing_violations);
let color_violations = wcag::check_use_of_color_with_page(page).await;
if !color_violations.is_empty() {
info!("Found {} use-of-color violations", color_violations.len());
}
wcag_results.extend_findings(color_violations);
if matches!(config.wcag_level, WcagLevel::AA | WcagLevel::AAA) {
info!("Running contrast check with CDP...");
let contrast_violations =
wcag::rules::ContrastRule::check_with_page(page, &snapshot.ax_tree, config.wcag_level)
.await;
info!("Found {} contrast violations", contrast_violations.len());
wcag_results.extend_findings(contrast_violations);
let orientation_violations = wcag::check_orientation_with_page(page).await;
if !orientation_violations.is_empty() {
info!(
"Found {} orientation violations",
orientation_violations.len()
);
}
wcag_results.extend_findings(orientation_violations);
let focus_css_violations = wcag::check_focus_visible_css_with_page(page).await;
if !focus_css_violations.is_empty() {
info!(
"Found {} CSS focus-suppression violations",
focus_css_violations.len()
);
}
wcag_results.extend_findings(focus_css_violations);
let reduced_motion_violations = wcag::check_reduced_motion_with_page(page).await;
if !reduced_motion_violations.is_empty() {
info!(
"Found {} reduced-motion violations",
reduced_motion_violations.len()
);
}
wcag_results.extend_findings(reduced_motion_violations);
let hover_violations = wcag::check_content_on_hover_with_page(page).await;
if !hover_violations.is_empty() {
info!(
"Found {} content-on-hover violations",
hover_violations.len()
);
}
wcag_results.extend_findings(hover_violations);
}
if config.wcag_level == WcagLevel::AAA {
let violations = wcag::check_identify_purpose_with_page(page).await;
wcag_results.extend_findings(violations);
let violations = wcag::check_background_audio_with_page(page).await;
wcag_results.extend_findings(violations);
let violations = wcag::check_visual_presentation_with_page(page).await;
wcag_results.extend_findings(violations);
let violations = wcag::check_no_timing_with_page(page).await;
wcag_results.extend_findings(violations);
let violations = wcag::check_no_interruptions_with_page(page).await;
wcag_results.extend_findings(violations);
let violations = wcag::check_re_authenticate_with_page(page).await;
wcag_results.extend_findings(violations);
let violations = wcag::check_timeouts_with_page(page).await;
wcag_results.extend_findings(violations);
let violations = wcag::check_location_with_page(page).await;
wcag_results.extend_findings(violations);
let violations = wcag::check_pointer_gestures_with_page(page).await;
wcag_results.extend_findings(violations);
let violations = wcag::check_pointer_cancellation_with_page(page).await;
wcag_results.extend_findings(violations);
let violations = wcag::check_label_in_name_with_page(page).await;
wcag_results.extend_findings(violations);
let violations = wcag::check_motion_actuation_with_page(page).await;
wcag_results.extend_findings(violations);
let violations = wcag::check_target_size_enhanced_with_page(page).await;
wcag_results.extend_findings(violations);
let violations = wcag::check_abbreviations_with_page(page).await;
wcag_results.extend_findings(violations);
let violations = wcag::check_parsing_with_page(page).await;
wcag_results.extend_findings(violations);
}
enrich_violations_with_page(page, &mut wcag_results.violations, &snapshot.ax_tree).await;
let (kept, demoted): (Vec<_>, Vec<_>) = wcag_results
.violations
.drain(..)
.partition(|v| v.kind == crate::wcag::types::FindingKind::Violation);
wcag_results.violations = kept;
wcag_results.warnings.extend(demoted);
wcag_results
}
fn aggregate_report(
url: &str,
config: &PipelineConfig,
snapshot: &SnapshotData,
mut wcag_results: WcagResults,
duration_ms: u64,
) -> AuditReport {
let pattern_analysis = crate::patterns::analyze(&snapshot.ax_tree);
wcag_results
.violations
.extend(pattern_analysis.violations.clone());
let mut report = AuditReport::new(
url.to_string(),
config.wcag_level,
wcag_results,
duration_ms,
);
report = report.with_patterns(pattern_analysis);
if let Some(performance) = snapshot.performance.clone() {
report = report.with_performance(performance);
}
if let Some(seo) = snapshot.seo.clone() {
report = report.with_seo(seo);
}
if let Some(security) = snapshot.security.clone() {
report = report.with_security(security);
}
if let Some(mobile) = snapshot.mobile.clone() {
report = report.with_mobile(mobile);
}
if let Some(ux) = snapshot.ux.clone() {
report = report.with_ux(ux);
}
if let Some(journey) = snapshot.journey.clone() {
report = report.with_journey(journey);
}
if let Some(dark_mode) = snapshot.dark_mode.clone() {
report = report.with_dark_mode(dark_mode);
}
if let Some(tech_stack) = snapshot.tech_stack.clone() {
report = report.with_tech_stack(tech_stack);
}
report.source_quality = Some(crate::source_quality::analyze_source_quality(&report));
report.ai_visibility = Some(crate::ai_visibility::analyze_ai_visibility(&report));
report.content_visibility = Some(crate::content_visibility::analyze_content_visibility(
&report,
));
report
}
async fn collect_throttled_performance(
page: &Page,
url: &str,
browser: &BrowserManager,
_config: &PipelineConfig,
) -> Vec<crate::audit::report::ThrottledPerfResult> {
use crate::audit::report::ThrottledPerfResult;
use crate::performance::calculate_performance_score;
let mut results = Vec::new();
for &profile in ThrottleProfile::AUTO_PROFILES {
info!("Throttled perf pass: {:?}", profile);
if let Err(e) = throttle::apply_throttling(page, profile).await {
warn!("Throttle apply failed for {:?}: {}", profile, e);
continue;
}
if let Err(e) = prepare_vitals_collection(page).await {
warn!("Vitals injection failed for {:?}: {}", profile, e);
let _ = throttle::disable_throttling(page).await;
continue;
}
if let Err(e) = browser.navigate(page, url).await {
warn!("Navigation failed for {:?}: {}", profile, e);
let _ = throttle::disable_throttling(page).await;
continue;
}
match crate::performance::extract_web_vitals(page).await {
Ok(vitals) => {
let score = calculate_performance_score(&vitals);
results.push(ThrottledPerfResult {
profile,
lcp_ms: vitals.lcp.as_ref().map(|v| v.value),
tbt_ms: vitals.tbt.as_ref().map(|v| v.value),
cls: vitals.cls.as_ref().map(|v| v.value),
score: score.overall,
});
}
Err(e) => {
warn!("Vitals collection failed for {:?}: {}", profile, e);
}
}
if let Err(e) = throttle::disable_throttling(page).await {
warn!("Throttle disable failed for {:?}: {}", profile, e);
}
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
}
let _ = set_viewport(page, Viewport::Mobile).await;
if !results.is_empty() {
let _ = throttle::disable_throttling(page).await;
}
results
}
fn persist_artifacts(url: &str, snapshot: &SnapshotData, report: &AuditReport) {
let snapshot_artifact = SnapshotArtifact {
ax_tree: snapshot.ax_tree.clone(),
performance: snapshot.performance.clone(),
seo: snapshot.seo.clone(),
security: snapshot.security.clone(),
mobile: snapshot.mobile.clone(),
};
let hash = content_hash(&snapshot_artifact);
let normalized = normalize(report);
let wcag_level = report.wcag_level.to_string();
let meta = CacheMeta {
auditmysite_version: env!("CARGO_PKG_VERSION").to_string(),
wcag_level: wcag_level.clone(),
cached_at: report.timestamp,
content_hash: hash.clone(),
};
let artifacts = AuditArtifacts {
fetch: FetchArtifact {
requested_url: url.to_string(),
final_url: report.url.clone(),
status_code: None,
fetched_at: report.timestamp,
duration_ms: report.duration_ms,
},
snapshot: snapshot_artifact,
audit: normalized,
content_hash: hash,
meta,
};
if let Err(e) = save_artifacts(url, &wcag_level, &artifacts) {
warn!("Artifact persistence failed for {}: {}", url, e);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pipeline_config_from_args() {
let args = Args {
command: None,
url: Some("https://example.com".to_string()),
sitemap: None,
url_file: None,
crawl: false,
level: WcagLevel::AA,
format: None,
output: None,
chrome_path: None,
remote_debugging_port: None,
max_pages: 0,
crawl_depth: 2,
concurrency: None,
timeout: None,
no_sandbox: false,
disable_images: false,
verbose: true,
quiet: false,
detect_chrome: false,
full: false,
performance: false,
skip_performance: false,
seo: false,
security: false,
mobile: false,
skip_mobile: false,
stack: false,
reuse_cache: false,
force_refresh: false,
no_sitemap_suggest: false,
prefer_sitemap: false,
per_page_reports: false,
report_level: crate::cli::ReportLevel::Standard,
lang: "de".to_string(),
logo: None,
compare: vec![],
};
let config = PipelineConfig::from(&args);
assert_eq!(config.wcag_level, WcagLevel::AA);
assert_eq!(config.timeout_secs, 30);
assert!(config.verbose);
assert!(config.check_performance);
assert!(config.check_mobile);
assert!(config.check_seo);
assert!(config.check_security);
assert!(config.check_dark_mode);
}
#[test]
fn test_merge_wcag_violations_dedup() {
use crate::wcag::Severity;
fn make_v(rule: &str, selector: &str) -> Violation {
let mut v = Violation::new(rule, rule, WcagLevel::A, Severity::High, "msg", "node-1");
v.selector = Some(selector.to_string());
v
}
let desktop = WcagResults {
violations: vec![
make_v("1.1.1", "#img1"), make_v("2.4.4", "#link1"), ],
warnings: vec![],
positives: vec![],
not_testables: vec![],
passes: 5,
incomplete: 0,
nodes_checked: 100,
};
let mobile = WcagResults {
violations: vec![
make_v("1.1.1", "#img1"), make_v("1.4.3", "#text1"), ],
warnings: vec![],
positives: vec![],
not_testables: vec![],
passes: 4,
incomplete: 0,
nodes_checked: 90,
};
let merged = merge_wcag_violations(&desktop, &mobile);
assert_eq!(merged.violations.len(), 3);
let shared = merged
.violations
.iter()
.find(|v| v.rule == "1.1.1")
.unwrap();
assert!(shared.tags.contains(&"both-viewports".to_string()));
let desktop_only = merged
.violations
.iter()
.find(|v| v.rule == "2.4.4")
.unwrap();
assert!(desktop_only.tags.contains(&"desktop-only".to_string()));
let mobile_only = merged
.violations
.iter()
.find(|v| v.rule == "1.4.3")
.unwrap();
assert!(mobile_only.tags.contains(&"mobile-only".to_string()));
assert_eq!(merged.passes, 5); assert_eq!(merged.nodes_checked, 100);
}
#[test]
fn test_compute_viewport_overall_acc_only() {
assert_eq!(compute_viewport_overall(80.0, None, None, None), 80);
}
#[test]
fn test_compute_viewport_overall_with_perf() {
let result = compute_viewport_overall(80.0, Some(60), None, None);
assert_eq!(result, 73);
}
}