use std::io::{Read, Write};
use std::net::TcpListener;
use std::path::PathBuf;
use std::sync::Arc;
use std::thread;
use auditmysite::{audit_page, BrowserManager, BrowserOptions, PipelineConfig, WcagLevel};
fn serve_fixture(filename: &str) -> (String, Arc<std::sync::atomic::AtomicBool>) {
let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join(filename);
let html = std::fs::read_to_string(&fixture_path)
.unwrap_or_else(|e| panic!("Failed to read fixture {}: {}", fixture_path.display(), e));
let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind");
let port = listener.local_addr().unwrap().port();
let url = format!("http://127.0.0.1:{}", port);
let shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
let shutdown_clone = shutdown.clone();
thread::spawn(move || {
listener
.set_nonblocking(true)
.expect("Cannot set non-blocking");
loop {
if shutdown_clone.load(std::sync::atomic::Ordering::Relaxed) {
break;
}
match listener.accept() {
Ok((mut stream, _)) => {
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\n\r\n{}",
html.len(),
html
);
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(std::time::Duration::from_millis(10));
}
Err(_) => break,
}
}
});
(url, shutdown)
}
async fn ci_browser() -> BrowserManager {
let opts = BrowserOptions {
no_sandbox: std::env::var("CI").is_ok(),
..Default::default()
};
BrowserManager::with_options(opts)
.await
.expect("Browser launch failed")
}
#[tokio::test]
#[ignore]
async fn chrome_axtree_exposes_svg_graphics_roles_and_the_rule_checks_names() {
let (url, shutdown) = serve_fixture("svg_graphics_roles.html");
let manager = ci_browser().await;
let page = manager.new_page().await.expect("New page failed");
manager
.navigate(&page, &url)
.await
.expect("Navigation failed");
let tree = auditmysite::accessibility::extract_ax_tree(&page)
.await
.expect("AXTree extraction failed");
let roles = tree
.iter()
.filter_map(|node| node.role.as_deref())
.collect::<Vec<_>>();
assert!(
roles
.iter()
.any(|role| { matches!(*role, "graphics-document" | "graphics-symbol") }),
"Chrome AXTree did not expose a graphics-* role: {roles:?}"
);
let results = auditmysite::wcag::rules::check_text_alternatives(&tree);
assert!(results.violations.iter().any(|finding| {
finding.role.as_deref() == Some("graphics-symbol") || finding.message.contains("graphic")
}));
shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
}
fn default_config() -> PipelineConfig {
PipelineConfig {
wcag_level: WcagLevel::AA,
timeout_secs: 30,
stability_budget_ms: 1500,
verbose: false,
full_audit: false,
check_performance: false,
check_seo: false,
check_security: false,
check_mobile: false,
check_dark_mode: false,
check_stack: false,
persist_artifacts: true,
capture_screenshots: false,
capture_element_evidence: false,
dismiss_consent: false,
interactive: auditmysite::cli::InteractiveMode::Off,
journey_budget_ms: auditmysite::a11y_journey::DEFAULT_BUDGET_MS,
lang: "de".to_string(),
}
}
#[tokio::test]
#[ignore]
async fn test_wcag_parity_gaps_on_stable_fixture() {
let (url, shutdown) = serve_fixture("parity_gaps.html");
let manager = ci_browser().await;
let page = manager.new_page().await.expect("New page failed");
manager
.navigate(&page, &url)
.await
.expect("Navigation failed");
let direct_title_findings = auditmysite::wcag::rules::check_page_titled_with_page(&page).await;
assert!(
direct_title_findings
.iter()
.any(|v| v.rule_id.as_deref() == Some("document-title")),
"direct DOM document-title check should trigger; got {direct_title_findings:?}"
);
let config = PipelineConfig {
persist_artifacts: false,
..default_config()
};
let (report, _snapshot) = audit_page(&page, &url, &config, &manager)
.await
.expect("Audit failed");
shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
let normalized = auditmysite::audit::normalize(&report).normalized;
let axe_ids: std::collections::BTreeSet<_> = normalized
.findings
.iter()
.filter_map(|finding| finding.axe_id.as_deref())
.collect();
let raw_rule_ids: Vec<_> = report
.accessibility
.wcag_results
.violations
.iter()
.map(|v| (v.rule.as_str(), v.rule_id.as_deref()))
.collect();
assert!(
axe_ids.contains("document-title"),
"missing title fixture should trigger document-title; got {axe_ids:?}; raw {raw_rule_ids:?}"
);
assert!(
axe_ids.contains("landmark-main-present") || axe_ids.contains("landmark-one-main"),
"missing main landmark fixture should trigger a main-landmark finding; got {axe_ids:?}"
);
assert!(
axe_ids.contains("landmark-unique"),
"duplicate navigation names should trigger landmark-unique; got {axe_ids:?}"
);
assert!(
axe_ids.contains("aria-hidden-focus"),
"focusable element in aria-hidden subtree should trigger aria-hidden-focus; got {axe_ids:?}"
);
}
#[tokio::test]
#[ignore]
async fn test_perfect_page_scores_high() {
let (url, shutdown) = serve_fixture("perfect.html");
let manager = ci_browser().await;
let page = manager.new_page().await.expect("New page failed");
manager
.navigate(&page, &url)
.await
.expect("Navigation failed");
let (report, _snapshot) = audit_page(&page, &url, &default_config(), &manager)
.await
.expect("Audit failed");
shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(
report.accessibility.score >= 70.0,
"Perfect page scored only {:.1}, expected >= 70",
report.accessibility.score
);
let critical_count = report
.accessibility
.wcag_results
.violations
.iter()
.filter(|v| v.severity == auditmysite::Severity::Critical)
.count();
assert!(
critical_count == 0,
"Perfect page has {} critical violations",
critical_count
);
}
#[tokio::test]
#[ignore]
async fn test_many_violations_page_scores_low() {
let (url, shutdown) = serve_fixture("many_violations.html");
let manager = ci_browser().await;
let page = manager.new_page().await.expect("New page failed");
manager
.navigate(&page, &url)
.await
.expect("Navigation failed");
let (report, _snapshot) = audit_page(&page, &url, &default_config(), &manager)
.await
.expect("Audit failed");
shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(
report.accessibility.score < 70.0,
"Violation-heavy page scored {:.1}, expected < 70",
report.accessibility.score
);
assert!(
report.accessibility.wcag_results.violations.len() >= 3,
"Expected at least 3 violations, got {}",
report.accessibility.wcag_results.violations.len()
);
}
#[tokio::test]
#[ignore]
async fn test_full_audit_with_all_modules() {
let (url, shutdown) = serve_fixture("perfect.html");
let config = PipelineConfig {
check_performance: true,
check_seo: true,
check_security: true,
check_mobile: true,
..default_config()
};
let manager = ci_browser().await;
let page = manager.new_page().await.expect("New page failed");
manager
.navigate(&page, &url)
.await
.expect("Navigation failed");
let (report, _snapshot) = audit_page(&page, &url, &config, &manager)
.await
.expect("Audit failed");
shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(
report.performance.is_some(),
"Performance results should be present"
);
assert!(
report.discoverability.seo.is_some(),
"SEO results should be present"
);
assert!(
report.security.is_some(),
"Security results should be present"
);
assert!(
report.experience.mobile.is_some(),
"Mobile results should be present"
);
let overall = auditmysite::audit::normalize(&report)
.normalized
.overall_score;
assert!(
overall > 0 && overall <= 100,
"Overall score {} out of range",
overall
);
}
#[tokio::test]
#[ignore]
async fn test_output_formats() {
let (url, shutdown) = serve_fixture("perfect.html");
let manager = ci_browser().await;
let page = manager.new_page().await.expect("New page failed");
manager
.navigate(&page, &url)
.await
.expect("Navigation failed");
let (report, _snapshot) = audit_page(&page, &url, &default_config(), &manager)
.await
.expect("Audit failed");
shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
let normalized = auditmysite::audit::normalize(&report);
let json = auditmysite::format_json_normalized(&normalized, &report, true)
.expect("JSON formatting failed");
let parsed: serde_json::Value = serde_json::from_str(&json).expect("Invalid JSON output");
assert_eq!(
parsed.get("schema_version").and_then(|v| v.as_str()),
Some("2.0"),
"JSON should contain schema_version 2.0"
);
let pages = parsed
.get("pages")
.expect("JSON should contain pages array");
let page = pages
.get(0)
.expect("pages array should have at least one entry");
assert!(
page.get("url").is_some(),
"page entry should contain url field"
);
assert!(
page.get("accessibility_score").is_some(),
"page entry should contain accessibility_score field"
);
}
#[tokio::test]
#[ignore]
async fn test_mobile_issues_detected() {
let (url, shutdown) = serve_fixture("mobile_issues.html");
let config = PipelineConfig {
check_mobile: true,
..default_config()
};
let manager = ci_browser().await;
let page = manager.new_page().await.expect("New page failed");
manager
.navigate(&page, &url)
.await
.expect("Navigation failed");
let (report, _snapshot) = audit_page(&page, &url, &config, &manager)
.await
.expect("Audit failed");
shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
let mobile = report
.experience
.mobile
.expect("Mobile analysis should be present");
assert!(
!mobile.viewport.has_viewport,
"Should detect missing viewport"
);
assert!(
!mobile.issues.is_empty(),
"Should detect mobile issues, got none"
);
assert!(
mobile.score < 100,
"Mobile score should be < 100 with issues, got {}",
mobile.score
);
}
#[tokio::test]
#[ignore]
async fn test_modern_contrast_resolution() {
let (url, shutdown) = serve_fixture("modern_contrast.html");
let manager = ci_browser().await;
let page = manager.new_page().await.expect("New page failed");
manager
.navigate(&page, &url)
.await
.expect("Navigation failed");
let (report, _snapshot) = audit_page(&page, &url, &default_config(), &manager)
.await
.expect("Audit failed");
shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
for violation in &report.accessibility.wcag_results.violations {
println!(
"Violation: rule={}, selector={:?}, message={}",
violation.rule, violation.selector, violation.message
);
}
let contrast_violations: Vec<_> = report
.accessibility
.wcag_results
.violations
.iter()
.filter(|v| v.rule == "1.4.3")
.collect();
assert!(
!contrast_violations.is_empty(),
"Should have detected the deliberate contrast violation"
);
let has_deliberate_violation = contrast_violations.iter().any(|v| {
v.selector
.as_deref()
.map(|s| {
s.contains("low-contrast-text")
|| s.contains("violation-fail-box")
|| s.contains("modern-low-contrast-fail")
})
.unwrap_or(false)
});
assert!(
has_deliberate_violation,
"Deliberate contrast violation was not identified correctly"
);
let has_false_positives = contrast_violations.iter().any(|v| {
if let Some(ref s) = v.selector {
s.contains("tailwind-pass-box")
|| s.contains("oklch-pass-box")
|| s.contains("transparency-pass-box")
|| s.contains("tailwind-space-separated-pass")
|| s.contains("oklch-pass")
|| s.contains("transparent-text-pass")
} else {
false
}
});
assert!(
!has_false_positives,
"Detected false positives in modern contrast color checks"
);
}
#[tokio::test]
#[ignore]
async fn test_image_contrast_pixel_sampling() {
let (url, shutdown) = serve_fixture("image_contrast.html");
let manager = ci_browser().await;
let page = manager.new_page().await.expect("New page failed");
manager
.navigate(&page, &url)
.await
.expect("Navigation failed");
let (report, _snapshot) = audit_page(&page, &url, &default_config(), &manager)
.await
.expect("Audit failed");
shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
let contrast_violations: Vec<_> = report
.accessibility
.wcag_results
.violations
.iter()
.filter(|v| v.rule == "1.4.3")
.collect();
let contrast_warnings: Vec<_> = report
.accessibility
.wcag_results
.warnings
.iter()
.filter(|v| v.rule == "1.4.3")
.collect();
for v in &contrast_violations {
println!(
"Confirmed Violation: selector={:?}, message={}",
v.selector, v.message
);
}
for w in &contrast_warnings {
println!(
"NeedsReview Warning: selector={:?}, message={}",
w.selector, w.message
);
}
let has_pass_violation = contrast_violations.iter().any(|v| {
v.selector
.as_deref()
.map(|s| s.contains("pass-text") || s.contains("gradient-pass-box"))
.unwrap_or(false)
});
let has_pass_warning = contrast_warnings.iter().any(|w| {
w.selector
.as_deref()
.map(|s| s.contains("pass-text") || s.contains("gradient-pass-box"))
.unwrap_or(false)
});
assert!(
!has_pass_violation,
"The dark-gradient text should not be a confirmed violation"
);
assert!(
!has_pass_warning,
"The dark-gradient text should not be a manual review warning (should pass completely)"
);
let has_fail_violation = contrast_violations.iter().any(|v| {
v.selector
.as_deref()
.map(|s| s.contains("fail-text") || s.contains("gradient-fail-box"))
.unwrap_or(false)
});
let has_fail_warning = contrast_warnings.iter().any(|w| {
w.selector
.as_deref()
.map(|s| s.contains("fail-text") || s.contains("gradient-fail-box"))
.unwrap_or(false)
});
assert!(
has_fail_violation,
"The light-gradient text should be a confirmed violation"
);
assert!(
!has_fail_warning,
"The light-gradient text should not be a manual review warning (it should fail)"
);
let has_warn_violation = contrast_violations.iter().any(|v| {
v.selector
.as_deref()
.map(|s| s.contains("warn-text") || s.contains("gradient-warn-box"))
.unwrap_or(false)
});
let has_warn_warning = contrast_warnings.iter().any(|w| {
w.selector
.as_deref()
.map(|s| s.contains("warn-text") || s.contains("gradient-warn-box"))
.unwrap_or(false)
});
assert!(
!has_warn_violation,
"The split-gradient text should not be a confirmed violation"
);
assert!(
has_warn_warning,
"The split-gradient text should be a manual review warning"
);
}
#[tokio::test]
#[ignore]
async fn test_catalog_driven_audit_complete_structure() {
let (url, shutdown) = serve_fixture("perfect.html");
let config = PipelineConfig {
check_performance: true,
check_seo: true,
check_mobile: true,
..default_config()
};
let manager = ci_browser().await;
let page = manager.new_page().await.expect("New page failed");
manager
.navigate(&page, &url)
.await
.expect("Navigation failed");
let (report, _snapshot) = auditmysite::audit_page(&page, &url, &config, &manager)
.await
.expect("Audit failed");
shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(
report.discoverability.source_quality.is_some(),
"source_quality must be populated"
);
assert!(
report.discoverability.ai_visibility.is_some(),
"ai_visibility must be populated"
);
assert!(
report.discoverability.content_visibility.is_some(),
"content_visibility must be populated"
);
assert!(report.accessibility.score >= 0.0 && report.accessibility.score <= 100.0);
}
#[tokio::test]
#[ignore]
async fn test_partial_module_failure_does_not_abort_audit() {
let (url, shutdown) = serve_fixture("many_violations.html");
let config = PipelineConfig {
check_performance: true,
check_seo: true,
check_security: true,
check_mobile: true,
..default_config()
};
let manager = ci_browser().await;
let page = manager.new_page().await.expect("New page failed");
manager
.navigate(&page, &url)
.await
.expect("Navigation failed");
let (report, _snapshot) = auditmysite::audit_page(&page, &url, &config, &manager)
.await
.expect("Audit must succeed even when sub-analyses fail");
shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(
!report.accessibility.wcag_results.violations.is_empty()
|| report.accessibility.wcag_results.passes > 0,
"WCAG results must be present"
);
}
#[tokio::test]
#[ignore]
async fn test_sequential_two_url_audit() {
let (url1, shutdown1) = serve_fixture("perfect.html");
let (url2, shutdown2) = serve_fixture("many_violations.html");
let manager = ci_browser().await;
let page1 = manager.new_page().await.expect("page 1 failed");
manager.navigate(&page1, &url1).await.expect("nav 1 failed");
let (report1, _snapshot) = auditmysite::audit_page(&page1, &url1, &default_config(), &manager)
.await
.expect("audit 1 failed");
let page2 = manager.new_page().await.expect("page 2 failed");
manager.navigate(&page2, &url2).await.expect("nav 2 failed");
let (report2, _snapshot) = auditmysite::audit_page(&page2, &url2, &default_config(), &manager)
.await
.expect("audit 2 failed");
shutdown1.store(true, std::sync::atomic::Ordering::Relaxed);
shutdown2.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(
report1.accessibility.score > report2.accessibility.score,
"perfect page ({:.1}) should beat violations page ({:.1})",
report1.accessibility.score,
report2.accessibility.score
);
}
#[tokio::test]
#[ignore]
async fn test_catalog_audit_json_envelope_v2() {
let (url, shutdown) = serve_fixture("perfect.html");
let manager = ci_browser().await;
let page = manager.new_page().await.expect("New page failed");
manager
.navigate(&page, &url)
.await
.expect("Navigation failed");
let (report, _snapshot) = auditmysite::audit_page(&page, &url, &default_config(), &manager)
.await
.expect("Audit failed");
shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
let normalized = auditmysite::audit::normalize(&report);
let json = auditmysite::format_json_normalized(&normalized, &report, true)
.expect("JSON formatting failed");
let parsed: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
assert_eq!(
parsed["schema_version"].as_str(),
Some("2.0"),
"schema_version must be 2.0"
);
assert_eq!(
parsed["report_type"].as_str(),
Some("single"),
"report_type must be single"
);
let pages = parsed["pages"].as_array().expect("pages must be array");
assert!(!pages.is_empty(), "pages must have at least one entry");
assert!(pages[0]["url"].is_string(), "page entry must have url");
assert!(
pages[0]["accessibility_score"].is_number(),
"page entry must have accessibility_score"
);
}