max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
10 files changed,
+619 insertions,
-8 deletions
| @@ -49,3 +49,11 @@ | |||
| 49 | 49 | # anywhere else and the seed builds the catalog without a buyer. The capture | |
| 50 | 50 | # script reads the same value as CAPTURE_BUYER_PASSWORD. See src/seed/buyer.rs. | |
| 51 | 51 | # TESTNOT_BUYER_PASSWORD= | |
| 52 | + | ||
| 53 | + | # Optional: the wave-2 conversion switch. A comma-separated list of screens that | |
| 54 | + | # serve from the quasi description layer instead of Askama; `*` converts | |
| 55 | + | # everything, which is a local-work setting. Unset means every screen serves | |
| 56 | + | # Askama, which is where every deployment stays until a screen's parity test is | |
| 57 | + | # green and Max flips it. A name that matches nothing is inert, so a typo | |
| 58 | + | # disables a screen rather than enabling the wrong one. See wiki look-wave-2. | |
| 59 | + | # QUASI_SCREENS= |
| @@ -5231,6 +5231,7 @@ | |||
| 5231 | 5231 | "governor", | |
| 5232 | 5232 | "hex", | |
| 5233 | 5233 | "hmac 0.13.0", | |
| 5234 | + | "html5ever", | |
| 5234 | 5235 | "http-body-util", | |
| 5235 | 5236 | "infer", | |
| 5236 | 5237 | "jsonwebtoken", | |
| @@ -10665,14 +10666,6 @@ | |||
| 10665 | 10666 | name = "quasi-webview" | |
| 10666 | 10667 | version = "0.1.0" | |
| 10667 | 10668 | ||
| 10668 | - | [[patch.unused]] | |
| 10669 | - | name = "kberg" | |
| 10670 | - | version = "0.1.0" | |
| 10671 | - | ||
| 10672 | - | [[patch.unused]] | |
| 10673 | - | name = "painhours" | |
| 10674 | - | version = "0.1.0" | |
| 10675 | - | ||
| 10676 | 10669 | [[patch.unused]] | |
| 10677 | 10670 | name = "synckit-client" | |
| 10678 | 10671 | version = "0.8.0" | |
| @@ -10680,3 +10673,11 @@ | |||
| 10680 | 10673 | [[patch.unused]] | |
| 10681 | 10674 | name = "synckit-config" | |
| 10682 | 10675 | version = "0.2.0" | |
| 10676 | + | ||
| 10677 | + | [[patch.unused]] | |
| 10678 | + | name = "kberg" | |
| 10679 | + | version = "0.1.0" | |
| 10680 | + | ||
| 10681 | + | [[patch.unused]] | |
| 10682 | + | name = "painhours" | |
| 10683 | + | version = "0.1.0" |
| @@ -234,6 +234,10 @@ | |||
| 234 | 234 | proptest = "1" | |
| 235 | 235 | wiremock = "0.6" | |
| 236 | 236 | pom-contract = { path = "../shared/pom-contract" } | |
| 237 | + | # The parity harness's normalizer. Already in the graph as ammonia's parser, so | |
| 238 | + | # this pins the same build rather than adding one; a dev-dependency because | |
| 239 | + | # nothing in the served binary parses HTML. | |
| 240 | + | html5ever = "0.39" | |
| 237 | 241 | ||
| 238 | 242 | [profile.release] | |
| 239 | 243 | # Drop the symbol table from the shipped binary. Release builds already carry no |
| @@ -1030,6 +1030,7 @@ | |||
| 1030 | 1030 | access_gate: crate::config::AccessGate::Open, | |
| 1031 | 1031 | sso: None, | |
| 1032 | 1032 | rate_limits: crate::constants::RateLimits::production(), | |
| 1033 | + | quasi_screens: crate::config::QuasiScreens::default(), | |
| 1033 | 1034 | build: BuildConfig { | |
| 1034 | 1035 | trigger_token: None, | |
| 1035 | 1036 | host_linux: None, | |
| @@ -1114,6 +1115,7 @@ | |||
| 1114 | 1115 | access_gate: crate::config::AccessGate::Open, | |
| 1115 | 1116 | sso: None, | |
| 1116 | 1117 | rate_limits: crate::constants::RateLimits::production(), | |
| 1118 | + | quasi_screens: crate::config::QuasiScreens::default(), | |
| 1117 | 1119 | build: BuildConfig { | |
| 1118 | 1120 | trigger_token: None, | |
| 1119 | 1121 | host_linux: None, |
| @@ -68,6 +68,68 @@ | |||
| 68 | 68 | /// Rate-limit profile the router is built with. Production everywhere that | |
| 69 | 69 | /// is not a test; see [`crate::constants::RateLimits`]. | |
| 70 | 70 | pub rate_limits: crate::constants::RateLimits, | |
| 71 | + | /// Which screens serve from the quasi description layer instead of Askama | |
| 72 | + | /// (`QUASI_SCREENS`). See [`QuasiScreens`]. | |
| 73 | + | pub quasi_screens: QuasiScreens, | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /// The wave-2 conversion switch, one screen at a time. | |
| 77 | + | /// | |
| 78 | + | /// Per screen and not global: a global flag means the first screen converted | |
| 79 | + | /// cannot ship until the last one is, which turns a hundred-screen conversion | |
| 80 | + | /// into one release. Per screen, each conversion ships the moment its parity | |
| 81 | + | /// test is green and reverts by editing an env var rather than by a deploy. | |
| 82 | + | /// | |
| 83 | + | /// Read from `QUASI_SCREENS` as a comma-separated list of screen names. Unset | |
| 84 | + | /// means empty means every screen still serves Askama, which is the state this | |
| 85 | + | /// is meant to be in everywhere until Max flips one: | |
| 86 | + | /// | |
| 87 | + | /// ```text | |
| 88 | + | /// QUASI_SCREENS=user_account,item_pricing | |
| 89 | + | /// QUASI_SCREENS=* # everything, for local work | |
| 90 | + | /// ``` | |
| 91 | + | /// | |
| 92 | + | /// The names are the conversion's own, matching the screen inventory in wiki | |
| 93 | + | /// `look-wave-2`. Nothing validates them against a registry: a typo disables a | |
| 94 | + | /// screen rather than enabling the wrong one, which is the safe direction, and | |
| 95 | + | /// a registry would have to be kept in step with a list that is still moving. | |
| 96 | + | #[derive(Clone, Debug, Default, PartialEq, Eq)] | |
| 97 | + | pub struct QuasiScreens { | |
| 98 | + | all: bool, | |
| 99 | + | names: std::collections::HashSet<String>, | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | impl QuasiScreens { | |
| 103 | + | /// Parse the comma-separated form. Blanks and stray whitespace are ignored. | |
| 104 | + | #[must_use] | |
| 105 | + | pub fn parse(raw: &str) -> Self { | |
| 106 | + | let mut all = false; | |
| 107 | + | let mut names = std::collections::HashSet::new(); | |
| 108 | + | for part in raw.split(',') { | |
| 109 | + | match part.trim() { | |
| 110 | + | "" => {} | |
| 111 | + | "*" => all = true, | |
| 112 | + | name => { | |
| 113 | + | names.insert(name.to_owned()); | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | Self { all, names } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | /// Whether this screen serves from the description layer. | |
| 121 | + | #[must_use] | |
| 122 | + | pub fn enabled(&self, screen: &str) -> bool { | |
| 123 | + | self.all || self.names.contains(screen) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | /// Whether any screen is converted. For the startup log line: a server | |
| 127 | + | /// serving one screen differently from every other deployment is worth | |
| 128 | + | /// saying out loud rather than discovering. | |
| 129 | + | #[must_use] | |
| 130 | + | pub fn any(&self) -> bool { | |
| 131 | + | self.all || !self.names.is_empty() | |
| 132 | + | } | |
| 71 | 133 | } | |
| 72 | 134 | ||
| 73 | 135 | /// Native build pipeline configuration (`BUILD_*`, `GIT_*`). | |
| @@ -495,6 +557,7 @@ | |||
| 495 | 557 | access_gate, | |
| 496 | 558 | sso, | |
| 497 | 559 | rate_limits: crate::constants::RateLimits::production(), | |
| 560 | + | quasi_screens: QuasiScreens::parse(&std::env::var("QUASI_SCREENS").unwrap_or_default()), | |
| 498 | 561 | build: BuildConfig { | |
| 499 | 562 | trigger_token: build_trigger_token, | |
| 500 | 563 | host_linux: build_host_linux, | |
| @@ -1055,6 +1118,7 @@ | |||
| 1055 | 1118 | access_gate: AccessGate::Open, | |
| 1056 | 1119 | sso: None, | |
| 1057 | 1120 | rate_limits: crate::constants::RateLimits::production(), | |
| 1121 | + | quasi_screens: QuasiScreens::default(), | |
| 1058 | 1122 | build: BuildConfig { | |
| 1059 | 1123 | trigger_token: None, | |
| 1060 | 1124 | host_linux: None, |
| @@ -132,6 +132,17 @@ | |||
| 132 | 132 | let config = Config::from_env().expect("Failed to load configuration"); | |
| 133 | 133 | tracing::info!("Configuration loaded"); | |
| 134 | 134 | ||
| 135 | + | // A box serving some screens from the description layer and the rest from | |
| 136 | + | // Askama is worth saying out loud. The failure this guards against is a | |
| 137 | + | // QUASI_SCREENS left set on a host nobody meant to convert, which otherwise | |
| 138 | + | // looks exactly like the site behaving oddly for one page. | |
| 139 | + | if config.quasi_screens.any() { | |
| 140 | + | tracing::warn!( | |
| 141 | + | screens = %std::env::var("QUASI_SCREENS").unwrap_or_default(), | |
| 142 | + | "QUASI_SCREENS is set: these screens serve from the description layer, not Askama" | |
| 143 | + | ); | |
| 144 | + | } | |
| 145 | + | ||
| 135 | 146 | // Create database connection pool with health checks and lifecycle limits. | |
| 136 | 147 | // - test_before_acquire: validates connections before use (catches stale/broken conns) | |
| 137 | 148 | // - max_lifetime: rotates connections to prevent long-lived session issues |
| @@ -5,6 +5,7 @@ | |||
| 5 | 5 | pub(crate) mod email; | |
| 6 | 6 | pub(crate) mod faults; | |
| 7 | 7 | pub(crate) mod gitfixture; | |
| 8 | + | pub(crate) mod parity; | |
| 8 | 9 | pub(crate) mod seed; | |
| 9 | 10 | pub(crate) mod storage; | |
| 10 | 11 | pub(crate) mod stripe; | |
| @@ -126,6 +127,10 @@ | |||
| 126 | 127 | /// state every pre-existing test was written against. Set it to exercise | |
| 127 | 128 | /// the surfaces that only exist while the window is open. | |
| 128 | 129 | pub founder_window_open: bool, | |
| 130 | + | /// Which screens serve from the description layer. Empty by default, so a | |
| 131 | + | /// test asserting the served HTML keeps asserting the Askama one until a | |
| 132 | + | /// conversion's own test opts in. | |
| 133 | + | pub quasi_screens: makenotwork::config::QuasiScreens, | |
| 129 | 134 | } | |
| 130 | 135 | ||
| 131 | 136 | /// Full test harness: isolated database, in-process app, cookie-aware client. | |
| @@ -417,6 +422,9 @@ | |||
| 417 | 422 | rate_limits: opts | |
| 418 | 423 | .rate_limits | |
| 419 | 424 | .unwrap_or_else(makenotwork::constants::RateLimits::relaxed), | |
| 425 | + | // Askama everywhere unless a test says otherwise, matching every | |
| 426 | + | // deployment until wave 2 flips a screen. | |
| 427 | + | quasi_screens: opts.quasi_screens.clone(), | |
| 420 | 428 | build: BuildConfig { | |
| 421 | 429 | trigger_token: opts.build_trigger_token, | |
| 422 | 430 | host_linux: None, |
| @@ -71,6 +71,7 @@ | |||
| 71 | 71 | // The load runner drives thousands of requests from one IP, so the | |
| 72 | 72 | // production limiter would measure the limiter rather than the server. | |
| 73 | 73 | rate_limits: makenotwork::constants::RateLimits::relaxed(), | |
| 74 | + | quasi_screens: makenotwork::config::QuasiScreens::default(), | |
| 74 | 75 | build: BuildConfig { | |
| 75 | 76 | trigger_token: None, | |
| 76 | 77 | host_linux: None, |
| @@ -1,0 +1,292 @@ | |||
| 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 | + | } |
| @@ -1,0 +1,220 @@ | |||
| 1 | + | //! Tests for the parity harness, and for the per-screen conversion switch. | |
| 2 | + | //! | |
| 3 | + | //! The harness is what every phase-3 conversion trusts, so it is tested on both | |
| 4 | + | //! sides: that it ignores what it claims to ignore, and that it still fails on | |
| 5 | + | //! everything else. A parity harness that quietly passes is worse than none, | |
| 6 | + | //! because the conversion's whole safety argument is that this file said yes. | |
| 7 | + | ||
| 8 | + | // Just the one module, not `mod harness;`. These are unit tests for the | |
| 9 | + | // normalizer and the switch: neither serves a request, so pulling the database, | |
| 10 | + | // storage and Stripe harnesses in would cost a much slower build for nothing. | |
| 11 | + | // The conversions' own parity tests go through the full harness. | |
| 12 | + | #[path = "harness/parity.rs"] | |
| 13 | + | mod parity; | |
| 14 | + | ||
| 15 | + | use makenotwork::config::QuasiScreens; | |
| 16 | + | use parity::Parity; | |
| 17 | + | ||
| 18 | + | // --- what it forgives ------------------------------------------------------- | |
| 19 | + | ||
| 20 | + | #[test] | |
| 21 | + | fn attribute_order_is_not_a_difference() { | |
| 22 | + | Parity::strict().assert( | |
| 23 | + | "t", | |
| 24 | + | r#"<div id="a" class="card" data-x="1"></div>"#, | |
| 25 | + | r#"<div data-x="1" id="a" class="card"></div>"#, | |
| 26 | + | ); | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | #[test] | |
| 30 | + | fn class_order_is_not_a_difference() { | |
| 31 | + | // A description emits classes in whatever order it composes them; a | |
| 32 | + | // template emits them in the order somebody typed. | |
| 33 | + | Parity::strict().assert( | |
| 34 | + | "t", | |
| 35 | + | r#"<div class="card is-selected raised"></div>"#, | |
| 36 | + | r#"<div class="raised card is-selected"></div>"#, | |
| 37 | + | ); | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | #[test] | |
| 41 | + | fn whitespace_between_tags_collapses() { | |
| 42 | + | Parity::strict().assert( | |
| 43 | + | "t", | |
| 44 | + | "<ul>\n <li>one</li>\n <li>two</li>\n</ul>", | |
| 45 | + | "<ul><li>one</li><li>two</li></ul>", | |
| 46 | + | ); | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | #[test] | |
| 50 | + | fn runs_of_whitespace_inside_text_collapse_to_one_space() { | |
| 51 | + | Parity::strict().assert("t", "<p>one two\n\tthree</p>", "<p>one two three</p>"); | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | #[test] | |
| 55 | + | fn comments_are_not_a_difference() { | |
| 56 | + | Parity::strict().assert( | |
| 57 | + | "t", | |
| 58 | + | "<div><!-- explains the next line --><span>x</span></div>", | |
| 59 | + | "<div><span>x</span></div>", | |
| 60 | + | ); | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // --- what it still catches -------------------------------------------------- | |
| 64 | + | ||
| 65 | + | #[test] | |
| 66 | + | #[should_panic(expected = "does not render the same page")] | |
| 67 | + | fn a_changed_attribute_value_fails() { | |
| 68 | + | Parity::strict().assert("t", r#"<a href="/a">x</a>"#, r#"<a href="/b">x</a>"#); | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | #[test] | |
| 72 | + | #[should_panic(expected = "does not render the same page")] | |
| 73 | + | fn a_dropped_class_fails() { | |
| 74 | + | Parity::strict().assert( | |
| 75 | + | "t", | |
| 76 | + | r#"<div class="card raised"></div>"#, | |
| 77 | + | r#"<div class="card"></div>"#, | |
| 78 | + | ); | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | #[test] | |
| 82 | + | #[should_panic(expected = "does not render the same page")] | |
| 83 | + | fn changed_text_fails() { | |
| 84 | + | Parity::strict().assert("t", "<p>Saved.</p>", "<p>Saved</p>"); | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | #[test] | |
| 88 | + | #[should_panic(expected = "does not render the same page")] | |
| 89 | + | fn different_nesting_fails() { | |
| 90 | + | // The case dropping end tags would have missed: same tags, same text, and | |
| 91 | + | // the region closes in the wrong place. | |
| 92 | + | Parity::strict().assert( | |
| 93 | + | "t", | |
| 94 | + | "<div><section><p>x</p></section></div>", | |
| 95 | + | "<div><section></section><p>x</p></div>", | |
| 96 | + | ); | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | #[test] | |
| 100 | + | #[should_panic(expected = "does not render the same page")] | |
| 101 | + | fn whitespace_inside_pre_is_content_and_still_compares() { | |
| 102 | + | Parity::strict().assert("t", "<pre>one two</pre>", "<pre>one two</pre>"); | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | #[test] | |
| 106 | + | #[should_panic(expected = "does not render the same page")] | |
| 107 | + | fn a_missing_doctype_fails() { | |
| 108 | + | // Not pedantry: it is the difference between standards and quirks mode. | |
| 109 | + | Parity::strict().assert("t", "<!doctype html><html></html>", "<html></html>"); | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // --- the allowlist ---------------------------------------------------------- | |
| 113 | + | ||
| 114 | + | #[test] | |
| 115 | + | fn a_named_attribute_can_be_forgiven() { | |
| 116 | + | // Retiring the private data-action vocabulary is progress, and it is named | |
| 117 | + | // per screen rather than ignored everywhere. | |
| 118 | + | Parity::strict().ignoring_attr("data-action").assert( | |
| 119 | + | "t", | |
| 120 | + | r#"<button data-action="toggleCart" data-arg="7">Buy</button>"#, | |
| 121 | + | r#"<button data-arg="7">Buy</button>"#, | |
| 122 | + | ); | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | #[test] | |
| 126 | + | #[should_panic(expected = "does not render the same page")] | |
| 127 | + | fn forgiving_one_attribute_does_not_forgive_its_neighbours() { | |
| 128 | + | Parity::strict().ignoring_attr("data-action").assert( | |
| 129 | + | "t", | |
| 130 | + | r#"<button data-action="toggleCart" data-arg="7">Buy</button>"#, | |
| 131 | + | r#"<button data-action="toggleCart">Buy</button>"#, | |
| 132 | + | ); | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | #[test] | |
| 136 | + | fn a_named_class_can_be_forgiven() { | |
| 137 | + | Parity::strict().ignoring_class("raised").assert( | |
| 138 | + | "t", | |
| 139 | + | r#"<div class="card"></div>"#, | |
| 140 | + | r#"<div class="card raised"></div>"#, | |
| 141 | + | ); | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | #[test] | |
| 145 | + | fn a_class_attribute_emptied_by_the_allowlist_matches_having_none() { | |
| 146 | + | Parity::strict().ignoring_class("raised").assert( | |
| 147 | + | "t", | |
| 148 | + | "<div></div>", | |
| 149 | + | r#"<div class="raised"></div>"#, | |
| 150 | + | ); | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | // --- the failure message ---------------------------------------------------- | |
| 154 | + | ||
| 155 | + | #[test] | |
| 156 | + | fn the_failure_names_the_screen_and_shows_both_sides() { | |
| 157 | + | let err = std::panic::catch_unwind(|| { | |
| 158 | + | Parity::strict().assert("item_pricing", "<p>a</p>", "<p>b</p>"); | |
| 159 | + | }) | |
| 160 | + | .expect_err("it fails"); | |
| 161 | + | let msg = err | |
| 162 | + | .downcast_ref::<String>() | |
| 163 | + | .expect("the panic carries a message"); | |
| 164 | + | ||
| 165 | + | assert!(msg.contains("item_pricing"), "names the screen: {msg}"); | |
| 166 | + | assert!(msg.contains("Askama"), "shows the Askama side: {msg}"); | |
| 167 | + | assert!(msg.contains("quasi"), "shows the quasi side: {msg}"); | |
| 168 | + | assert!( | |
| 169 | + | msg.contains("ignoring_attr"), | |
| 170 | + | "says what to do about an intended difference: {msg}" | |
| 171 | + | ); | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // --- the per-screen switch -------------------------------------------------- | |
| 175 | + | ||
| 176 | + | #[test] | |
| 177 | + | fn no_screen_is_converted_by_default() { | |
| 178 | + | // The state every deployment is in until Max flips one. | |
| 179 | + | let screens = QuasiScreens::default(); | |
| 180 | + | assert!(!screens.enabled("item_pricing")); | |
| 181 | + | assert!(!screens.any()); | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | #[test] | |
| 185 | + | fn an_unset_variable_converts_nothing() { | |
| 186 | + | let screens = QuasiScreens::parse(""); | |
| 187 | + | assert!(!screens.enabled("item_pricing")); | |
| 188 | + | assert!(!screens.any()); | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | #[test] | |
| 192 | + | fn screens_are_named_one_at_a_time() { | |
| 193 | + | let screens = QuasiScreens::parse("user_account,item_pricing"); | |
| 194 | + | assert!(screens.enabled("user_account")); | |
| 195 | + | assert!(screens.enabled("item_pricing")); | |
| 196 | + | assert!(!screens.enabled("project_content")); | |
| 197 | + | assert!(screens.any()); | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | #[test] | |
| 201 | + | fn whitespace_and_blanks_in_the_list_are_ignored() { | |
| 202 | + | let screens = QuasiScreens::parse(" user_account , , item_pricing ,"); | |
| 203 | + | assert!(screens.enabled("user_account")); | |
| 204 | + | assert!(screens.enabled("item_pricing")); | |
| 205 | + | assert!(!screens.enabled("")); | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | #[test] | |
| 209 | + | fn a_star_converts_everything_for_local_work() { | |
| 210 | + | let screens = QuasiScreens::parse("*"); | |
| 211 | + | assert!(screens.enabled("anything_at_all")); | |
| 212 | + | assert!(screens.any()); | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | #[test] | |
| 216 | + | fn a_typo_disables_a_screen_rather_than_enabling_another() { | |
| 217 | + | // The safe direction, and the reason nothing validates against a registry. | |
| 218 | + | let screens = QuasiScreens::parse("item_pricng"); | |
| 219 | + | assert!(!screens.enabled("item_pricing")); | |
| 220 | + | } |