//! The wave-2 parity harness: is the converted screen the same page? //! //! Every phase-3 conversion asserts that the screen it just described renders //! what Askama rendered. The whole safety argument for converting a hundred //! screens rests on this file, so what it does and does not check is worth //! stating rather than inferring. //! //! # What equivalence means here //! //! Byte-identical HTML is the wrong bar. Attribute order carries no meaning, //! whitespace between tags collapses, and a description layer emits neither in //! the order a hand-written template happened to. A test that fails on those //! fails on every conversion and gets muted, which is worse than no test. //! //! "Looks the same" is the other wrong bar, because nothing can assert it. //! //! So: a normalized token stream. Both sides are tokenized with the same parser //! the sanitizer already uses, then reduced to what a browser would act on. //! //! - Attributes are sorted by name, so order stops being a difference. //! - `class` is compared as a SET of classes, not a string, for the same reason. //! - Runs of whitespace in text collapse to one space, and text that is //! entirely whitespace is dropped. NOT inside `pre` or `textarea`, where //! whitespace is the content. //! - Comments are dropped. A comment changes no pixel. //! - End tags are kept. Nesting is most of what a page IS, and dropping them //! would let a converted screen close a region in the wrong place and pass. //! //! Everything else is a difference and fails. //! //! # What it deliberately does not do //! //! It does not parse into a tree, so it does not repair malformed markup the //! way a browser would. Two documents that a browser would reconcile to the //! same DOM but that tokenize differently will fail here. That is the strict //! direction and it is the right one for a conversion: if the two sides differ //! enough that only the parser's error recovery makes them agree, the //! conversion should say so. //! //! # The allowlist //! //! Some differences are the POINT of the conversion, and those are named per //! test rather than blanket-ignored, so that each one is a claim somebody //! wrote down. Retiring the private `data-action` vocabulary means the //! converted side does not emit it; that is progress, not a regression, and it //! is spelled `.ignoring_attr("data-action")` at the call site. //! //! A blanket "ignore anything that differs" option is deliberately absent. // `tests/harness/mod.rs` is compiled into every test binary, and only the // parity ones call this. Per-item allows the way `faults.rs` does it would be // one on nearly every item here. #![allow(dead_code)] use std::cell::RefCell; use std::collections::BTreeMap; use std::fmt::Write as _; use html5ever::tokenizer::{ BufferQueue, Token, TokenSink, TokenSinkResult, Tokenizer, TokenizerOpts, }; /// One normalized piece of a document. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum Piece { /// An open tag: name, then attributes sorted by name. `class` values are /// sorted within the value so the set is what compares. Open(String, BTreeMap), /// A close tag. Close(String), /// Text, whitespace-collapsed unless it came from a `pre`/`textarea`. Text(String), /// A doctype, lowercased. Present or absent is a real difference: it /// decides standards mode. Doctype(String), } impl Piece { /// A one-line rendering for the failure message. fn show(&self) -> String { match self { Self::Open(name, attrs) => { let attrs: Vec = attrs .iter() .map(|(k, v)| { if v.is_empty() { k.clone() } else { format!("{k}=\"{v}\"") } }) .collect(); if attrs.is_empty() { format!("<{name}>") } else { format!("<{name} {}>", attrs.join(" ")) } } Self::Close(name) => format!(""), Self::Text(t) => format!("{t:?}"), Self::Doctype(d) => format!(""), } } } /// How two renderings of one screen are allowed to differ. #[derive(Debug, Clone, Default)] pub(crate) struct Parity { ignored_attrs: Vec, ignored_classes: Vec, } impl Parity { /// Strict: every difference fails. pub(crate) fn strict() -> Self { Self::default() } /// Drop an attribute from both sides before comparing. /// /// For the attributes the conversion exists to retire. Name each one; the /// list at a call site is the record of what that screen gave up. #[must_use] pub(crate) fn ignoring_attr(mut self, name: &str) -> Self { self.ignored_attrs.push(name.to_ascii_lowercase()); self } /// Drop a class from both sides' `class` attributes before comparing. /// /// For the generated primitives a converted screen gains and a hand-written /// one never had. #[must_use] pub(crate) fn ignoring_class(mut self, name: &str) -> Self { self.ignored_classes.push(name.to_owned()); self } /// Reduce a document to its comparable pieces. pub(crate) fn normalize(&self, html: &str) -> Vec { let raw = tokenize(html); let mut out = Vec::with_capacity(raw.len()); for piece in raw { match piece { Piece::Open(name, attrs) => { let attrs = attrs .into_iter() .filter(|(k, _)| !self.ignored_attrs.contains(k)) .map(|(k, v)| { if k == "class" { let mut classes: Vec<&str> = v .split_ascii_whitespace() .filter(|c| !self.ignored_classes.iter().any(|i| i == c)) .collect(); classes.sort_unstable(); (k, classes.join(" ")) } else { (k, v) } }) // A class attribute emptied by the allowlist is not the // same as one that was never there, but for our purpose // it is: both mean "no classes the test cares about". .filter(|(k, v)| !(k == "class" && v.is_empty())) .collect(); out.push(Piece::Open(name, attrs)); } other => out.push(other), } } out } /// Assert two renderings of one screen are equivalent. /// /// `askama` is what the server serves today and `quasi` is what the /// description emits; the argument order is the direction of the /// conversion, and it decides which side a difference is reported against. #[track_caller] pub(crate) fn assert(&self, screen: &str, askama: &str, quasi: &str) { let left = self.normalize(askama); let right = self.normalize(quasi); if left == right { return; } let at = left .iter() .zip(right.iter()) .position(|(a, b)| a != b) .unwrap_or_else(|| left.len().min(right.len())); let mut msg = format!( "screen `{screen}` does not render the same page through quasi as through Askama\n\ first difference at piece {at} of {} (Askama) / {} (quasi)\n", left.len(), right.len() ); let from = at.saturating_sub(3); msg.push_str("\n context, Askama:\n"); for (i, p) in left.iter().enumerate().skip(from).take(7) { let marker = if i == at { ">>" } else { " " }; let _ = writeln!(msg, " {marker} {i:4} {}", p.show()); } msg.push_str("\n context, quasi:\n"); for (i, p) in right.iter().enumerate().skip(from).take(7) { let marker = if i == at { ">>" } else { " " }; let _ = writeln!(msg, " {marker} {i:4} {}", p.show()); } msg.push_str( "\nIf the difference is the point of the conversion, name it with \ .ignoring_attr()/.ignoring_class() rather than widening the test.\n", ); panic!("{msg}"); } } /// The sink: normalization that does not depend on the allowlist. struct Sink { pieces: RefCell>, /// Depth inside an element whose whitespace is content. literal: RefCell, } impl TokenSink for Sink { type Handle = (); fn process_token(&self, token: Token, _line: u64) -> TokenSinkResult<()> { match token { Token::DoctypeToken(d) => { let name = d.name.unwrap_or_default().to_ascii_lowercase(); self.pieces.borrow_mut().push(Piece::Doctype(name)); } Token::TagToken(tag) => { let name = tag.name.to_string(); let literal = matches!(name.as_str(), "pre" | "textarea"); match tag.kind { html5ever::tokenizer::TagKind::StartTag => { if literal { *self.literal.borrow_mut() += 1; } let mut attrs = BTreeMap::new(); for attr in tag.attrs { attrs.insert( attr.name.local.to_string().to_ascii_lowercase(), attr.value.to_string(), ); } self.pieces.borrow_mut().push(Piece::Open(name, attrs)); } html5ever::tokenizer::TagKind::EndTag => { if literal { let mut depth = self.literal.borrow_mut(); *depth = depth.saturating_sub(1); } self.pieces.borrow_mut().push(Piece::Close(name)); } } } Token::CharacterTokens(text) => { let text = text.to_string(); if *self.literal.borrow() > 0 { self.pieces.borrow_mut().push(Piece::Text(text)); } else { let collapsed = text.split_whitespace().collect::>().join(" "); if !collapsed.is_empty() { self.pieces.borrow_mut().push(Piece::Text(collapsed)); } } } // Comments change no pixel. Parse errors are the tokenizer's // opinion about malformed input, and both sides get the same // treatment, so neither is a difference worth failing on. Token::CommentToken(_) | Token::ParseError(_) => {} Token::NullCharacterToken | Token::EOFToken => {} } TokenSinkResult::Continue } } fn tokenize(html: &str) -> Vec { let sink = Sink { pieces: RefCell::new(Vec::new()), literal: RefCell::new(0), }; let tok = Tokenizer::new(sink, TokenizerOpts::default()); let input = BufferQueue::default(); input.push_back(html5ever::tendril::StrTendril::from(html)); let _ = tok.feed(&input); tok.end(); tok.sink.pieces.take() }