use chromiumoxide::Page;
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::error::Result;
mod dom;
mod issues;
mod probes;
use dom::{run_dom_inspection, run_local_html_validation};
pub use issues::collect_issues;
use probes::run_http_probes;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PageHealthAnalysis {
pub soft_404_status: Option<u16>,
pub is_soft_404: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom_404: Option<Custom404Check>,
pub has_meta_refresh: bool,
pub meta_refresh_content: Option<String>,
pub frame_count: u32,
pub iframe_count: u32,
pub cross_origin_iframe_count: u32,
pub url_length: usize,
pub url_has_query_params: bool,
pub url_is_dynamic: bool,
pub url_path_depth: usize,
pub url_is_too_long: bool,
pub url_is_too_deep: bool,
pub own_redirect_detected: bool,
pub own_final_url: Option<String>,
pub redirect_count: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub redirect_chain: Vec<RedirectHop>,
pub www_consolidation: Option<WwwConsolidation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url_canonicalization: Option<UrlCanonicalizationCheck>,
pub duplicate_id_count: u32,
pub images_without_alt: u32,
pub tables_without_headers: u32,
pub empty_headings: u32,
pub nested_interactive_count: u32,
pub html_issues: Vec<HtmlValidationIssue>,
pub html_validator_status: String,
#[serde(default, deserialize_with = "deserialize_html_validator_detail")]
pub html_validator_detail: Option<String>,
pub has_doctype: bool,
pub document_write_count: u32,
pub dom_node_count: u32,
pub dom_max_depth: u32,
pub images_without_dimensions: u32,
pub paste_blocking_password_fields: u32,
pub offscreen_images_without_lazy: u32,
pub images_without_srcset: u32,
pub missing_preconnect_count: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub missing_preconnect_origins: Vec<String>,
pub non_crawlable_links: u32,
pub images_without_modern_format: u32,
pub oversized_images: u32,
pub gif_images: u32,
pub font_display_issues: u32,
pub font_face_count: u32,
pub fonts_without_preload_count: u32,
pub preload_hints: u32,
pub prefetch_hints: u32,
pub dns_prefetch_hints: u32,
pub orphaned_preload_count: u32,
pub uses_http2: bool,
pub has_compression: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub document_decoded_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub document_transfer_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_control: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub charset_header: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub declared_charset: Option<String>,
pub has_efficient_cache: bool,
#[serde(default)]
pub resource_cache: ResourceCacheAudit,
#[serde(skip)]
pub resource_cache_probe_urls: Vec<String>,
pub server_timing_count: u32,
pub hreflang_count: u32,
pub hreflang_invalid_count: u32,
pub jsonld_count: u32,
pub jsonld_invalid_count: u32,
pub lcp_image_without_preload: bool,
pub lcp_image_without_fetchpriority: bool,
pub lcp_image_lazy_loaded: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lcp_image_url: Option<String>,
pub deprecated_api_count: u32,
pub sync_head_scripts: u32,
pub external_scripts_without_sri: u32,
pub external_styles_without_sri: u32,
pub broken_fragment_links: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub broken_fragment_samples: Vec<String>,
pub generic_link_text_count: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub generic_link_text_samples: Vec<String>,
pub issues: Vec<PageHealthIssue>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedirectHop {
pub status: u16,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Custom404Check {
pub probe_url: String,
pub status: u16,
pub proper_status: bool,
pub custom_page: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ResourceCacheAudit {
pub checked_resources: u32,
pub cacheable_resources: u32,
pub inefficient_resources: u32,
pub immutable_resources: u32,
pub etag_resources: u32,
pub expires_resources: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub samples: Vec<ResourceCacheFinding>,
#[serde(default)]
pub missing_content_type: u32,
#[serde(default)]
pub incorrect_content_type: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub content_type_samples: Vec<ContentTypeFinding>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResourceCacheFinding {
pub url: String,
pub cache_control: Option<String>,
pub has_etag: bool,
pub has_expires: bool,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentTypeFinding {
pub url: String,
pub expected: String,
pub actual: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WwwConsolidation {
pub www_status: Option<u16>,
pub non_www_status: Option<u16>,
pub www_redirects_to_non_www: bool,
pub non_www_redirects_to_www: bool,
pub canonical_variant: String,
pub is_consolidated: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UrlCanonicalizationCheck {
pub trailing_slash_variant_status: Option<u16>,
pub trailing_slash_inconsistent: bool,
pub http_status: Option<u16>,
pub http_to_https_missing: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HtmlValidationKind {
DuplicateIds,
ImagesWithoutAlt,
TablesWithoutHeaders,
EmptyHeadings,
NestedInteractive,
ParseErrors,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(try_from = "HtmlValidationIssueWire")]
pub struct HtmlValidationIssue {
pub kind: HtmlValidationKind,
pub check: String,
pub count: u32,
pub severity: String,
pub detail: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub samples: Vec<String>,
}
impl HtmlValidationIssue {
pub fn new(kind: HtmlValidationKind, count: u32, severity: &str, samples: Vec<String>) -> Self {
Self {
kind,
check: html_validation_check_text(kind, true).to_string(),
count,
severity: severity.to_string(),
detail: html_validation_detail_text(kind, count, &samples, true),
samples,
}
}
}
pub fn html_validation_check_text(kind: HtmlValidationKind, en: bool) -> &'static str {
match (kind, en) {
(HtmlValidationKind::DuplicateIds, true) => "Duplicate IDs",
(HtmlValidationKind::DuplicateIds, false) => "Doppelte IDs",
(HtmlValidationKind::ImagesWithoutAlt, true) => "Images without alt attribute",
(HtmlValidationKind::ImagesWithoutAlt, false) => "Bilder ohne alt-Attribut",
(HtmlValidationKind::TablesWithoutHeaders, true) => "Tables without header row",
(HtmlValidationKind::TablesWithoutHeaders, false) => "Tabellen ohne Kopfzeile",
(HtmlValidationKind::EmptyHeadings, true) => "Empty headings",
(HtmlValidationKind::EmptyHeadings, false) => "Leere Überschriften",
(HtmlValidationKind::NestedInteractive, true) => "Nested interactive elements",
(HtmlValidationKind::NestedInteractive, false) => "Verschachtelte interaktive Elemente",
(HtmlValidationKind::ParseErrors, true) => "HTML5 parsing errors",
(HtmlValidationKind::ParseErrors, false) => "HTML5-Parsing-Fehler",
}
}
pub fn html_validation_detail_text(
kind: HtmlValidationKind,
count: u32,
samples: &[String],
en: bool,
) -> String {
match kind {
HtmlValidationKind::DuplicateIds => match (samples.is_empty(), en) {
(true, true) => format!("{count} found"),
(true, false) => format!("{count} gefunden"),
(false, true) => format!("{count} (e.g. {})", samples.join(", ")),
(false, false) => format!("{count} (z.B. {})", samples.join(", ")),
},
HtmlValidationKind::ImagesWithoutAlt if en => format!("{count} <img> without alt"),
HtmlValidationKind::ImagesWithoutAlt => format!("{count} <img> ohne alt"),
HtmlValidationKind::TablesWithoutHeaders if en => {
format!("{count} <table> without <th> or <caption>")
}
HtmlValidationKind::TablesWithoutHeaders => {
format!("{count} <table> ohne <th> oder <caption>")
}
HtmlValidationKind::EmptyHeadings if en => format!("{count} empty h1–h6 elements"),
HtmlValidationKind::EmptyHeadings => format!("{count} leere h1–h6 Elemente"),
HtmlValidationKind::NestedInteractive => format!("{count} button/a in button/a"),
HtmlValidationKind::ParseErrors => {
let joined = samples.join(" | ");
let more = (count as usize).saturating_sub(samples.len());
match (more, en) {
(0, _) => joined,
(n, true) => format!("{joined} | +{n} more"),
(n, false) => format!("{joined} | +{n} weitere"),
}
}
}
}
pub fn html_validator_executed_text(en: bool) -> &'static str {
if en {
"HTML5 validation run locally via html5ever"
} else {
"HTML5-Validierung lokal via html5ever"
}
}
#[derive(Deserialize)]
struct HtmlValidationIssueWire {
#[serde(default)]
kind: Option<HtmlValidationKind>,
check: String,
count: u32,
severity: String,
detail: String,
#[serde(default)]
samples: Vec<String>,
}
impl TryFrom<HtmlValidationIssueWire> for HtmlValidationIssue {
type Error = String;
fn try_from(w: HtmlValidationIssueWire) -> std::result::Result<Self, Self::Error> {
if let Some(kind) = w.kind {
return Ok(Self {
kind,
check: w.check,
count: w.count,
severity: w.severity,
detail: w.detail,
samples: w.samples,
});
}
let kind = [
HtmlValidationKind::DuplicateIds,
HtmlValidationKind::ImagesWithoutAlt,
HtmlValidationKind::TablesWithoutHeaders,
HtmlValidationKind::EmptyHeadings,
HtmlValidationKind::NestedInteractive,
HtmlValidationKind::ParseErrors,
]
.into_iter()
.find(|k| html_validation_check_text(*k, false) == w.check)
.ok_or_else(|| format!("unknown legacy HTML validation check: {}", w.check))?;
let samples = match kind {
HtmlValidationKind::DuplicateIds => w
.detail
.split_once(" (z.B. ")
.and_then(|(_, rest)| rest.strip_suffix(')'))
.map(|list| list.split(", ").map(str::to_string).collect())
.unwrap_or_default(),
HtmlValidationKind::ParseErrors => w
.detail
.split(" | ")
.filter(|part| !(part.starts_with('+') && part.ends_with(" weitere")))
.map(str::to_string)
.collect(),
_ => Vec::new(),
};
Ok(Self::new(kind, w.count, &w.severity, samples))
}
}
fn deserialize_html_validator_detail<'de, D>(
deserializer: D,
) -> std::result::Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let detail = Option::<String>::deserialize(deserializer)?;
Ok(detail.map(|d| {
if d == html_validator_executed_text(false) {
html_validator_executed_text(true).to_string()
} else {
d
}
}))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageHealthIssue {
pub issue_type: String,
pub message: String,
pub severity: String,
}
pub async fn analyze_page_health(page: &Page, url: &str) -> Result<PageHealthAnalysis> {
let mut analysis = PageHealthAnalysis {
html_validator_status: "skipped".to_string(),
..Default::default()
};
analyze_url(url, &mut analysis);
if let Err(e) = run_dom_inspection(page, url, &mut analysis).await {
warn!("Page health DOM inspection failed: {}", e);
}
run_http_probes(url, &mut analysis).await;
if let Err(e) = run_local_html_validation(page, url, &mut analysis).await {
analysis.html_validator_status = "failed".to_string();
analysis.html_validator_detail = Some(e.to_string());
warn!("Local HTML validation failed: {}", e);
}
analysis.issues = collect_issues(&analysis, true);
Ok(analysis)
}
fn analyze_url(url: &str, a: &mut PageHealthAnalysis) {
a.url_length = url.len();
a.url_is_too_long = url.len() > 115;
if let Ok(parsed) = url::Url::parse(url) {
a.url_has_query_params = parsed.query().is_some();
a.url_is_dynamic = a.url_has_query_params;
let depth = parsed
.path_segments()
.map(|segs| segs.filter(|s| !s.is_empty()).count())
.unwrap_or(0);
a.url_path_depth = depth;
a.url_is_too_deep = depth > 5;
}
}
#[cfg(test)]
mod tests {
use super::dom::{build_html_issues, validate_html_locally};
use super::probes::{
document_timing_indicates_compression, expected_content_type_substring,
is_cache_policy_efficient, is_static_cache_candidate, looks_like_custom_404_page,
};
use super::*;
use serde_json::json;
#[test]
fn test_analyze_url_basic() {
let mut a = PageHealthAnalysis::default();
analyze_url("https://example.com/foo/bar?q=1", &mut a);
assert_eq!(a.url_length, 31);
assert!(a.url_has_query_params);
assert!(a.url_is_dynamic);
assert_eq!(a.url_path_depth, 2);
assert!(!a.url_is_too_long);
assert!(!a.url_is_too_deep);
}
#[test]
fn test_analyze_url_long() {
let long_url = format!("https://example.com/{}", "a".repeat(100));
let mut a = PageHealthAnalysis::default();
analyze_url(&long_url, &mut a);
assert!(a.url_is_too_long);
}
#[test]
fn test_analyze_url_deep() {
let mut a = PageHealthAnalysis::default();
analyze_url("https://example.com/a/b/c/d/e/f", &mut a);
assert_eq!(a.url_path_depth, 6);
assert!(a.url_is_too_deep);
}
#[test]
fn test_collect_issues_soft_404() {
let a = PageHealthAnalysis {
is_soft_404: true,
soft_404_status: Some(200),
custom_404: Some(Custom404Check {
probe_url: "https://example.com/auditmysite-404-probe-xyz123".to_string(),
status: 200,
proper_status: false,
custom_page: true,
}),
..Default::default()
};
let issues = collect_issues(&a, false);
assert!(issues.iter().any(|i| i.issue_type == "soft_404"));
assert!(issues
.iter()
.any(|i| i.issue_type == "custom_404_invalid_status"));
}
#[test]
fn test_collect_issues_url_canonicalization() {
let a = PageHealthAnalysis {
url_canonicalization: Some(UrlCanonicalizationCheck {
trailing_slash_variant_status: Some(200),
trailing_slash_inconsistent: true,
http_status: Some(200),
http_to_https_missing: true,
}),
..Default::default()
};
let issues = collect_issues(&a, false);
assert!(issues
.iter()
.any(|i| i.issue_type == "trailing_slash_inconsistent"));
assert!(issues
.iter()
.any(|i| i.issue_type == "http_not_redirected_to_https"));
}
#[test]
fn test_collect_issues_url_canonicalization_consistent_is_silent() {
let a = PageHealthAnalysis {
url_canonicalization: Some(UrlCanonicalizationCheck {
trailing_slash_variant_status: Some(301),
trailing_slash_inconsistent: false,
http_status: Some(301),
http_to_https_missing: false,
}),
..Default::default()
};
let issues = collect_issues(&a, false);
assert!(!issues
.iter()
.any(|i| i.issue_type == "trailing_slash_inconsistent"));
assert!(!issues
.iter()
.any(|i| i.issue_type == "http_not_redirected_to_https"));
}
#[test]
fn test_collect_issues_charset_mismatch() {
let a = PageHealthAnalysis {
charset_header: Some("iso-8859-1".to_string()),
declared_charset: Some("UTF-8".to_string()),
..Default::default()
};
let issues = collect_issues(&a, false);
assert!(issues.iter().any(|i| i.issue_type == "charset_mismatch"));
}
#[test]
fn test_collect_issues_charset_match_is_case_insensitive_and_silent() {
let a = PageHealthAnalysis {
charset_header: Some("utf-8".to_string()),
declared_charset: Some("UTF-8".to_string()),
..Default::default()
};
let issues = collect_issues(&a, false);
assert!(!issues.iter().any(|i| i.issue_type == "charset_mismatch"));
}
#[test]
fn test_collect_issues_charset_missing_one_side_is_silent() {
let a = PageHealthAnalysis {
charset_header: Some("utf-8".to_string()),
declared_charset: None,
..Default::default()
};
let issues = collect_issues(&a, false);
assert!(!issues.iter().any(|i| i.issue_type == "charset_mismatch"));
}
#[test]
fn test_collect_issues_fonts_not_preloaded() {
let a = PageHealthAnalysis {
font_face_count: 2,
fonts_without_preload_count: 1,
..Default::default()
};
let issues = collect_issues(&a, false);
assert!(issues.iter().any(|i| i.issue_type == "fonts_not_preloaded"));
}
#[test]
fn test_collect_issues_all_fonts_preloaded_is_silent() {
let a = PageHealthAnalysis {
font_face_count: 2,
fonts_without_preload_count: 0,
..Default::default()
};
let issues = collect_issues(&a, false);
assert!(!issues.iter().any(|i| i.issue_type == "fonts_not_preloaded"));
}
#[test]
fn collect_issues_english_messages_have_no_german_characters() {
let a = PageHealthAnalysis {
has_doctype: false,
dom_node_count: 1600,
dom_max_depth: 20,
document_write_count: 2,
images_without_dimensions: 4,
paste_blocking_password_fields: 1,
offscreen_images_without_lazy: 6,
images_without_srcset: 7,
missing_preconnect_count: 2,
missing_preconnect_origins: vec![
"https://cdn.example.com".to_string(),
"https://fonts.example.com".to_string(),
],
non_crawlable_links: 6,
images_without_modern_format: 6,
oversized_images: 3,
gif_images: 2,
font_display_issues: 3,
font_face_count: 2,
fonts_without_preload_count: 1,
orphaned_preload_count: 1,
lcp_image_lazy_loaded: true,
lcp_image_without_preload: true,
lcp_image_url: Some("https://example.com/hero.jpg".to_string()),
deprecated_api_count: 2,
uses_http2: false,
has_compression: false,
has_efficient_cache: false,
cache_control: Some("max-age=60".to_string()),
resource_cache: ResourceCacheAudit {
checked_resources: 3,
cacheable_resources: 3,
inefficient_resources: 2,
samples: vec![ResourceCacheFinding {
url: "https://example.com/app.js".to_string(),
cache_control: Some("max-age=60".to_string()),
has_etag: true,
has_expires: false,
reason: "short or missing max-age/s-maxage".to_string(),
}],
..Default::default()
},
hreflang_invalid_count: 1,
jsonld_invalid_count: 1,
sync_head_scripts: 2,
external_scripts_without_sri: 1,
external_styles_without_sri: 1,
broken_fragment_links: 2,
broken_fragment_samples: vec!["#missing".to_string()],
generic_link_text_count: 3,
is_soft_404: true,
soft_404_status: Some(200),
has_meta_refresh: true,
meta_refresh_content: Some("0; url=https://example.com".to_string()),
frame_count: 1,
url_is_too_long: true,
url_length: 200,
url_is_too_deep: true,
url_path_depth: 8,
url_has_query_params: true,
redirect_count: 3,
www_consolidation: Some(WwwConsolidation {
www_status: None,
non_www_status: None,
www_redirects_to_non_www: false,
non_www_redirects_to_www: false,
canonical_variant: "inconsistent".to_string(),
is_consolidated: false,
}),
url_canonicalization: Some(UrlCanonicalizationCheck {
trailing_slash_variant_status: Some(200),
trailing_slash_inconsistent: true,
http_status: Some(200),
http_to_https_missing: true,
}),
charset_header: Some("iso-8859-1".to_string()),
declared_charset: Some("UTF-8".to_string()),
custom_404: Some(Custom404Check {
probe_url: "https://example.com/auditmysite-404-probe-xyz123".to_string(),
status: 200,
proper_status: false,
custom_page: false,
}),
..Default::default()
};
let issues = collect_issues(&a, true);
assert!(
issues.len() >= 10,
"expected many issues, got {}",
issues.len()
);
for issue in &issues {
assert!(
!issue.message.chars().any(|c| "äöüÄÖÜß".contains(c)),
"English message contains German characters: {}",
issue.message
);
}
}
#[test]
fn test_collect_issues_meta_refresh() {
let a = PageHealthAnalysis {
has_meta_refresh: true,
meta_refresh_content: Some("0; url=https://example.com".to_string()),
..Default::default()
};
let issues = collect_issues(&a, false);
assert!(issues.iter().any(|i| i.issue_type == "meta_refresh"));
assert_eq!(
issues
.iter()
.find(|i| i.issue_type == "meta_refresh")
.map(|i| i.severity.as_str()),
Some("high")
);
}
#[test]
fn compression_inferred_from_navigation_timing_ratio() {
let a = PageHealthAnalysis {
document_transfer_bytes: Some(21_814),
document_decoded_bytes: Some(1_233_280),
..Default::default()
};
assert!(document_timing_indicates_compression(&a));
}
#[test]
fn missing_compression_issue_suppressed_when_timing_shows_compression() {
let a = PageHealthAnalysis {
has_compression: true,
document_transfer_bytes: Some(21_814),
document_decoded_bytes: Some(1_233_280),
..Default::default()
};
let issues = collect_issues(&a, false);
assert!(!issues.iter().any(|i| i.issue_type == "missing_compression"));
}
#[test]
fn cache_policy_requires_meaningful_lifetime_or_immutable() {
assert!(is_cache_policy_efficient(
"public, max-age=31536000, immutable"
));
assert!(is_cache_policy_efficient("public, s-maxage=86400"));
assert!(!is_cache_policy_efficient("public, max-age=60"));
assert!(!is_cache_policy_efficient("no-cache, max-age=31536000"));
}
#[test]
fn static_cache_candidate_detects_asset_extensions() {
assert!(is_static_cache_candidate("https://example.com/app.css"));
assert!(is_static_cache_candidate(
"https://example.com/fonts/inter.woff2?v=1"
));
assert!(!is_static_cache_candidate("https://example.com/page/"));
}
#[test]
fn custom_404_heuristic_rejects_generic_short_pages() {
assert!(!looks_like_custom_404_page(
"<html><title>404 Not Found</title><body>nginx 404 not found</body></html>"
));
assert!(looks_like_custom_404_page(&format!(
"<html><body><nav>Home</nav><main>{}</main></body></html>",
"Helpful not-found guidance. ".repeat(30)
)));
}
#[test]
fn collect_issues_reports_resource_cache_findings() {
let a = PageHealthAnalysis {
resource_cache: ResourceCacheAudit {
checked_resources: 3,
cacheable_resources: 3,
inefficient_resources: 2,
samples: vec![ResourceCacheFinding {
url: "https://example.com/app.js".to_string(),
cache_control: Some("max-age=60".to_string()),
has_etag: true,
has_expires: false,
reason: "short or missing max-age/s-maxage".to_string(),
}],
..Default::default()
},
..Default::default()
};
let issues = collect_issues(&a, false);
assert!(issues
.iter()
.any(|issue| issue.issue_type == "inefficient_resource_cache"));
}
#[test]
fn collect_issues_reports_incorrect_content_type() {
let a = PageHealthAnalysis {
resource_cache: ResourceCacheAudit {
missing_content_type: 1,
incorrect_content_type: 1,
content_type_samples: vec![ContentTypeFinding {
url: "https://example.com/app.js".to_string(),
expected: "javascript".to_string(),
actual: Some("text/html".to_string()),
}],
..Default::default()
},
..Default::default()
};
let issues = collect_issues(&a, false);
assert!(issues
.iter()
.any(|issue| issue.issue_type == "incorrect_content_type"));
}
#[test]
fn expected_content_type_substring_recognizes_common_extensions() {
assert_eq!(
expected_content_type_substring("https://example.com/app.js"),
Some("javascript")
);
assert_eq!(
expected_content_type_substring("https://example.com/style.css"),
Some("css")
);
assert_eq!(
expected_content_type_substring("https://example.com/page.html"),
None
);
}
#[test]
fn collect_issues_reports_generic_custom_404() {
let a = PageHealthAnalysis {
custom_404: Some(Custom404Check {
probe_url: "https://example.com/auditmysite-404-probe-xyz123".to_string(),
status: 404,
proper_status: true,
custom_page: false,
}),
..Default::default()
};
let issues = collect_issues(&a, false);
assert!(issues
.iter()
.any(|issue| issue.issue_type == "generic_404_page"));
}
#[test]
fn test_build_html_issues_for_classic_validation_findings() {
let analysis = PageHealthAnalysis {
duplicate_id_count: 2,
images_without_alt: 3,
tables_without_headers: 1,
empty_headings: 2,
nested_interactive_count: 1,
..Default::default()
};
let parsed = json!({
"duplicateIdSamples": ["hero", "cta-button"]
});
let issues = build_html_issues(&analysis, &parsed);
assert!(issues.iter().any(|i| {
i.kind == HtmlValidationKind::DuplicateIds
&& i.count == 2
&& i.samples == ["hero", "cta-button"]
&& i.detail == "2 (e.g. hero, cta-button)"
}));
assert!(issues
.iter()
.any(|i| i.kind == HtmlValidationKind::ImagesWithoutAlt && i.count == 3));
assert!(issues
.iter()
.any(|i| i.kind == HtmlValidationKind::TablesWithoutHeaders && i.count == 1));
assert!(issues
.iter()
.any(|i| i.kind == HtmlValidationKind::EmptyHeadings && i.count == 2));
assert!(issues
.iter()
.any(|i| i.kind == HtmlValidationKind::NestedInteractive && i.count == 1));
}
const ALL_HTML_VALIDATION_KINDS: [HtmlValidationKind; 6] = [
HtmlValidationKind::DuplicateIds,
HtmlValidationKind::ImagesWithoutAlt,
HtmlValidationKind::TablesWithoutHeaders,
HtmlValidationKind::EmptyHeadings,
HtmlValidationKind::NestedInteractive,
HtmlValidationKind::ParseErrors,
];
#[test]
fn english_html_validation_text_has_no_german_chars() {
let samples = vec!["a".to_string(), "b".to_string()];
let mut texts = vec![html_validator_executed_text(true).to_string()];
for kind in ALL_HTML_VALIDATION_KINDS {
texts.push(html_validation_check_text(kind, true).to_string());
texts.push(html_validation_detail_text(kind, 5, &samples, true));
texts.push(html_validation_detail_text(kind, 5, &[], true));
}
for text in texts {
assert!(
!text.contains(['ä', 'ö', 'ü', 'Ä', 'Ö', 'Ü', 'ß']),
"German char in EN text: {text}"
);
for word in [
"gefunden",
"z.B.",
"weitere",
"ohne",
"Fehler",
"Validierung",
] {
assert!(!text.contains(word), "German word in EN text: {text}");
}
}
}
#[test]
fn legacy_german_html_issues_deserialize_to_canonical_english() {
let legacy = json!([
{"check": "Doppelte IDs", "count": 2, "severity": "medium",
"detail": "2 (z.B. hero-swiper, webshop-widget)"},
{"check": "Doppelte IDs", "count": 1, "severity": "medium", "detail": "1 gefunden"},
{"check": "HTML5-Parsing-Fehler", "count": 40, "severity": "high",
"detail": "No <p> tag to close | Unexpected token | No <p> tag to close | +37 weitere"},
{"check": "Leere Überschriften", "count": 10, "severity": "medium",
"detail": "10 leere h1–h6 Elemente"}
]);
let issues: Vec<HtmlValidationIssue> = serde_json::from_value(legacy.clone()).unwrap();
assert_eq!(issues[0].kind, HtmlValidationKind::DuplicateIds);
assert_eq!(issues[0].check, "Duplicate IDs");
assert_eq!(issues[0].detail, "2 (e.g. hero-swiper, webshop-widget)");
assert_eq!(issues[1].detail, "1 found");
assert_eq!(issues[2].kind, HtmlValidationKind::ParseErrors);
assert_eq!(issues[2].samples.len(), 3);
assert_eq!(issues[3].check, "Empty headings");
for (issue, old) in issues.iter().zip(legacy.as_array().unwrap()) {
assert_eq!(html_validation_check_text(issue.kind, false), old["check"]);
assert_eq!(
html_validation_detail_text(issue.kind, issue.count, &issue.samples, false),
old["detail"]
);
}
let again: Vec<HtmlValidationIssue> =
serde_json::from_value(serde_json::to_value(&issues).unwrap()).unwrap();
assert_eq!(again[2].samples, issues[2].samples);
assert_eq!(again[0].detail, issues[0].detail);
}
#[test]
fn legacy_german_validator_detail_deserializes_to_english() {
let mut value = serde_json::to_value(PageHealthAnalysis::default()).unwrap();
value["html_validator_detail"] = json!("HTML5-Validierung lokal via html5ever");
let analysis: PageHealthAnalysis = serde_json::from_value(value).unwrap();
assert_eq!(
analysis.html_validator_detail.as_deref(),
Some(html_validator_executed_text(true))
);
}
#[test]
fn html5ever_parse_errors_are_structured_findings() {
let issues = validate_html_locally("<!doctype html><html><head><p></head></html>");
assert!(issues.iter().any(|issue| {
issue.kind == HtmlValidationKind::ParseErrors
&& issue.count > 0
&& issue.severity == "high"
&& !issue.detail.is_empty()
}));
}
}