Skip to main content

max / makenotwork

custom-pages: tests for the css sanitizer's caps, guards and thresholds The module's first mutation run left 31 of 108 mutants alive (infra c1b3900b). Twenty-five of them were coverage gaps, all of the same shape: the suite asserted the refusing side of every guard and never the allowing side, so no test could tell `>` from `>=`. What the new tests pin, each one boundary-first: - the rule and selector caps at exactly their limits, and past them by one, on sheets built so that only one cap can fire - the flattening projection descending through @media, @supports and @layer, and walking past a cheap first rule - nesting arriving in the output rather than merely not being refused, and error recovery keeping a sheet a stray brace would otherwise discard whole - sanitize_item_css, which no test entered at all - reaching a system slot through :-webkit-any() and :host() - the visible side of every hiding heuristic: opacity 0.1, a transform that is not scale(0), a clip-path that clips nothing, a positive text-indent - the strobe guard at exactly 2s, and parse_seconds on milliseconds Six survivors are equivalent mutants and are recorded with their reasons in .cargo/mutants.toml. One of those, the inert ParserFlags::NESTING field, cannot be excluded at all: cargo-mutants 27.1.0's exclude_re does not filter struct-field mutants. The reason now sits at the field. 103 mutants, 95 caught, 7 unviable, 1 missed.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-27 01:51 UTC
Signed with PGP, not checked
Commit: 7036584c948907671b45ea4694dbd6979474a047
Parent: 24716c5
3 files changed, +340 insertions, -37 deletions
@@ -1200,16 +1200,44 @@
1200 1200 version = "0.7.0"
1201 1201
1202 1202 [[patch.unused]]
1203 - name = "quasi-type"
1203 + name = "quasi-axum"
1204 + version = "0.64.0"
1205 +
1206 + [[patch.unused]]
1207 + name = "quasi-basics"
1208 + version = "0.64.0"
1209 +
1210 + [[patch.unused]]
1211 + name = "quasi-http"
1212 + version = "0.64.0"
1213 +
1214 + [[patch.unused]]
1215 + name = "quasi-immediate"
1216 + version = "0.64.0"
1217 +
1218 + [[patch.unused]]
1219 + name = "quasi-notifs"
1220 + version = "0.64.0"
1221 +
1222 + [[patch.unused]]
1223 + name = "quasi-router"
1224 + version = "0.64.0"
1225 +
1226 + [[patch.unused]]
1227 + name = "quasi-store"
1204 1228 version = "0.1.0"
1205 1229
1206 1230 [[patch.unused]]
1207 - name = "synckit-client"
1208 - version = "0.9.1"
1231 + name = "quasi-tauri"
1232 + version = "0.64.0"
1209 1233
1210 1234 [[patch.unused]]
1211 - name = "synckit-config"
1212 - version = "0.2.0"
1235 + name = "quasi-webview"
1236 + version = "0.64.0"
1237 +
1238 + [[patch.unused]]
1239 + name = "quasi-type"
1240 + version = "0.1.0"
1213 1241
1214 1242 [[patch.unused]]
1215 1243 name = "kberg"
@@ -1228,37 +1256,9 @@
1228 1256 version = "0.4.1"
1229 1257
1230 1258 [[patch.unused]]
1231 - name = "quasi-axum"
1232 - version = "0.59.0"
1259 + name = "synckit-client"
1260 + version = "0.9.1"
1233 1261
1234 1262 [[patch.unused]]
1235 - name = "quasi-basics"
1236 - version = "0.59.0"
1237 -
1238 - [[patch.unused]]
1239 - name = "quasi-http"
1240 - version = "0.59.0"
1241 -
1242 - [[patch.unused]]
1243 - name = "quasi-immediate"
1244 - version = "0.59.0"
1245 -
1246 - [[patch.unused]]
1247 - name = "quasi-notifs"
1248 - version = "0.59.0"
1249 -
1250 - [[patch.unused]]
1251 - name = "quasi-router"
1252 - version = "0.59.0"
1253 -
1254 - [[patch.unused]]
1255 - name = "quasi-store"
1256 - version = "0.1.0"
1257 -
1258 - [[patch.unused]]
1259 - name = "quasi-tauri"
1260 - version = "0.59.0"
1261 -
1262 - [[patch.unused]]
1263 - name = "quasi-webview"
1264 - version = "0.59.0"
1263 + name = "synckit-config"
1264 + version = "0.2.0"
@@ -340,6 +340,14 @@
340 340 pub(super) fn parser_options<'o, 'i>() -> ParserOptions<'o, 'i> {
341 341 ParserOptions {
342 342 // Nesting is standard CSS; let creators use it and let us wrap with it.
343 + //
344 + // Inert at the pinned lightningcss version and kept anyway: measured
345 + // 2026-08-26, `ParserFlags::NESTING` is defined in 1.0.0-alpha.71's
346 + // parser.rs and read nowhere in the crate, so nesting parses with or
347 + // without it. The pin is deliberate (see Cargo.toml) and a later alpha
348 + // may start reading the flag again, so stating the intent costs
349 + // nothing. It does mean no test can observe this line, and mutation
350 + // reports it as a permanent survivor.
343 351 flags: ParserFlags::NESTING,
344 352 // One malformed rule shouldn't discard the whole sheet.
345 353 error_recovery: true,
@@ -1127,6 +1135,251 @@
1127 1135 "@view-transition",
1128 1136 );
1129 1137 }
1138 +
1139 + // ---- The caps, at their exact boundaries -------------------------------
1140 + //
1141 + // Filed from the first mutation run of this module (infra `c1b3900b`,
1142 + // 2026-08-26): 31 of 108 mutants survived, and six of them lived in the two
1143 + // cap comparisons. A cap tested only far past its limit does not pin the
1144 + // comparison, `>` and `>=` agree on 5001 rules and disagree on 5000, so the
1145 + // boundary is where the test has to stand.
1146 +
1147 + /// `n` single-selector rules: rule-heavy, selector-light.
1148 + fn n_rules(n: usize) -> String {
1149 + use std::fmt::Write;
1150 + let mut css = String::new();
1151 + for i in 0..n {
1152 + let _ = write!(css, ".c{i}{{color:red}}");
1153 + }
1154 + css
1155 + }
1156 +
1157 + /// One rule carrying `n` selectors: selector-heavy, rule-light. Its
1158 + /// flattening projection is `n` as well, since the projection takes the
1159 + /// widest rule rather than the sum.
1160 + fn one_rule_of(n: usize) -> String {
1161 + let selectors = (0..n)
1162 + .map(|i| format!(".s{i}"))
1163 + .collect::<Vec<_>>()
1164 + .join(",");
1165 + format!("{selectors}{{color:red}}")
1166 + }
1167 +
1168 + fn refused_for_complexity(css: &str) -> bool {
1169 + let (out, rejections) = san(css);
1170 + out.is_empty()
1171 + && rejections
1172 + .iter()
1173 + .any(|r| r.kind == RejectionKind::ComplexityLimit)
1174 + }
1175 +
1176 + #[test]
1177 + fn exactly_max_rules_is_accepted() {
1178 + let (out, rejections) = san(&n_rules(MAX_RULES));
1179 + assert!(
1180 + !rejections
1181 + .iter()
1182 + .any(|r| r.kind == RejectionKind::ComplexityLimit),
1183 + "the limit is inclusive: {MAX_RULES} rules are allowed"
1184 + );
1185 + assert!(!out.is_empty());
1186 + }
1187 +
1188 + #[test]
1189 + fn one_rule_past_the_cap_is_refused() {
1190 + // Rule-heavy and nothing else: this sheet's selector count and its
1191 + // flattening projection both stay far inside their limits, so only the
1192 + // rule half of the comparison can refuse it.
1193 + assert!(refused_for_complexity(&n_rules(MAX_RULES + 1)));
1194 + }
1195 +
1196 + #[test]
1197 + fn exactly_max_selectors_is_accepted() {
1198 + let (out, rejections) = san(&one_rule_of(MAX_SELECTORS));
1199 + assert!(
1200 + !rejections
1201 + .iter()
1202 + .any(|r| r.kind == RejectionKind::ComplexityLimit),
1203 + "the limit is inclusive: {MAX_SELECTORS} selectors are allowed, and \
1204 + the flattening projection of one such rule is exactly the limit too"
1205 + );
1206 + assert!(!out.is_empty());
1207 + }
1208 +
1209 + #[test]
1210 + fn the_selector_cap_is_reached_by_breadth_too() {
1211 + // 200 rules of 51 selectors: 10,200 selectors, which is past the cap,
1212 + // while rule_count (200) and the projection (51, the widest rule) are
1213 + // both nowhere near theirs. This is the shape that proves
1214 + // `selector_count` accumulates at all, since a counter that never
1215 + // leaves zero is invisible to every other check.
1216 + use std::fmt::Write;
1217 + let mut css = String::new();
1218 + for rule in 0..200 {
1219 + let selectors = (0..51)
1220 + .map(|s| format!(".r{rule}s{s}"))
1221 + .collect::<Vec<_>>()
1222 + .join(",");
1223 + let _ = write!(css, "{selectors}{{color:red}}");
1224 + }
1225 + assert!(refused_for_complexity(&css));
1226 + }
1227 +
1228 + // ---- The flattening projection ----------------------------------------
1229 +
1230 + /// The nested-`&` bomb from infra `bd562c12`, ~30x per level.
1231 + fn amplifying_rule() -> String {
1232 + let amp = "&".repeat(30);
1233 + format!("{amp} {{ {amp} {{ {amp} {{ color:red }} }} }}")
1234 + }
1235 +
1236 + #[test]
1237 + fn nested_amplification_is_refused_inside_at_rules_too() {
1238 + // The projection has to descend through the grouping at-rules it allows,
1239 + // or the bomb is one `@media print` away from being invisible again.
1240 + let inner = amplifying_rule();
1241 + for css in [
1242 + format!("@media print {{ {inner} }}"),
1243 + format!("@supports (display: grid) {{ {inner} }}"),
1244 + format!("@layer base {{ {inner} }}"),
1245 + ] {
1246 + assert!(
1247 + refused_for_complexity(&css),
1248 + "amplification survived its wrapper: {css:.60}"
1249 + );
1250 + }
1251 + }
1252 +
1253 + #[test]
1254 + fn the_projection_walks_past_the_first_rule() {
1255 + // The early return at the foot of the walk is an optimisation, and an
1256 + // optimisation that fires too early is a hole: a cheap rule first, the
1257 + // bomb second.
1258 + let css = format!(".a {{ color: red }} {}", amplifying_rule());
1259 + assert!(refused_for_complexity(&css));
1260 + }
1261 +
1262 + // ---- Parser options ----------------------------------------------------
1263 +
1264 + #[test]
1265 + fn nesting_survives_into_the_output() {
1266 + // `ordinary_nesting_is_not_refused` passes even with nesting disabled,
1267 + // because the outer declarations still print. Assert the nested rule
1268 + // itself arrives.
1269 + let out = scoped(".card { color: red; &:hover { color: blue } }");
1270 + assert!(
1271 + out.contains(":hover"),
1272 + "the nested rule was dropped rather than parsed: {out}"
1273 + );
1274 + }
1275 +
1276 + #[test]
1277 + fn one_bad_rule_does_not_discard_the_sheet() {
1278 + // A stray `}` is a hard parse error without error recovery, and this
1279 + // crate's answer to a fatal parse failure is to render nothing at all.
1280 + // Recovery is what keeps one typo from blanking a creator's page. It
1281 + // does not save everything: recovery still discards from the stray
1282 + // brace onward, so `h1` is gone either way and `p` is the difference.
1283 + let (out, rejections) = san("p { color: red } } h1 { color: blue }");
1284 + assert!(
1285 + !rejections
1286 + .iter()
1287 + .any(|r| r.kind == RejectionKind::MalformedCss),
1288 + "one stray brace discarded the whole sheet: {rejections:?}"
1289 + );
1290 + assert!(
1291 + out.to_lowercase().contains("red"),
1292 + "the whole sheet was discarded: {out}"
1293 + );
1294 + }
1295 +
1296 + // ---- The item-page entry point -----------------------------------------
1297 +
1298 + #[test]
1299 + fn item_css_is_scoped_to_the_item_canvas() {
1300 + // The only test that enters through `sanitize_item_css`. Without it the
1301 + // whole function is unobserved: item pages have no HTML of their own,
1302 + // so a wrong scope here styles nothing and nobody sees an error.
1303 + let (out, rejections) = sanitize_item_css("p { color: red }", SCOPE, &policy());
1304 + assert!(rejections.is_empty());
1305 + assert!(
1306 + out.contains(&format!(".item-canvas#ic-{SCOPE}")),
1307 + "item CSS was not scoped to the item canvas: {out:.200}"
1308 + );
1309 + }
1310 +
1311 + // ---- Reaching a system slot through the selector forms -----------------
1312 +
1313 + #[test]
1314 + fn hiding_a_system_slot_through_any_and_host_is_stripped() {
1315 + // `:is`/`:where`/`:not`/`:has` have their own arm and their own test.
1316 + // These two do not, and a selector form the walk does not recurse into
1317 + // is a way to hide a buy button.
1318 + for selector in [":-webkit-any(.mnw-buy)", ":host(.mnw-buy)"] {
1319 + let (_out, rejections) = san(&format!("{selector} {{ display: none }}"));
1320 + assert!(
1321 + rejections
1322 + .iter()
1323 + .any(|r| r.kind == RejectionKind::HidingProperty),
1324 + "{selector} reached a system slot unchecked"
1325 + );
1326 + }
1327 + }
1328 +
1329 + // ---- The hiding heuristics, on their visible side ----------------------
1330 +
1331 + #[test]
1332 + fn the_hiding_thresholds_keep_what_is_still_visible() {
1333 + // Every one of these is one comparison away from being a hide, and the
1334 + // suite only ever asserted the hiding side. A guard that also eats
1335 + // ordinary declarations is a bug creators would hit and we would not.
1336 + for decl in [
1337 + // The threshold is `< 0.1`, so a tenth is still visible.
1338 + "opacity: 0.1",
1339 + // A transform is not a hide unless it scales to nothing.
1340 + "transform: translateX(10px)",
1341 + // Nor is a clip-path unless it clips everything away.
1342 + "clip-path: inset(0)",
1343 + // The text-indent trick is large and NEGATIVE.
1344 + "text-indent: 5px",
1345 + ] {
1346 + let (out, rejections) = san(&format!(".mnw-buy {{ {decl} }}"));
1347 + assert!(
1348 + !rejections
1349 + .iter()
1350 + .any(|r| r.kind == RejectionKind::HidingProperty),
1351 + "{decl} is visible and was stripped anyway"
1352 + );
1353 + assert!(!out.is_empty(), "{decl} produced nothing");
1354 + }
1355 + }
1356 +
1357 + // ---- The strobe guard, at its boundary ---------------------------------
1358 +
1359 + #[test]
1360 + fn an_infinite_animation_at_exactly_two_seconds_is_kept() {
1361 + // The budget is "faster than 2s", so 2s itself is allowed.
1362 + let (out, rejections) = san(".spin { animation: spin 2s infinite }");
1363 + assert!(
1364 + !rejections
1365 + .iter()
1366 + .any(|r| r.kind == RejectionKind::AnimationBudget),
1367 + "2s is the allowed side of the boundary"
1368 + );
1369 + assert!(out.to_lowercase().contains("animation"));
1370 + }
1371 +
1372 + #[test]
1373 + fn milliseconds_are_read_as_milliseconds() {
1374 + // A unit test rather than a sheet, because lightningcss prints
1375 + // `3000ms` back as `3s` and the ms branch is only reliably reached from
1376 + // here. Getting the conversion wrong in either direction lets a 500ms
1377 + // strobe through or eats a three-second animation.
1378 + assert_eq!(parse_seconds("500ms"), Some(0.5));
1379 + assert_eq!(parse_seconds("3000ms"), Some(3.0));
1380 + assert_eq!(parse_seconds("2s"), Some(2.0));
1381 + assert_eq!(parse_seconds("infinite"), None);
1382 + }
1130 1383 }
1131 1384
1132 1385 #[cfg(test)]
@@ -1,0 +1,50 @@
1 + # Mutants that cannot be killed by any test, with the reason each one is
2 + # equivalent to the code it replaces. Recorded from the first two mutation runs
3 + # of this crate (infra `c1b3900b`, 2026-08-26: 31 survivors, then 6 after the
4 + # boundary tests landed). Excluding them is what keeps a later run's "0 missed"
5 + # meaningful; a run that always reports six survivors teaches nobody to read it.
6 + #
7 + # Add to this list only with the reason written out, and only after trying to
8 + # kill the mutant with a test. A survivor is a coverage gap until proven
9 + # otherwise, and five of the original six looked equivalent at a glance and were
10 + # not.
11 + exclude_re = [
12 + # `CssRule::Ignored` prints as nothing, so sending it to `scopable` instead
13 + # of dropping it produces byte-identical CSS: `print_rules` yields "" and
14 + # the `scopable_css.trim().is_empty()` branch is taken either way.
15 + "delete match arm CssRule::Ignored in scope_and_sanitize",
16 +
17 + # The `worst > u32::MAX` early return at the foot of `projected_expansion`
18 + # is an optimisation, not a decision: anything that large is already an
19 + # order of magnitude past MAX_SELECTORS and is refused whether the walk
20 + # stops there or runs to the end. `>=` and `==` differ from `>` only at
21 + # exactly u32::MAX, which no stylesheet can be built to hit on purpose.
22 + # (`<` is NOT excluded: it returns early on the first cheap rule and misses
23 + # a bomb placed second, which `the_projection_walks_past_the_first_rule`
24 + # catches.)
25 + "replace > with >= in projected_expansion",
26 + "replace > with == in projected_expansion",
27 +
28 + # `flags: ParserFlags::NESTING` is inert at the pinned lightningcss version:
29 + # `ParserFlags::NESTING` is defined in parser.rs and read nowhere in
30 + # 1.0.0-alpha.71, so nesting parses regardless. The field stays because the
31 + # pin is deliberate and a later version may start reading the flag again,
32 + # but no test can observe its absence today (the reason is also at the
33 + # field itself, which is where a reader meets it).
34 + #
35 + # THIS ENTRY DOES NOT TAKE EFFECT, and is kept as the record. Measured
36 + # against cargo-mutants 27.1.0: `exclude_re` does not filter "delete field
37 + # X from struct Y expression" mutants at all, by any pattern, while it
38 + # filters every other kind. So a clean run of this crate reports one
39 + # survivor rather than none, and this is it.
40 + "delete field flags from struct ParserOptions expression in parser_options",
41 +
42 + # `token.ends_with('s')` and `ends_with('%')` in `update_max_iterations` are
43 + # a fast path, not a filter: a token they let through, such as `2s` or
44 + # `50%`, fails `parse::<f32>()` on the next line and is discarded there. So
45 + # `&&` in place of `||` changes which line rejects the token, never whether
46 + # it is rejected. Same for the `is_empty()` term.
47 + # (the pipes are escaped because these patterns are regexes, and a bare `||`
48 + # is an empty alternation that matches every mutant in the crate)
49 + "replace \\|\\| with && in update_max_iterations",
50 + ]