//! Checks that a hand-written frontend still agrees with the crate that //! generates its siblings. //! //! The generated files cannot drift: they ask makeover-geometry for the answer. //! The hand-written ones state it, and a stylesheet or a script that disagrees //! with the crate is not an error at any point -- it is a rule that quietly //! stops matching where it used to. Cheaper to read a panic naming the line. //! //! Deliberately assertions and not substitutions. A JS or CSS file that has to //! be generated to be correct stops being readable on its own, and it is worth //! something that you can still open the frontend in a browser and have it //! work. use std::path::{Path, PathBuf}; use makeover_geometry::{Density, SizeClass}; use makeover_webview::Emit; /// The declaration this check reads. Shared vocabulary, not a parameter: two /// apps and a server naming the same string want the same name for it. const CONST_NAME: &str = "TOUCH_DENSITY"; /// The capability sniffs the media query replaced, so neither can come back by /// copy-paste. /// /// Both ask the hardware what it has rather than what is pointing at the /// screen, so both say yes to a touchscreen laptop driving a mouse. const SNIFFS: &[&str] = &["ontouchstart", "maxTouchPoints"]; /// Fail the build if a JS copy of the touch-density query has drifted from /// [`Density::Touch`]. /// /// Every `.js` file under `js_dir`, recursively, must state the crate's own /// media condition in a `const TOUCH_DENSITY = '...'`, at least one file must /// declare it, and no file may name a capability sniff. /// /// The string is the crate's and no app gets a say in it, which is why this /// check takes no policy argument. The generated `geometry.css` already keys /// its touch gap overrides on the same condition, so the gestures and the /// spacing agree by construction rather than by two people remembering. /// /// Emits `cargo:rerun-if-changed` for every file it read. /// /// # Panics /// /// If `js_dir` cannot be read, if no declaration is found, or if any file /// disagrees with the crate. A build script has nowhere useful to return an /// error to, and a frontend that disagrees with its own stylesheet is worse /// than a failed build. pub fn check_touch_density(js_dir: impl AsRef) { let js_dir = js_dir.as_ref(); let want = Density::Touch.media_condition(); let mut wrong: Vec = Vec::new(); let mut found = 0usize; let files = js_files(js_dir); for path in &files { let src = std::fs::read_to_string(path).expect("read js file"); let name = path .strip_prefix(js_dir) .unwrap_or(path) .display() .to_string(); for (offset, literal) in touch_density_literals(&src) { found += 1; if literal != want { wrong.push(format!( " {name}:{} {CONST_NAME} = '{literal}'", line_of(&src, offset) )); } } for needle in SNIFFS { if let Some(offset) = src.find(needle) { wrong.push(format!( " {name}:{} {needle} -- device sniff, not a density question", line_of(&src, offset) )); } } } assert!( found > 0, "no {CONST_NAME} literal found under {}.\n\n\ A frontend that asks whether it is being touched states\n\ makeover_geometry::Density::Touch's media condition in a const of that\n\ name, and this check exists to keep every copy equal to it. If the\n\ const was renamed, rename it back rather than dropping the check; if\n\ this frontend genuinely asks no density question, drop the call.", js_dir.display() ); assert!( wrong.is_empty(), "hand-written touch detection disagrees with makeover_geometry::Density.\n\n\ Density::Touch.media_condition() is: {want}\n\n\ Wrong:\n{}\n\n\ Fix the JS to state the crate's string. Never widen it to catch a\n\ device the query misses: density is what is pointing at the screen,\n\ and a laptop with a touchscreen and a mouse is a pointer device.", wrong.join("\n") ); for path in &files { println!("cargo:rerun-if-changed={}", path.display()); } } /// Every `.js` file under `dir`, recursively, sorted. fn js_files(dir: &Path) -> Vec { files_with_extension(dir, "js") } /// Every file under `dir` with extension `ext`, recursively, sorted. /// /// Recursive because a consumer's frontend is not always one flat directory: /// the Tauri apps keep `js/*.js`, the server keeps subdirectories under /// `static/`, and a check that silently skipped the nested half would report /// clean on the files most likely to have been copied. fn files_with_extension(dir: &Path, ext: &str) -> Vec { let mut out = Vec::new(); let mut stack = vec![dir.to_path_buf()]; while let Some(d) = stack.pop() { for entry in std::fs::read_dir(&d) .unwrap_or_else(|e| panic!("read {}: {e}", d.display())) .flatten() { let path = entry.path(); if path.is_dir() { stack.push(path); } else if path.extension().is_some_and(|x| x == ext) { out.push(path); } } } out.sort(); out } /// `(byte offset of the declaration, the literal's contents)` for every /// `const TOUCH_DENSITY = '...'` in a JS source. fn touch_density_literals(src: &str) -> Vec<(usize, &str)> { let mut out = Vec::new(); let mut at = 0; while let Some(i) = src[at..].find(CONST_NAME) { let start = at + i; at = start + CONST_NAME.len(); // Only the declaration states the string; a use site reads the const. let Some(rest) = src[at..].strip_prefix(" = ") else { continue; }; let open = at + " = ".len(); let Some(quote @ ('\'' | '"')) = rest.chars().next() else { continue; }; let body = open + 1; if let Some(j) = src[body..].find(quote) { out.push((start, &src[body..body + j])); at = body + j + 1; } } out } fn line_of(src: &str, offset: usize) -> usize { src[..offset].matches('\n').count() + 1 } /// Fail the build if a hand-written breakpoint has drifted from [`SizeClass`]. /// /// Every pixel width named by a media query under `frontend/css` or /// `frontend/js`, recursively, must be a [`SizeClass`] boundary or one of /// `tuning_widths`. /// /// Without this, moving `SizeClass::Medium::min_px` regenerates the emitted /// stylesheets and silently leaves every hand-written query behind, and what /// you get is not an error but a stylesheet that disagrees with itself at the /// old boundary. /// /// `tuning_widths` is the one thing an app gets a say in, which is why this /// takes a parameter where [`check_touch_density`] does not. A shell boundary /// is a [`SizeClass`] edge and belongs to makeover-geometry; a tuning width is /// a point inside a shell where something reflows without the shell changing -- /// a dashboard dropping from three columns to two, a pane's width cap ending. /// Nothing switches shells at one, so it should not move when a size class /// does. Pass `&[]` if the app has none, and treat every addition as owing a /// note saying what it tunes: the list is where a genuine boundary goes to hide /// from this check. /// /// The generated stylesheets are scanned too, and pass by construction: they /// ask makeover-geometry for the number rather than stating it. Scanning them /// costs nothing and means a consumer never has to name which files are /// hand-written. /// /// Emits `cargo:rerun-if-changed` for every file it read. /// /// # Panics /// /// If `frontend/css` or `frontend/js` cannot be read, or if any width is /// neither a size-class boundary nor a declared tuning width. A build script /// has nowhere useful to return an error to. pub fn check_breakpoints(frontend: impl AsRef, tuning_widths: &[u16]) { let frontend = frontend.as_ref(); let mut files = files_with_extension(&frontend.join("css"), "css"); files.extend(js_files(&frontend.join("js"))); check_paths(&files, tuning_widths, Some(frontend)); } /// [`check_breakpoints`] against a named list of files rather than a tree. /// /// For a frontend whose generated and hand-written files share a directory, so /// there is nothing to point a directory scan at: the MNW server keeps both /// under `static/` alongside a bundler's output, and bundled third-party CSS /// is exactly the place a width nobody chose would come from. /// /// The cost is that the list is hand-maintained, and a stylesheet nobody adds /// to it is unchecked rather than failing. Prefer [`check_breakpoints`] where /// the layout allows it. /// /// A `.js` path is parsed as script and anything else as stylesheet, which is /// the only difference: a media condition is parenthesised in both. /// /// # Panics /// /// If a listed file cannot be read -- a listed path that no longer exists is a /// check silently covering less than it says -- or if any width is neither a /// size-class boundary nor a declared tuning width. pub fn check_breakpoints_files>(paths: &[P], tuning_widths: &[u16]) { let paths: Vec = paths.iter().map(|p| p.as_ref().to_path_buf()).collect(); check_paths(&paths, tuning_widths, None); } /// The check itself. `root`, when given, is stripped from reported paths. fn check_paths(paths: &[PathBuf], tuning_widths: &[u16], root: Option<&Path>) { let allowed = allowed_widths(tuning_widths); let mut stale: Vec = Vec::new(); for path in paths { let raw = std::fs::read_to_string(path) .unwrap_or_else(|e| panic!("read {}: {e}", path.display())); let name = match root { Some(root) => display_name(root, path), None => path.display().to_string(), }; if path.extension().is_some_and(|x| x == "js") { // No declarations in JS, so any parenthesised width is a query. for (offset, px) in js_widths(&raw) { if !allowed.contains(&px) { stale.push(format!(" {name}:{} ({px}px)", line_of(&raw, offset))); } } continue; } // Comments first: a note about a breakpoint that used to be here is // prose, not a rule, and should not fail a build. let src = strip_block_comments(&raw); for (offset, condition) in media_conditions(&src) { for px in media_widths(condition) { if !allowed.contains(&px) { stale.push(format!( " {name}:{} @media{condition} ({px}px)", line_of(&src, offset) )); } } } } assert!( stale.is_empty(), "hand-written breakpoints disagree with makeover_geometry::SizeClass.\n\n\ Allowed: {allowed:?}\n\ ({:?} come from SizeClass; {tuning_widths:?} were passed as tuning widths.)\n\n\ Stale:\n{}\n\n\ If a size class moved, update these to match. If one of these is a new\n\ tuning width inside the wide shell rather than a shell boundary, add it\n\ to the caller's tuning list with a note saying what it tunes.\n\n\ Best of all, make the rule dimensional so it needs no threshold: a grid\n\ wants repeat(auto-fit, minmax(, 1fr)) and a size wants\n\ clamp(). A threshold is for what appears and disappears.", allowed .iter() .filter(|px| !tuning_widths.contains(px)) .collect::>(), stale.join("\n") ); for path in paths { println!("cargo:rerun-if-changed={}", path.display()); } } /// A path as the frontend sees it, for an error a reader can act on. fn display_name(frontend: &Path, path: &Path) -> String { path.strip_prefix(frontend) .unwrap_or(path) .display() .to_string() } /// Every width a hand-written media query is allowed to name. /// /// Read out of [`SizeClass::media_condition`] rather than typed, which is the /// whole point: that is the one place the numbers come from, and a bump in /// makeover-geometry has to reach the stylesheet through here. fn allowed_widths(tuning_widths: &[u16]) -> Vec { let mut widths: Vec = SizeClass::all() .iter() .flat_map(|c| media_widths(&c.media_condition())) .collect(); widths.extend_from_slice(tuning_widths); widths.sort_unstable(); widths.dedup(); widths } /// The pixel values in a media condition, in the order they appear. fn media_widths(condition: &str) -> Vec { let mut out = Vec::new(); let mut rest = condition; while let Some(i) = rest.find("-width:") { rest = &rest[i + "-width:".len()..]; let digits: String = rest .trim_start() .chars() .take_while(char::is_ascii_digit) .collect(); if let Ok(px) = digits.parse() { out.push(px); } } out } /// `(byte offset of the `@media`, the condition text before the `{`)`. fn media_conditions(css: &str) -> Vec<(usize, &str)> { let mut out = Vec::new(); let mut at = 0; while let Some(i) = css[at..].find("@media") { let start = at + i; let after = start + "@media".len(); match css[after..].find('{') { Some(j) => { out.push((start, &css[after..after + j])); at = after + j; } None => break, } } out } /// `(byte offset, pixel value)` for every `(max-width: Npx)` in a JS source. /// /// The parentheses are the whole test, and they have to be: a media condition /// is always parenthesized and a CSS declaration never is, so `'max-width: /// 320px'` in an inline-style string is not a breakpoint and must not read as /// one. goingson's shared-updater.js builds exactly that, and the first version /// of this check failed the build on it. fn js_widths(src: &str) -> Vec<(usize, u16)> { let mut out = Vec::new(); for pat in ["(max-width:", "(min-width:"] { let mut at = 0; while let Some(i) = src[at..].find(pat) { let start = at + i; let rest = src[start + pat.len()..].trim_start(); let digits: String = rest.chars().take_while(char::is_ascii_digit).collect(); if let Ok(px) = digits.parse() && rest[digits.len()..].starts_with("px)") { out.push((start, px)); } at = start + pat.len(); } } out } /// Replace every `/* ... */` with spaces, so byte offsets still line up. fn strip_block_comments(css: &str) -> String { let bytes = css.as_bytes(); let mut out = String::with_capacity(css.len()); let mut i = 0; while i < bytes.len() { if bytes[i..].starts_with(b"/*") { let end = css[i..].find("*/").map_or(bytes.len(), |j| i + j + 2); for c in css[i..end].chars() { out.push(if c == '\n' { '\n' } else { ' ' }); } i = end; } else { let c = css[i..].chars().next().unwrap(); out.push(c); i += c.len_utf8(); } } out } /// Fail the build if a hand-written stylesheet takes a property the generated /// one already sets -- on the same class, or on an element that carries it. /// /// The generated sheet sits in `@layer makeover`. App CSS beats it whatever the /// specificity, either by being unlayered or by sitting in a layer the app's /// order statement puts after `makeover`, so an app declaration for a property /// makeover already sets does not merge with it: it wins, silently, and the /// design system's version of that component stops applying, and nothing looks. /// /// # Two passes, because a rule can carry no class /// /// The class pass is the original: an app `.button` against the generated /// `.button`. It reads rules by the classes in their selectors, so a rule with /// no class in it is invisible to it -- and `button { color: var(--content) }` /// is exactly that. It sets the same property the generated `.button` sets, on /// every described act in the app, and it took `.button[data-tone="danger"]`'s /// tone with it: a destructive act rendered indistinguishable from an ordinary /// one for months, with this check reporting nothing. /// /// The element pass closes it. `makeover_webview::vocabulary::ELEMENT_CLASSES` /// says which generated classes an element can carry -- CSS cannot say it, and /// the renderer can -- and a bare element rule taking a property the design /// system sets on one of those classes is the same defect as the class case. /// /// Two things are not reported, both deliberately: /// /// - A scoped rule (`.page button`). It reaches the elements inside one /// region rather than every one of them, so whether it lands on a described /// act depends on where that act renders. The certain case is the one this /// reads. /// - A property the app names on the class itself, from a rule that outranks /// the element rule. Both are the app's and both sit in the same layer, so /// that one contest is settled by specificity, and what reaches the design /// system is the class rule -- which the class pass has already, reported /// or reviewed. The rank matters: `.field` does not beat /// `input[type="text"]`, and a handoff written as the weaker of the two is /// a remedy that looks written and is not. /// /// # Why properties and not class names /// /// A shared class name is not by itself a divergence, and the first run of this /// check against goingson is what settled it: nine classes are shared and every /// one is deliberate. `.badge` sets shape in the app and colour in the /// generated sheet, and the app's own comment beside it reads "Fill, edge and /// text colour come from the generated .badge in layout.css. Do not add /// background, border or box-shadow here." That arrangement is correct, so a /// check on names would have asked for it to be deleted. On properties, the /// comment becomes the check. /// /// # The exception list /// /// `allowed` is `(class, property)` pairs this app has reviewed and kept. /// Deciding which are legitimate here would need a selector matcher, and a check /// that guesses wrong about specificity fails correct builds -- so the app /// declares it instead, the same shape as quasi-webview's `RENDERER_OWN`. /// /// A reviewed pairing is a claim about who owns a property, and it expires when /// the design system takes the property back. /// /// `allowed_elements` is the same thing one pass down: `(element, class, /// property)` triples where a bare element rule reaching a generated class has /// been read and kept. /// /// The remedy is usually neither list. A later layer can hand the property back /// with `revert-layer`, which says "whatever the design system set here, keep /// it" on the arms makeover actually paints, and that is a statement in the /// stylesheet rather than a note in a build script. Handoffs are not reported by /// either pass. /// /// A pair that stops colliding fails too. A licence nobody is using is where /// the next real collision lands and reads as company. /// /// `frontend` is the directory holding `css/`. `generated` names the sheets /// this crate writes, relative to `frontend/css`, which are skipped: the /// generated file setting a generated property is the point. /// /// Emits `cargo:rerun-if-changed` for every file it read. /// /// # Panics /// /// If `frontend/css` cannot be read, if any hand-written sheet takes a /// generated property without declaring it, or if a declared pair no longer /// collides. A build script has nowhere useful to return an error to, and an /// app quietly overriding its own design system is worse than a failed build. pub fn check_vocabulary( frontend: impl AsRef, opts: &Emit, generated: &[&str], allowed: &[(&str, &str)], allowed_elements: &[(&str, &str, &str)], ) { let frontend = frontend.as_ref(); let css = frontend.join("css"); let files: Vec = files_with_extension(&css, "css") .into_iter() .filter(|p| { let name = p.strip_prefix(&css).unwrap_or(p).display().to_string(); !generated.contains(&name.as_str()) }) .collect(); check_vocabulary_paths(&files, opts, Some(frontend), allowed, allowed_elements); } /// [`check_vocabulary`] against a named list of files rather than a tree. /// /// For a frontend whose generated and hand-written sheets share a directory, so /// a directory scan has nothing to point at. Same trade as /// [`check_breakpoints_files`]: the list is hand-maintained, and a stylesheet /// nobody adds to it is unchecked rather than failing. /// /// # Panics /// /// As [`check_vocabulary`]. pub fn check_vocabulary_files>( paths: &[P], opts: &Emit, allowed: &[(&str, &str)], allowed_elements: &[(&str, &str, &str)], ) { let paths: Vec = paths.iter().map(|p| p.as_ref().to_path_buf()).collect(); check_vocabulary_paths(&paths, opts, None, allowed, allowed_elements); } /// The check itself. `root`, when given, is stripped from reported paths. fn check_vocabulary_paths( paths: &[PathBuf], opts: &Emit, root: Option<&Path>, allowed: &[(&str, &str)], allowed_elements: &[(&str, &str, &str)], ) { let generated = makeover_webview::vocabulary::declarations_by_class(&makeover_webview::stylesheet(opts)); let mut clashes: Vec = Vec::new(); let mut seen: Vec<(String, String)> = Vec::new(); let mut element_clashes: Vec = Vec::new(); let mut element_seen: Vec<(String, String, String)> = Vec::new(); for path in paths { println!("cargo::rerun-if-changed={}", path.display()); let raw = std::fs::read_to_string(path) .unwrap_or_else(|e| panic!("read {}: {e}", path.display())); let name = match root { Some(root) => display_name(root, path), None => path.display().to_string(), }; // Read the app's sheet the same way the crate reads its own, or the two // sides are not comparable. let local = makeover_webview::vocabulary::declarations_by_class(&raw); for (class, properties) in &local { let Some(theirs) = generated.get(class) else { continue; }; for property in properties.intersection(theirs) { seen.push((class.clone(), property.clone())); if allowed.contains(&(class.as_str(), property.as_str())) { continue; } clashes.push(format!(" {name} .{class} {{ {property} }}")); } } // The second pass: a rule carrying no class at all, which the first one // cannot see. What the app says about the class itself settles the // pair, but only from a rule that outranks the element rule -- both are // the app's and both are in the same layer, so this one contest is // decided by specificity. `.field` does not beat `input[type="text"]`. let mentioned = makeover_webview::vocabulary::mentions_by_class(&raw); let by_element = makeover_webview::vocabulary::declarations_by_element(&raw); for (element, properties) in &by_element { for class in makeover_webview::vocabulary::classes_for_element(element, opts) { let Some(theirs) = generated.get(&class) else { continue; }; for (property, rank) in properties { if !theirs.contains(property) { continue; } // A tie goes to the class rule: at equal specificity the // later rule wins, and a remedy is written after the rule // it remedies. let spoken_for = mentioned .get(&class) .and_then(|properties| properties.get(property)) .is_some_and(|theirs| theirs >= rank); if spoken_for { continue; } element_seen.push((element.clone(), class.clone(), property.clone())); if allowed_elements.contains(&( element.as_str(), class.as_str(), property.as_str(), )) { continue; } element_clashes.push(format!( " {name} {element} {{ {property} }} beats .{class} {{ {property} }}" )); } } } } assert!( clashes.is_empty(), "{} hand-written declaration(s) take a property the generated stylesheet \ already sets on the same class. App CSS wins over @layer makeover, \ whether by a later layer or by being unlayered, so each of these wins \ over the design system silently:\n{}\n\nDelete the declaration, or, if \ it is a deliberate pairing on a different selector arm, add \ (class, property) to this check's allowed list and say why beside it. \ Count the consumers before deciding a divergence is worth keeping.", clashes.len(), clashes.join("\n") ); assert!( element_clashes.is_empty(), "{} hand-written element rule(s) take a property the generated \ stylesheet sets on a class that element carries. App CSS wins over \ @layer makeover, whether by a later layer or by being unlayered, so a \ described component rendered on one of these elements loses the \ design system's version of that property silently -- which is how a \ destructive act came to look like an ordinary one:\n{}\n\nHand the \ property back on the arms makeover paints \ (`.{{class}}:disabled {{ color: revert-layer }}`), scope the element \ rule so it stops reaching described markup, or add \ (element, class, property) to this check's allowed-elements list and \ say why beside it.", element_clashes.len(), element_clashes.join("\n") ); let stale: Vec<&(&str, &str)> = allowed .iter() .filter(|(class, property)| { !seen.contains(&((*class).to_string(), (*property).to_string())) }) .collect(); assert!( stale.is_empty(), "the allowed list declares {stale:?}, which no longer collides with \ anything. Delete the entries: an exception nobody is using is where the \ next real collision lands and reads as company." ); let stale: Vec<&(&str, &str, &str)> = allowed_elements .iter() .filter(|(element, class, property)| { !element_seen.contains(&( (*element).to_string(), (*class).to_string(), (*property).to_string(), )) }) .collect(); assert!( stale.is_empty(), "the allowed-elements list declares {stale:?}, which no longer collides \ with anything. Delete the entries: an exception nobody is using is \ where the next real collision lands and reads as company." ); } /// Warn when the generated vocabulary has grown dead, and fail when it grows /// deader than the recorded high-water mark. /// /// A generated class no markup emits is a rule shipped to every user for /// nothing, and the proportion was large when it was first measured: 42% of the /// vocabulary unused in goingson, 67% in the MNW server, 84% in Balanced /// Breakfast. Those are not failures on their own or no app would build. What /// this converts is the direction: dead vocabulary becoming a number in a build /// script means a change that worsens it stops being something somebody /// notices. /// /// One-sided, the same shape as the MNW server's `frontend_globals` seal: /// exceeding `high_water` fails, coming in under it warns and asks for the seal /// to be lowered. A build that fails because dead CSS was deleted would teach /// the wrong lesson. /// /// `markup` is every file that can carry a class: templates, `.js`, `.html`, /// and any Rust that writes markup. A class is counted as used if its name /// appears in any of them, which is deliberately generous. A stricter reading /// would need to know how each app builds its class strings, and a check that /// guesses wrong fails a correct build. /// /// # Panics /// /// If a listed file cannot be read, or if more classes are unused than /// `high_water`. pub fn check_vocabulary_use>(markup: &[P], opts: &Emit, high_water: usize) { let generated = makeover_webview::vocabulary::names(opts); let mut haystack = String::new(); for path in markup { let path = path.as_ref(); println!("cargo::rerun-if-changed={}", path.display()); haystack.push_str( &std::fs::read_to_string(path) .unwrap_or_else(|e| panic!("read {}: {e}", path.display())), ); haystack.push('\n'); } let unused: Vec<&String> = generated .iter() .filter(|class| !haystack.contains(class.as_str())) .collect(); assert!( unused.len() <= high_water, "{} of {} generated classes are emitted by no markup, above the recorded {}. \ The vocabulary grew or the markup stopped using it:\n{}", unused.len(), generated.len(), high_water, unused .iter() .map(|c| format!(" .{c}")) .collect::>() .join("\n") ); if unused.len() < high_water { println!( "cargo::warning=dead makeover vocabulary is down to {} from a sealed {}; \ lower the seal so it cannot grow back", unused.len(), high_water ); } } #[cfg(test)] mod tests { use super::*; fn scratch(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("makeover-drift-{}-{name}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("create scratch"); dir } fn write(dir: &Path, name: &str, src: &str) { if let Some(parent) = dir.join(name).parent() { std::fs::create_dir_all(parent).unwrap(); } std::fs::write(dir.join(name), src).unwrap(); } fn declaring() -> String { format!( "const {CONST_NAME} = '{}';\n", Density::Touch.media_condition() ) } #[test] fn the_crates_own_string_passes() { let dir = scratch("ok"); write(&dir, "touch.js", &declaring()); check_touch_density(&dir); } #[test] #[should_panic(expected = "disagrees with makeover_geometry::Density")] fn a_drifted_literal_fails() { let dir = scratch("drift"); write(&dir, "touch.js", &declaring()); write( &dir, "haptics.js", &format!("const {CONST_NAME} = '(pointer: coarse)';\n"), ); check_touch_density(&dir); } #[test] #[should_panic(expected = "device sniff")] fn the_sniff_cannot_come_back() { let dir = scratch("sniff"); write(&dir, "touch.js", &declaring()); write(&dir, "legacy.js", "if ('ontouchstart' in window) {}\n"); check_touch_density(&dir); } #[test] #[should_panic(expected = "no TOUCH_DENSITY literal found")] fn a_frontend_that_states_nothing_fails() { let dir = scratch("empty"); write(&dir, "app.js", "export const x = 1;\n"); check_touch_density(&dir); } #[test] fn a_use_site_is_not_a_declaration() { // The const is read far more often than it is declared, and a read // states no string. Counting one as a declaration would make the // `found > 0` assertion pass on a frontend that only imports it. let src = format!("import {{ {CONST_NAME} }} from './touch.js';\nmatchMedia({CONST_NAME});\n"); assert!(touch_density_literals(&src).is_empty()); } #[test] fn nested_files_are_read() { // The server keeps its scripts in subdirectories, and the nested half // is the half most likely to be a copy. let dir = scratch("nested"); write(&dir, "touch.js", &declaring()); write(&dir, "screens/legacy.js", "navigator.maxTouchPoints > 0;\n"); let files = js_files(&dir); assert_eq!(files.len(), 2); } #[test] fn a_non_js_file_is_ignored() { let dir = scratch("nonjs"); write(&dir, "touch.js", &declaring()); write(&dir, "styles.css", "body { }\n"); assert_eq!(js_files(&dir).len(), 1); } fn frontend(name: &str) -> PathBuf { let dir = scratch(name); std::fs::create_dir_all(dir.join("css")).unwrap(); std::fs::create_dir_all(dir.join("js")).unwrap(); dir } /// A width every size class agrees is a boundary. fn boundary() -> u16 { SizeClass::Medium.min_px() } #[test] fn the_crates_own_boundaries_pass() { let dir = frontend("bp-ok"); write( &dir, "css/styles.css", &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()), ); check_breakpoints(&dir, &[]); } #[test] #[should_panic(expected = "disagree with makeover_geometry::SizeClass")] fn a_stale_css_width_fails() { let dir = frontend("bp-css"); write(&dir, "css/styles.css", "@media (max-width: 768px) { }\n"); check_breakpoints(&dir, &[]); } #[test] #[should_panic(expected = "disagree with makeover_geometry::SizeClass")] fn a_stale_js_width_fails() { let dir = frontend("bp-js"); write(&dir, "js/shell.js", "matchMedia('(max-width: 768px)');\n"); check_breakpoints(&dir, &[]); } #[test] fn a_declared_tuning_width_passes() { let dir = frontend("bp-tuning"); write(&dir, "css/styles.css", "@media (min-width: 1400px) { }\n"); check_breakpoints(&dir, &[1400]); } #[test] fn a_width_in_a_comment_is_prose() { // The note explaining which breakpoint used to be here is not a rule, // and failing a build on documentation would teach people to delete it. let dir = frontend("bp-comment"); write( &dir, "css/styles.css", "/* was @media (max-width: 768px) until the size classes landed */\n", ); check_breakpoints(&dir, &[]); } #[test] fn an_unparenthesized_width_is_not_a_breakpoint() { // A JS string building an inline style states `max-width: 320px` with // no parentheses. It is a declaration, not a query, and the first // version of this check failed the build on one. let dir = frontend("bp-inline"); write( &dir, "js/style.js", "el.style.cssText = 'max-width: 320px; display: block';\n", ); check_breakpoints(&dir, &[]); } #[test] fn nested_css_is_read() { // Same argument as the touch check: the nested half is the half most // likely to be a copy. let dir = frontend("bp-nested"); write( &dir, "css/screens/detail.css", "@media (max-width: 768px) { }\n", ); let found = std::panic::catch_unwind(|| check_breakpoints(&dir, &[])); assert!(found.is_err(), "a nested stylesheet must be scanned"); } #[test] fn a_named_list_is_checked() { let dir = frontend("bp-list"); write(&dir, "css/style.css", "@media (max-width: 768px) { }\n"); let listed = dir.join("css/style.css"); let err = std::panic::catch_unwind(|| check_breakpoints_files(&[&listed], &[])).unwrap_err(); let msg = err.downcast_ref::().expect("String payload"); assert!(msg.contains("style.css:1"), "got: {msg}"); } #[test] #[should_panic(expected = "read ")] fn a_listed_file_that_is_gone_fails() { // The list is hand-maintained, so a path that stopped existing is a // check quietly covering less than it claims. Louder than skipping it. let dir = frontend("bp-missing"); check_breakpoints_files(&[dir.join("css/never-written.css")], &[]); } #[test] fn a_listed_js_file_is_parsed_as_script() { // The unparenthesized-declaration rule is what separates the two, and // picking the parser off the extension is the whole difference. let dir = frontend("bp-list-js"); write( &dir, "js/style.js", "el.style.cssText = 'max-width: 320px';\n", ); check_breakpoints_files(&[dir.join("js/style.js")], &[]); } #[test] fn the_error_names_the_file_and_line() { let dir = frontend("bp-message"); write( &dir, "css/styles.css", "body { }\n@media (max-width: 768px) { }\n", ); let err = std::panic::catch_unwind(|| check_breakpoints(&dir, &[])).unwrap_err(); let msg = err .downcast_ref::() .expect("panic payload is a String"); assert!(msg.contains("css/styles.css:2"), "got: {msg}"); } #[test] fn a_rule_restating_a_generated_class_fails_and_names_it() { let dir = scratch("vocab-clash"); // `.card` is makeover's. An app rule for it beats the generated one, // because app CSS is unlayered and the generated sheet is not. write( &dir, "css/styles.css", "body { color: red; }\n.card { box-shadow: none; }\n", ); let err = std::panic::catch_unwind(|| check_vocabulary(&dir, &Emit::default(), &[], &[], &[])) .unwrap_err(); let msg = err .downcast_ref::() .expect("panic payload is a String"); assert!(msg.contains(".card"), "got: {msg}"); assert!(msg.contains("box-shadow"), "got: {msg}"); assert!(msg.contains("css/styles.css"), "got: {msg}"); } #[test] fn an_app_class_of_its_own_is_left_alone() { let dir = scratch("vocab-clean"); write( &dir, "css/styles.css", ".task-list-container { overflow: auto; }\n.day-plan-slot { height: 1rem; }\n", ); check_vocabulary(&dir, &Emit::default(), &[], &[], &[]); } #[test] fn the_generated_sheet_is_skipped_rather_than_reported_against_itself() { let dir = scratch("vocab-generated"); let opts = Emit::default(); write(&dir, "css/layout.css", &makeover_webview::stylesheet(&opts)); // Without the skip this is the loudest failure possible: every class in // the vocabulary, reported as a clash with the vocabulary. check_vocabulary(&dir, &opts, &["layout.css"], &[], &[]); } #[test] fn a_prefixed_app_is_checked_against_its_own_prefix() { let dir = scratch("vocab-prefix"); let opts = Emit { class_prefix: "mo-", ..Emit::default() }; // Bare `.card` is the app's own class once the generated sheet writes // `.mo-card`, so this has to pass. write(&dir, "css/styles.css", ".card { box-shadow: none; }\n"); check_vocabulary(&dir, &opts, &[], &[], &[]); let dir = scratch("vocab-prefix-clash"); write(&dir, "css/styles.css", ".mo-card { box-shadow: none; }\n"); assert!(std::panic::catch_unwind(|| check_vocabulary(&dir, &opts, &[], &[], &[])).is_err()); } #[test] fn a_class_shared_without_a_shared_property_is_left_alone() { let dir = scratch("vocab-additive"); // What goingson actually does: the generated `.badge` sets the text // colour and the app sets the shape. Same class, no argument. write( &dir, "css/styles.css", ".badge { padding: 2px; border-radius: 3px; font-weight: 600; }\n", ); check_vocabulary(&dir, &Emit::default(), &[], &[], &[]); } #[test] fn a_reviewed_pair_passes_and_stops_passing_when_it_stops_colliding() { let dir = scratch("vocab-allowed"); write(&dir, "css/styles.css", ".card { box-shadow: none; }\n"); check_vocabulary(&dir, &Emit::default(), &[], &[("card", "box-shadow")], &[]); // The same licence against a sheet that no longer collides has to fail, // or the list only ever grows. let dir = scratch("vocab-allowed-stale"); write(&dir, "css/styles.css", ".card { padding: 2px; }\n"); let err = std::panic::catch_unwind(|| { check_vocabulary(&dir, &Emit::default(), &[], &[("card", "box-shadow")], &[]); }) .unwrap_err(); let msg = err .downcast_ref::() .expect("panic payload is a String"); assert!(msg.contains("no longer collides"), "got: {msg}"); } #[test] fn an_element_rule_clobbering_a_generated_class_fails_and_names_all_three() { let dir = scratch("vocab-element"); // The defect that shipped for months: no class in the selector, so the // class pass sees nothing, and every described act in the app takes the // app's bevel instead of the design system's. write(&dir, "css/styles.css", "select { box-shadow: none; }\n"); let err = std::panic::catch_unwind(|| check_vocabulary(&dir, &Emit::default(), &[], &[], &[])) .unwrap_err(); let msg = err .downcast_ref::() .expect("panic payload is a String"); assert!(msg.contains("select {"), "got: {msg}"); assert!(msg.contains(".field"), "got: {msg}"); assert!(msg.contains("box-shadow"), "got: {msg}"); assert!(msg.contains("css/styles.css"), "got: {msg}"); } #[test] fn a_handoff_on_the_class_is_the_remedy_and_reads_as_one() { let dir = scratch("vocab-element-handoff"); // What a consumer writes instead of an exception: the element rule // stays, and a later layer gives the property back on the class. The // check has to read that as settled or the remedy fails the build it // was written to fix. write( &dir, "css/styles.css", "select { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n", ); check_vocabulary(&dir, &Emit::default(), &[], &[], &[]); } #[test] fn a_property_the_app_states_on_the_class_is_not_the_element_rules_doing() { let dir = scratch("vocab-element-spoken-for"); // Within the app's own sheet the class rule outranks the bare element // rule, so what reaches the design system is `.button`, not `button`. // The class pass has that pair -- here as a reviewed one -- and // reporting it twice would ask for two remedies for one collision. write( &dir, "css/styles.css", "select { box-shadow: none; }\n.field { box-shadow: none; }\n", ); check_vocabulary(&dir, &Emit::default(), &[], &[("field", "box-shadow")], &[]); } #[test] fn a_handoff_that_loses_to_the_rule_it_remedies_is_not_a_remedy() { let dir = scratch("vocab-element-weak-handoff"); // The shape that reads as fixed and is not: both rules are the app's // and both are in the same layer, so the state on the element rule // decides, and the described field keeps the app's sunken fill. write( &dir, "css/styles.css", "select:focus { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n", ); let err = std::panic::catch_unwind(|| check_vocabulary(&dir, &Emit::default(), &[], &[], &[])) .unwrap_err(); let msg = err .downcast_ref::() .expect("panic payload is a String"); assert!(msg.contains(".field"), "got: {msg}"); // Written to win, it is. let dir = scratch("vocab-element-strong-handoff"); write( &dir, "css/styles.css", "select:focus { box-shadow: none; }\nselect.field { box-shadow: revert-layer; }\n", ); check_vocabulary(&dir, &Emit::default(), &[], &[], &[]); } #[test] fn a_scoped_rule_is_not_read_as_an_element_rule() { let dir = scratch("vocab-element-scoped"); // It reaches the buttons inside one region rather than every button, so // whether it lands on a described act depends on where that act // renders. Failing the build on a guess is the worse error. write( &dir, "css/styles.css", ".wizard select { box-shadow: none; }\n", ); check_vocabulary(&dir, &Emit::default(), &[], &[], &[]); } #[test] fn an_element_the_design_system_never_renders_onto_is_left_alone() { let dir = scratch("vocab-element-unpaired"); // `.card` is a container: no generated class sits on a `