use chromiumoxide::Page;
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::cli::WcagLevel;
pub use crate::taxonomy::Severity;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum FindingKind {
#[default]
Violation,
Warning,
Positive,
NotTestable,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ViolationEvidence {
pub source: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub field: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
}
impl ViolationEvidence {
pub fn ax_tree(value: impl Into<String>) -> Self {
Self {
source: "ax_tree".to_string(),
field: None,
value: Some(value.into()),
}
}
pub fn dom_attribute(field: impl Into<String>, value: Option<String>) -> Self {
Self {
source: "dom_attribute".to_string(),
field: Some(field.into()),
value,
}
}
pub fn computed(field: impl Into<String>, value: impl Into<String>) -> Self {
Self {
source: "computed".to_string(),
field: Some(field.into()),
value: Some(value.into()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Violation {
pub rule: String,
pub rule_name: String,
pub level: WcagLevel,
pub severity: Severity,
pub message: String,
pub node_id: String,
pub role: Option<String>,
pub name: Option<String>,
pub selector: Option<String>,
pub fix_suggestion: Option<String>,
pub help_url: Option<String>,
#[serde(default)]
pub rule_id: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub impact: Option<String>,
#[serde(default)]
pub html_snippet: Option<String>,
#[serde(default)]
pub suggested_code: Option<String>,
#[serde(default)]
pub kind: FindingKind,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub evidence: Vec<ViolationEvidence>,
#[serde(skip)]
pub evidence_screenshot: Option<Vec<u8>>,
#[serde(skip)]
pub evidence_viewport: Option<&'static str>,
}
impl Violation {
pub fn new(
rule: impl Into<String>,
rule_name: impl Into<String>,
level: WcagLevel,
severity: Severity,
message: impl Into<String>,
node_id: impl Into<String>,
) -> Self {
let sev = severity;
let impact = Some(Self::severity_to_impact(sev).to_string());
Self {
rule: rule.into(),
rule_name: rule_name.into(),
level,
severity,
message: message.into(),
node_id: node_id.into(),
role: None,
name: None,
selector: None,
fix_suggestion: None,
help_url: None,
rule_id: None,
tags: Vec::new(),
impact,
html_snippet: None,
suggested_code: None,
kind: FindingKind::Violation,
evidence: Vec::new(),
evidence_screenshot: None,
evidence_viewport: None,
}
}
fn severity_to_impact(severity: Severity) -> &'static str {
match severity {
Severity::Critical => "critical",
Severity::High => "serious",
Severity::Medium => "moderate",
Severity::Low => "minor",
}
}
pub fn impact_str(&self) -> &str {
Self::severity_to_impact(self.severity)
}
pub fn with_role(mut self, role: Option<String>) -> Self {
self.role = role;
self
}
pub fn with_name(mut self, name: Option<String>) -> Self {
self.name = name;
self
}
pub fn with_selector(mut self, selector: impl Into<String>) -> Self {
self.selector = Some(selector.into());
self
}
pub fn with_fix(mut self, fix: impl Into<String>) -> Self {
self.fix_suggestion = Some(fix.into());
self
}
pub fn with_help_url(mut self, url: impl Into<String>) -> Self {
self.help_url = Some(url.into());
self
}
pub fn with_rule_id(mut self, id: impl Into<String>) -> Self {
self.rule_id = Some(id.into());
self
}
pub fn with_tags(mut self, tags: Vec<String>) -> Self {
self.tags = tags;
self
}
pub fn with_html_snippet(mut self, snippet: impl Into<String>) -> Self {
self.html_snippet = Some(snippet.into());
self
}
pub fn with_suggested_code(mut self, code: impl Into<String>) -> Self {
self.suggested_code = Some(code.into());
self
}
pub fn with_kind(mut self, kind: FindingKind) -> Self {
self.kind = kind;
self
}
pub fn with_evidence_item(mut self, ev: ViolationEvidence) -> Self {
self.evidence.push(ev);
self
}
pub fn as_warning(self) -> Self {
self.with_kind(FindingKind::Warning)
}
pub fn as_positive(self) -> Self {
self.with_kind(FindingKind::Positive)
}
}
pub fn technical_rule_failure(rule: &RuleMetadata, reason_code: &str) -> Violation {
Violation::new(
rule.id,
rule.name,
rule.level,
Severity::Low,
"The automated rule could not be evaluated.",
"document",
)
.with_rule_id(rule.axe_id)
.with_tags(vec![
"audit-execution-failed".to_string(),
format!("reason:{reason_code}"),
])
.with_kind(FindingKind::NotTestable)
}
pub fn technical_rule_failure_for(rule_id: &str, level: WcagLevel, reason_code: &str) -> Violation {
Violation::new(
rule_id,
"Automated page check",
level,
Severity::Low,
"The automated rule could not be evaluated.",
"document",
)
.with_rule_id(rule_id)
.with_tags(vec![
"audit-execution-failed".to_string(),
format!("reason:{reason_code}"),
])
.with_kind(FindingKind::NotTestable)
}
enum EvalFailure {
PageError(chromiumoxide::error::CdpError),
MissingValue,
}
async fn evaluate_and_extract(page: &Page, js: &str) -> Result<serde_json::Value, EvalFailure> {
let result = page.evaluate(js).await.map_err(EvalFailure::PageError)?;
result.value().cloned().ok_or(EvalFailure::MissingValue)
}
fn eval_failure_reason(err: &EvalFailure) -> &'static str {
match err {
EvalFailure::PageError(_) => "page_evaluation_failed",
EvalFailure::MissingValue => "missing_evaluation_value",
}
}
pub async fn evaluate_or_fail(
page: &Page,
rule: &RuleMetadata,
js: &str,
) -> Result<serde_json::Value, Vec<Violation>> {
evaluate_and_extract(page, js)
.await
.map_err(|err| vec![technical_rule_failure(rule, eval_failure_reason(&err))])
}
pub async fn evaluate_or_fail_for(
page: &Page,
rule_id: &str,
level: WcagLevel,
js: &str,
) -> Result<serde_json::Value, Vec<Violation>> {
evaluate_and_extract(page, js).await.map_err(|err| {
if let EvalFailure::PageError(ref e) = err {
warn!("{rule_id} JS failed: {e}");
}
vec![technical_rule_failure_for(
rule_id,
level,
eval_failure_reason(&err),
)]
})
}
pub fn technical_failure_reason(finding: &Violation) -> Option<&str> {
if !finding
.tags
.iter()
.any(|tag| tag == "audit-execution-failed")
{
return None;
}
finding
.tags
.iter()
.find_map(|tag| tag.strip_prefix("reason:"))
.or(Some("rule_execution_failed"))
}
#[derive(Debug, Clone)]
pub struct RuleMetadata {
pub id: &'static str,
pub name: &'static str,
pub level: WcagLevel,
pub severity: Severity,
pub description: &'static str,
pub help_url: &'static str,
pub axe_id: &'static str,
pub tags: &'static [&'static str],
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WcagResults {
pub violations: Vec<Violation>,
#[serde(default)]
pub warnings: Vec<Violation>,
#[serde(default)]
pub positives: Vec<Violation>,
#[serde(default)]
pub not_testables: Vec<Violation>,
pub passes: usize,
pub incomplete: usize,
pub nodes_checked: usize,
#[serde(default)]
pub rule_outcomes: Vec<RuleOutcome>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuleOutcomeStatus {
ViolationsFound,
NoViolationDetected,
Warning,
ManualReviewRequired,
Failed,
Skipped,
NotApplicable,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuleOutcome {
pub rule_id: String,
pub status: RuleOutcomeStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wcag_criterion: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub viewport: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason_code: Option<String>,
#[serde(default)]
pub finding_count: usize,
}
impl WcagResults {
pub fn new() -> Self {
Self {
violations: Vec::new(),
warnings: Vec::new(),
positives: Vec::new(),
not_testables: Vec::new(),
passes: 0,
incomplete: 0,
nodes_checked: 0,
rule_outcomes: Vec::new(),
}
}
pub fn add_violation(&mut self, finding: Violation) {
match finding.kind {
FindingKind::Warning => self.warnings.push(finding),
FindingKind::Positive => self.positives.push(finding),
FindingKind::NotTestable => self.not_testables.push(finding),
FindingKind::Violation => self.violations.push(finding),
}
}
pub fn extend_findings(&mut self, findings: Vec<Violation>) {
for f in findings {
self.add_violation(f);
}
}
pub fn add_warning(&mut self, mut finding: Violation) {
finding.kind = FindingKind::Warning;
self.warnings.push(finding);
}
pub fn add_positive(&mut self, mut finding: Violation) {
finding.kind = FindingKind::Positive;
self.positives.push(finding);
}
pub fn add_not_testable(&mut self, mut finding: Violation) {
finding.kind = FindingKind::NotTestable;
self.not_testables.push(finding);
}
pub fn count_by_severity(&self, severity: Severity) -> usize {
self.violations
.iter()
.filter(|v| v.severity == severity)
.count()
}
pub fn count_by_level(&self, level: WcagLevel) -> usize {
self.violations.iter().filter(|v| v.level == level).count()
}
pub fn count_by_rule(&self, rule: &str) -> usize {
self.violations.iter().filter(|v| v.rule == rule).count()
}
pub fn critical_violations(&self) -> Vec<&Violation> {
self.violations
.iter()
.filter(|v| v.severity == Severity::Critical)
.collect()
}
pub fn passes_level(&self, level: WcagLevel) -> bool {
!self.violations.iter().any(|v| {
match level {
WcagLevel::A => v.level == WcagLevel::A,
WcagLevel::AA => v.level == WcagLevel::A || v.level == WcagLevel::AA,
WcagLevel::AAA => true, }
})
}
pub fn merge(&mut self, other: WcagResults) {
self.violations.extend(other.violations);
self.warnings.extend(other.warnings);
self.positives.extend(other.positives);
self.not_testables.extend(other.not_testables);
self.passes += other.passes;
self.incomplete += other.incomplete;
self.nodes_checked += other.nodes_checked;
self.rule_outcomes.extend(other.rule_outcomes);
}
}
impl Default for WcagResults {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_violation_builder() {
let violation = Violation::new(
"1.1.1",
"Non-text Content",
WcagLevel::A,
Severity::High,
"Image missing alt text",
"node-123",
)
.with_role(Some("image".to_string()))
.with_fix("Add alt attribute to the image");
assert_eq!(violation.rule, "1.1.1");
assert_eq!(violation.role, Some("image".to_string()));
assert!(violation.fix_suggestion.is_some());
}
#[test]
fn test_severity_ordering() {
assert!(Severity::Critical > Severity::High);
assert!(Severity::High > Severity::Medium);
assert!(Severity::Medium > Severity::Low);
}
#[test]
fn violation_evidence_ax_tree_constructor() {
let ev = ViolationEvidence::ax_tree("img#logo");
assert_eq!(ev.source, "ax_tree");
assert_eq!(ev.value.as_deref(), Some("img#logo"));
assert!(ev.field.is_none());
}
#[test]
fn violation_evidence_dom_attribute_constructor() {
let ev = ViolationEvidence::dom_attribute("alt", Some("".to_string()));
assert_eq!(ev.source, "dom_attribute");
assert_eq!(ev.field.as_deref(), Some("alt"));
assert_eq!(ev.value.as_deref(), Some(""));
}
#[test]
fn violation_evidence_dom_attribute_no_value() {
let ev = ViolationEvidence::dom_attribute("role", None);
assert_eq!(ev.source, "dom_attribute");
assert!(ev.value.is_none());
}
#[test]
fn violation_with_evidence_item_accumulates() {
let v = Violation::new(
"1.1.1",
"Non-text Content",
WcagLevel::A,
Severity::High,
"msg",
"n1",
)
.with_evidence_item(ViolationEvidence::ax_tree("img.hero"))
.with_evidence_item(ViolationEvidence::dom_attribute("alt", None));
assert_eq!(v.evidence.len(), 2);
assert_eq!(v.evidence[0].source, "ax_tree");
assert_eq!(v.evidence[1].source, "dom_attribute");
}
#[test]
fn violation_new_starts_with_empty_evidence() {
let v = Violation::new(
"2.1.1",
"Keyboard",
WcagLevel::A,
Severity::High,
"msg",
"n1",
);
assert!(v.evidence.is_empty());
}
}