Skip to main content

max / makenotwork

11.4 KB · 293 lines History Blame Raw
1 //! The wave-2 parity harness: is the converted screen the same page?
2 //!
3 //! Every phase-3 conversion asserts that the screen it just described renders
4 //! what Askama rendered. The whole safety argument for converting a hundred
5 //! screens rests on this file, so what it does and does not check is worth
6 //! stating rather than inferring.
7 //!
8 //! # What equivalence means here
9 //!
10 //! Byte-identical HTML is the wrong bar. Attribute order carries no meaning,
11 //! whitespace between tags collapses, and a description layer emits neither in
12 //! the order a hand-written template happened to. A test that fails on those
13 //! fails on every conversion and gets muted, which is worse than no test.
14 //!
15 //! "Looks the same" is the other wrong bar, because nothing can assert it.
16 //!
17 //! So: a normalized token stream. Both sides are tokenized with the same parser
18 //! the sanitizer already uses, then reduced to what a browser would act on.
19 //!
20 //! - Attributes are sorted by name, so order stops being a difference.
21 //! - `class` is compared as a SET of classes, not a string, for the same reason.
22 //! - Runs of whitespace in text collapse to one space, and text that is
23 //! entirely whitespace is dropped. NOT inside `pre` or `textarea`, where
24 //! whitespace is the content.
25 //! - Comments are dropped. A comment changes no pixel.
26 //! - End tags are kept. Nesting is most of what a page IS, and dropping them
27 //! would let a converted screen close a region in the wrong place and pass.
28 //!
29 //! Everything else is a difference and fails.
30 //!
31 //! # What it deliberately does not do
32 //!
33 //! It does not parse into a tree, so it does not repair malformed markup the
34 //! way a browser would. Two documents that a browser would reconcile to the
35 //! same DOM but that tokenize differently will fail here. That is the strict
36 //! direction and it is the right one for a conversion: if the two sides differ
37 //! enough that only the parser's error recovery makes them agree, the
38 //! conversion should say so.
39 //!
40 //! # The allowlist
41 //!
42 //! Some differences are the POINT of the conversion, and those are named per
43 //! test rather than blanket-ignored, so that each one is a claim somebody
44 //! wrote down. Retiring the private `data-action` vocabulary means the
45 //! converted side does not emit it; that is progress, not a regression, and it
46 //! is spelled `.ignoring_attr("data-action")` at the call site.
47 //!
48 //! A blanket "ignore anything that differs" option is deliberately absent.
49
50 // `tests/harness/mod.rs` is compiled into every test binary, and only the
51 // parity ones call this. Per-item allows the way `faults.rs` does it would be
52 // one on nearly every item here.
53 #![allow(dead_code)]
54
55 use std::cell::RefCell;
56 use std::collections::BTreeMap;
57 use std::fmt::Write as _;
58
59 use html5ever::tokenizer::{
60 BufferQueue, Token, TokenSink, TokenSinkResult, Tokenizer, TokenizerOpts,
61 };
62
63 /// One normalized piece of a document.
64 #[derive(Debug, Clone, PartialEq, Eq)]
65 pub(crate) enum Piece {
66 /// An open tag: name, then attributes sorted by name. `class` values are
67 /// sorted within the value so the set is what compares.
68 Open(String, BTreeMap<String, String>),
69 /// A close tag.
70 Close(String),
71 /// Text, whitespace-collapsed unless it came from a `pre`/`textarea`.
72 Text(String),
73 /// A doctype, lowercased. Present or absent is a real difference: it
74 /// decides standards mode.
75 Doctype(String),
76 }
77
78 impl Piece {
79 /// A one-line rendering for the failure message.
80 fn show(&self) -> String {
81 match self {
82 Self::Open(name, attrs) => {
83 let attrs: Vec<String> = attrs
84 .iter()
85 .map(|(k, v)| {
86 if v.is_empty() {
87 k.clone()
88 } else {
89 format!("{k}=\"{v}\"")
90 }
91 })
92 .collect();
93 if attrs.is_empty() {
94 format!("<{name}>")
95 } else {
96 format!("<{name} {}>", attrs.join(" "))
97 }
98 }
99 Self::Close(name) => format!("</{name}>"),
100 Self::Text(t) => format!("{t:?}"),
101 Self::Doctype(d) => format!("<!doctype {d}>"),
102 }
103 }
104 }
105
106 /// How two renderings of one screen are allowed to differ.
107 #[derive(Debug, Clone, Default)]
108 pub(crate) struct Parity {
109 ignored_attrs: Vec<String>,
110 ignored_classes: Vec<String>,
111 }
112
113 impl Parity {
114 /// Strict: every difference fails.
115 pub(crate) fn strict() -> Self {
116 Self::default()
117 }
118
119 /// Drop an attribute from both sides before comparing.
120 ///
121 /// For the attributes the conversion exists to retire. Name each one; the
122 /// list at a call site is the record of what that screen gave up.
123 #[must_use]
124 pub(crate) fn ignoring_attr(mut self, name: &str) -> Self {
125 self.ignored_attrs.push(name.to_ascii_lowercase());
126 self
127 }
128
129 /// Drop a class from both sides' `class` attributes before comparing.
130 ///
131 /// For the generated primitives a converted screen gains and a hand-written
132 /// one never had.
133 #[must_use]
134 pub(crate) fn ignoring_class(mut self, name: &str) -> Self {
135 self.ignored_classes.push(name.to_owned());
136 self
137 }
138
139 /// Reduce a document to its comparable pieces.
140 pub(crate) fn normalize(&self, html: &str) -> Vec<Piece> {
141 let raw = tokenize(html);
142 let mut out = Vec::with_capacity(raw.len());
143 for piece in raw {
144 match piece {
145 Piece::Open(name, attrs) => {
146 let attrs = attrs
147 .into_iter()
148 .filter(|(k, _)| !self.ignored_attrs.contains(k))
149 .map(|(k, v)| {
150 if k == "class" {
151 let mut classes: Vec<&str> = v
152 .split_ascii_whitespace()
153 .filter(|c| !self.ignored_classes.iter().any(|i| i == c))
154 .collect();
155 classes.sort_unstable();
156 (k, classes.join(" "))
157 } else {
158 (k, v)
159 }
160 })
161 // A class attribute emptied by the allowlist is not the
162 // same as one that was never there, but for our purpose
163 // it is: both mean "no classes the test cares about".
164 .filter(|(k, v)| !(k == "class" && v.is_empty()))
165 .collect();
166 out.push(Piece::Open(name, attrs));
167 }
168 other => out.push(other),
169 }
170 }
171 out
172 }
173
174 /// Assert two renderings of one screen are equivalent.
175 ///
176 /// `askama` is what the server serves today and `quasi` is what the
177 /// description emits; the argument order is the direction of the
178 /// conversion, and it decides which side a difference is reported against.
179 #[track_caller]
180 pub(crate) fn assert(&self, screen: &str, askama: &str, quasi: &str) {
181 let left = self.normalize(askama);
182 let right = self.normalize(quasi);
183 if left == right {
184 return;
185 }
186
187 let at = left
188 .iter()
189 .zip(right.iter())
190 .position(|(a, b)| a != b)
191 .unwrap_or_else(|| left.len().min(right.len()));
192
193 let mut msg = format!(
194 "screen `{screen}` does not render the same page through quasi as through Askama\n\
195 first difference at piece {at} of {} (Askama) / {} (quasi)\n",
196 left.len(),
197 right.len()
198 );
199 let from = at.saturating_sub(3);
200 msg.push_str("\n context, Askama:\n");
201 for (i, p) in left.iter().enumerate().skip(from).take(7) {
202 let marker = if i == at { ">>" } else { " " };
203 let _ = writeln!(msg, " {marker} {i:4} {}", p.show());
204 }
205 msg.push_str("\n context, quasi:\n");
206 for (i, p) in right.iter().enumerate().skip(from).take(7) {
207 let marker = if i == at { ">>" } else { " " };
208 let _ = writeln!(msg, " {marker} {i:4} {}", p.show());
209 }
210 msg.push_str(
211 "\nIf the difference is the point of the conversion, name it with \
212 .ignoring_attr()/.ignoring_class() rather than widening the test.\n",
213 );
214 panic!("{msg}");
215 }
216 }
217
218 /// The sink: normalization that does not depend on the allowlist.
219 struct Sink {
220 pieces: RefCell<Vec<Piece>>,
221 /// Depth inside an element whose whitespace is content.
222 literal: RefCell<u32>,
223 }
224
225 impl TokenSink for Sink {
226 type Handle = ();
227
228 fn process_token(&self, token: Token, _line: u64) -> TokenSinkResult<()> {
229 match token {
230 Token::DoctypeToken(d) => {
231 let name = d.name.unwrap_or_default().to_ascii_lowercase();
232 self.pieces.borrow_mut().push(Piece::Doctype(name));
233 }
234 Token::TagToken(tag) => {
235 let name = tag.name.to_string();
236 let literal = matches!(name.as_str(), "pre" | "textarea");
237 match tag.kind {
238 html5ever::tokenizer::TagKind::StartTag => {
239 if literal {
240 *self.literal.borrow_mut() += 1;
241 }
242 let mut attrs = BTreeMap::new();
243 for attr in tag.attrs {
244 attrs.insert(
245 attr.name.local.to_string().to_ascii_lowercase(),
246 attr.value.to_string(),
247 );
248 }
249 self.pieces.borrow_mut().push(Piece::Open(name, attrs));
250 }
251 html5ever::tokenizer::TagKind::EndTag => {
252 if literal {
253 let mut depth = self.literal.borrow_mut();
254 *depth = depth.saturating_sub(1);
255 }
256 self.pieces.borrow_mut().push(Piece::Close(name));
257 }
258 }
259 }
260 Token::CharacterTokens(text) => {
261 let text = text.to_string();
262 if *self.literal.borrow() > 0 {
263 self.pieces.borrow_mut().push(Piece::Text(text));
264 } else {
265 let collapsed = text.split_whitespace().collect::<Vec<_>>().join(" ");
266 if !collapsed.is_empty() {
267 self.pieces.borrow_mut().push(Piece::Text(collapsed));
268 }
269 }
270 }
271 // Comments change no pixel. Parse errors are the tokenizer's
272 // opinion about malformed input, and both sides get the same
273 // treatment, so neither is a difference worth failing on.
274 Token::CommentToken(_) | Token::ParseError(_) => {}
275 Token::NullCharacterToken | Token::EOFToken => {}
276 }
277 TokenSinkResult::Continue
278 }
279 }
280
281 fn tokenize(html: &str) -> Vec<Piece> {
282 let sink = Sink {
283 pieces: RefCell::new(Vec::new()),
284 literal: RefCell::new(0),
285 };
286 let tok = Tokenizer::new(sink, TokenizerOpts::default());
287 let input = BufferQueue::default();
288 input.push_back(html5ever::tendril::StrTendril::from(html));
289 let _ = tok.feed(&input);
290 tok.end();
291 tok.sink.pieces.take()
292 }
293