pub mod module;
pub use module::HtmlConformModule;
use chromiumoxide::Page;
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::error::{AuditError, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HtmlConformAnalysis {
pub score: u32,
pub checked: bool,
pub error_count: u32,
pub warning_count: u32,
pub info_count: u32,
pub distinct_defect_count: u32,
pub findings: Vec<HtmlConformFinding>,
#[serde(skip)]
pub raw_html: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HtmlConformFinding {
pub rule_id: String,
pub severity: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub byte_offset: Option<usize>,
}
pub async fn analyze_html_conform(page: &Page) -> Result<HtmlConformAnalysis> {
let html = extract_document_html(page).await?;
let report = match html_conform::check(&html) {
Ok(report) => report,
Err(e) => {
warn!("HTML conformance check setup failed: {}", e);
return Ok(not_measured());
}
};
let mut error_count = 0u32;
let mut warning_count = 0u32;
let mut info_count = 0u32;
let findings: Vec<HtmlConformFinding> = report
.findings
.into_iter()
.map(|f| {
let severity = match f.severity {
html_conform::Severity::Error => {
error_count += 1;
"error"
}
html_conform::Severity::Warning => {
warning_count += 1;
"warning"
}
html_conform::Severity::Info => {
info_count += 1;
"info"
}
};
HtmlConformFinding {
rule_id: f.rule_id,
severity: severity.to_string(),
message: f.message,
location: f.location.map(|l| l.to_string()),
byte_offset: f.location.map(|l| l.byte_offset),
}
})
.collect();
let score = score_findings(&findings);
let distinct_defect_count = distinct_defect_count(&findings);
Ok(HtmlConformAnalysis {
score,
checked: true,
error_count,
warning_count,
info_count,
distinct_defect_count,
findings,
raw_html: Some(html),
})
}
const ERROR_WEIGHT: u32 = 10;
const WARNING_WEIGHT: u32 = 4;
const INFO_WEIGHT: u32 = 1;
pub(crate) fn defect_key(finding: &HtmlConformFinding) -> String {
let message = finding
.message
.split(" at ")
.next()
.unwrap_or(&finding.message);
let mut normalized = String::with_capacity(message.len());
let mut in_quotes = false;
for ch in message.chars() {
match ch {
'`' if in_quotes => in_quotes = false,
'`' => {
in_quotes = true;
normalized.push('*');
}
_ if in_quotes => {}
_ => normalized.push(ch),
}
}
format!("{}|{}", finding.rule_id, normalized.trim())
}
fn defect_cost(weight: u32, occurrences: u32) -> u32 {
let surcharge = match occurrences {
0..=1 => 0,
2..=4 => weight / 4,
5..=19 => weight / 2,
_ => weight,
};
weight + surcharge
}
fn distinct_defect_count(findings: &[HtmlConformFinding]) -> u32 {
let unique: std::collections::HashSet<String> = findings.iter().map(defect_key).collect();
unique.len() as u32
}
fn score_findings(findings: &[HtmlConformFinding]) -> u32 {
use std::collections::HashMap;
let mut per_defect: HashMap<String, (u32, u32)> = HashMap::new();
for finding in findings {
let weight = match finding.severity.as_str() {
"error" => ERROR_WEIGHT,
"warning" => WARNING_WEIGHT,
_ => INFO_WEIGHT,
};
let entry = per_defect.entry(defect_key(finding)).or_insert((weight, 0));
entry.1 += 1;
}
let mut costs: Vec<u32> = per_defect
.into_values()
.map(|(weight, occurrences)| defect_cost(weight, occurrences))
.collect();
costs.sort_unstable_by(|a, b| b.cmp(a));
let penalty: u32 = costs
.into_iter()
.enumerate()
.map(|(rank, cost)| match rank {
0 => cost,
1..=2 => cost / 2,
_ => cost / 4,
})
.sum();
100u32.saturating_sub(penalty.min(100))
}
fn not_measured() -> HtmlConformAnalysis {
HtmlConformAnalysis {
score: 100,
checked: false,
error_count: 0,
warning_count: 0,
info_count: 0,
distinct_defect_count: 0,
findings: Vec::new(),
raw_html: None,
}
}
async fn extract_document_html(page: &Page) -> Result<String> {
let js = r#"
(() => {
const d = document.doctype;
const doctype = d
? `<!DOCTYPE ${d.name}${d.publicId ? ` PUBLIC "${d.publicId}"` : ''}${d.systemId ? ` "${d.systemId}"` : ''}>`
: '<!DOCTYPE html>';
return doctype + '\n' + document.documentElement.outerHTML;
})()
"#;
let result = page.evaluate(js).await.map_err(|e| {
AuditError::CdpError(format!(
"HTML extraction for conformance check failed: {}",
e
))
})?;
result
.value()
.and_then(|v| v.as_str())
.map(str::to_string)
.ok_or_else(|| {
AuditError::CdpError(
"HTML extraction for conformance check returned no value".to_string(),
)
})
}
#[cfg(test)]
mod tests {
use super::*;
fn finding(rule: &str, severity: &str, message: &str) -> HtmlConformFinding {
HtmlConformFinding {
rule_id: rule.to_string(),
severity: severity.to_string(),
message: message.to_string(),
location: None,
byte_offset: None,
}
}
#[test]
fn defect_key_collapses_value_and_position_variation() {
let a = finding(
"schema.html5",
"error",
"invalid value `100%` for attribute `height` at 2:29748",
);
let b = finding(
"schema.html5",
"error",
"invalid value `150px` for attribute `height` at 9:41",
);
assert_eq!(defect_key(&a), defect_key(&b));
}
#[test]
fn defect_key_keeps_genuinely_different_defects_apart() {
let height = finding(
"schema.html5",
"error",
"invalid value `100%` for attribute `height`",
);
let element = finding("schema.html5", "error", "unexpected element `div`");
let other_rule = finding(
"parser.html5",
"error",
"invalid value `100%` for attribute `height`",
);
assert_ne!(defect_key(&height), defect_key(&element));
assert_ne!(defect_key(&height), defect_key(&other_rule));
}
#[test]
fn repetition_of_one_defect_does_not_floor_the_score() {
let findings: Vec<_> = (0..12)
.map(|i| {
finding(
"assertion.elements.linkas-missing-rel",
"error",
&format!("A `link` element with `as` must have `rel` at 2:{i}"),
)
})
.collect();
assert_eq!(distinct_defect_count(&findings), 1);
assert_eq!(score_findings(&findings), 85);
}
#[test]
fn distinct_defects_are_damped_by_rank() {
let findings: Vec<_> = (0..4)
.map(|i| finding("schema.html5", "error", &format!("defect number {i}")))
.collect();
assert_eq!(distinct_defect_count(&findings), 4);
assert_eq!(score_findings(&findings), 78);
}
#[test]
fn a_single_error_costs_its_full_weight() {
let findings = vec![finding("schema.html5", "error", "one defect")];
assert_eq!(score_findings(&findings), 100 - ERROR_WEIGHT);
}
#[test]
fn severity_is_weighted() {
let error = vec![finding("r", "error", "m")];
let warning = vec![finding("r", "warning", "m")];
let info = vec![finding("r", "info", "m")];
assert!(score_findings(&error) < score_findings(&warning));
assert!(score_findings(&warning) < score_findings(&info));
}
#[test]
fn clean_page_scores_full_marks() {
assert_eq!(score_findings(&[]), 100);
assert_eq!(distinct_defect_count(&[]), 0);
}
#[test]
fn score_floors_at_zero() {
let findings: Vec<_> = (0..200)
.map(|i| finding("schema.html5", "error", &format!("defect number {i}")))
.collect();
assert_eq!(score_findings(&findings), 0);
}
#[test]
fn not_measured_has_full_score_and_is_excluded() {
let a = not_measured();
assert!(!a.checked);
assert_eq!(a.score, 100);
assert!(a.findings.is_empty());
assert_eq!(a.distinct_defect_count, 0);
}
}