Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions docs/output.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,15 +289,22 @@ style rule in each side's stylesheets and lists the groups of rules only one sid
```
rules: 3 changed groups, 1 never matched, 2 matched on some shots
+chat.css (1 rule) matched nothing: /help@1440,375
+docs.css @media (width >= 60em) (5 rules) matched /a@960,1440 /b@960; nothing /a@959,375
+docs.css @media (min-width: 60em) (5 rules) matched /a@960,1440 /b@960; nothing /a@959,375
-docs.css @media (min-width: 60em) (5 rules) matched /a@960,1440 /b@960; nothing /a@959,375
```

- A rule is its enclosing at-rules, selector (nesting resolved) and declarations, as Chrome
serializes them. So pure notation changes Chrome normalizes (`rgb(0 0 0)` and
`rgb(0, 0, 0)`) are no change, while `min-width:` against `width >=` is. Asset hashes in
`url()` and the left origin are read as on the other side. A rule moved to another file is
no change. Only style rules count: not `@font-face`, `@keyframes` or `@property`.
serializes them, compared by meaning. So notation Chrome normalizes (`rgb(0 0 0)` and
`rgb(0, 0, 0)`, hex case, `0` and `0px`, merged shorthands) is no change, and neither is
declaration order or `@media (min-width: 60em)` against `(width >= 60em)`, or `max-width:`
against `<=`. A shorthand and longhand that swap order are a change, since that changes
what the rule does. A rule with a logical (`margin-inline-start`) or `-webkit-` property
keeps its order, since `-webkit-border-image` and `border-image` set the same thing and the
later wins, so reordering one is a change even where it does nothing. The reversed
`(60em <= width)` and the two-sided `(60em <= width <= 80em)` aren't read as `min-width:`.
Asset hashes in `url()` and the left origin are read as on the other side. A rule moved to
another file is no change. Only style rules count: not `@font-face`, `@keyframes` or
`@property`.
- `+` rules only the left (your change) has, `-` only the right. A group is a stylesheet's file
name (asset hash as `#`; a `<style>` is `inline#` and a hash of its text, so one block
inlined into every page is one group) and its enclosing at-rules.
Expand Down Expand Up @@ -560,7 +567,7 @@ file, not each line), scores rounded down to four decimals (so a shot that diffe
"fonts": { "added": [], "removed": ["Inter 600"] }, // web font faces one side loaded; absent when equal
"masked": ["rotating quote (data-renderdiff-mask)"], // absent unless a side marked elements data-renderdiff-mask
"computed": { "dom": 1, "changes": [], "truncated": 0, "unmatched": { "left": [], "right": ["body > nav > a[href=\"/pricing\"]"] },
"rules": [{ "side": "left", "sheet": "docs.css", "conditions": ["@media (width >= 60em)"], "rules": 5, "elements": 67 }],
"rules": [{ "side": "left", "sheet": "docs.css", "conditions": ["@media (min-width: 60em)"], "rules": 5, "elements": 67 }],
"prefixes": { "left": ["-webkit-user-select: none"], "right": [] } }, // --computed only; null: snapshot failed, "computedError" says why; rules: one-sided rule groups, elements they could style here; prefixes: { left, right }, prefixed declarations only that side's CSS source has
"aria": { "added": ["- text: Buy now"], "removed": ["- button \"Buy now\""], "truncated": 0 }, // --aria only; both empty: identical; null: snapshot failed, "ariaError" says why
"images": { "left": "about.1440.left.png", "right": "about.1440.right.png", "diff": "about.1440.diff.png" }, // packed: .webp, right may name left's file, no diff when diffPixels is 0
Expand Down
6 changes: 3 additions & 3 deletions skills/renderdiff/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,9 +225,9 @@ computed: 2 findings; dom identical on 2 of 3 route(s), differs /pricing (1), so
- `rules: N changed groups, X never matched, Y matched on some shots`: style rules only one
side has (`+` left, `-` right), by stylesheet and `@media`. `matched nothing`: no shot had an
element they could style, so the run proved nothing about them; add a route, width or hook
that reaches them. Rules compare as Chrome parses them: `rgb()` notation is no change,
`min-width:` → `width >=` is. Details and limits: docs/output.md § Rule coverage in the
renderdiff repo.
that reaches them. Rules compare by meaning, as Chrome parses them: `rgb()` notation,
declaration order and `min-width:` → `width >=` are no change; a shorthand and longhand
swapping order is. Details and limits: docs/output.md § Rule coverage in the renderdiff repo.
- `prefixes: N prefixed declarations only one side has`: `-webkit-`/`-moz-`/`-ms-`/`-o-`
properties one side's CSS source declares more often. Details and limits: docs/output.md
§ Vendor prefixes in the renderdiff repo.
Expand Down
44 changes: 33 additions & 11 deletions src/computed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,20 +103,24 @@ const POSITION = FIELDS.indexOf("position");
const CLIP = FIELDS.indexOf("clip");
const CLIP_PATH = FIELDS.indexOf("clip-path");

const splitTop = (s: string) => {
/** `s` split at `sep`; one in a string or brackets (`url("data:…;base64,…")`) splits nothing. */
function splitTop(s: string, sep = ","): string[] {
const out: string[] = [];
let depth = 0;
let start = 0;
let [from, quote, depth] = [0, "", 0];
for (let i = 0; i < s.length; i++) {
if (s[i] === "(") depth++;
else if (s[i] === ")") depth--;
else if (s[i] === "," && !depth) {
out.push(s.slice(start, i).trim());
start = i + 1;
const c = s[i]!;
if (c === "\\") i++;
else if (quote) quote = c === quote ? "" : quote;
else if (c === '"' || c === "'") quote = c;
else if ("([{".includes(c)) depth++;
else if (")]}".includes(c)) depth--;
else if (c === sep && !depth) {
out.push(s.slice(from, i).trim());
from = i + 1;
}
}
return [...out, s.slice(start).trim()];
};
return [...out, s.slice(from).trim()];
}

const SIDES: Record<string, number> = { top: 0, right: 90, bottom: 180, left: 270 };
const FLIP: Record<string, string> = { top: "bottom", bottom: "top", left: "right", right: "left" };
Expand Down Expand Up @@ -438,9 +442,27 @@ function matching(groups: string[][]) {
});
}

const ORDERED =
/^(?!--)(?:[a-z-]+-)?(?:inline|block)[-:]|^(?:border|corner)-(?:start|end)-(?:start|end)-|^-webkit-/;

/** Declarations sorted, unless one is `ORDERED`: it and its twin are two longhands, and the later wins. */
function unordered(css: string): string {
const all = splitTop(css, ";");
return (all.some((d) => ORDERED.test(d)) ? all : all.sort()).join("; ");
}

/** `@media (min-width: 1px)` as `(width >= 1px)`; in `@supports` that's a property, so left alone. */
const ranged = (cond: string) =>
cond.startsWith("@media ")
? cond.replace(
/\((min|max)-([a-z-]+): ((?:[^()]|\([^()]*\))+)\)/g,
(_, m: string, f: string, v: string) => `(${f} ${m === "min" ? ">=" : "<="} ${v})`,
)
: cond;

/** A row compared across sides: the left origin read as the right's, asset hashes in `url()` as `#`. */
const ruleKey = ([, conds, sel, css]: RuleRow, origins?: [string, string]) => {
const k = `${conds.join(" ")}\0${sel}\0${css}`;
const k = `${conds.map(ranged).join(" ")}\0${sel}\0${unordered(css)}`;
return (origins ? rebaseText(k, ...origins) : k).replace(
/url\("([^"]*)"\)/g,
(_, u: string) => `url("${normalizeUrl(u)}")`,
Expand Down
18 changes: 14 additions & 4 deletions tests/fixtures/pages/rules/left.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,31 @@
<title>Rule coverage</title>
<link rel="stylesheet" href="/shared/base.css">
<style>
/* GUARD RAIL for --computed's rule coverage. This side writes the breakpoint as a range and adds a rule for a .bubble no page has and two
state-only rules, which any element could take; the
colour and the url() hash differ only in notation, so they are no change. */
/* GUARD RAIL for --computed's rule coverage. This side writes breakpoints as ranges and adds a rule for a .bubble no page has and two
state-only rules, which any element could take. The colour, the url() hash, .toc's declaration order, .data's and the two
.toc li breakpoints differ only in notation, so they are no change. The .toc breakpoint's value is one. So are .swap, .swap-var and
.logical, .corner and .autoprefixed, which keep their declarations in another order that changes what they mean, and .split, whose `;` are inside strings. */
.toc { margin: 0 0 32px; padding: 0; list-style: none; color: rgb(0 0 0); }
.toc li { height: 24px; }
@media (width >= 960px) { .toc li { padding-left: 8px; } }
@media (width <= 959px) { .toc li { padding-left: 0; } }
@media (width >= 960px) { .toc { outline-offset: 1px; } }
.ghost { background-image: url(/shared/img/ghost.3f9a8c1d.png); }
.data { background: url("data:image/gif;base64,R0lGODlhAQABAAAAACw=") no-repeat; color: red; content: "a;b"; }
.bubble { border-radius: 4px; }
.swap { margin: 0; margin-top: 4px; }
.swap-var { margin: var(--gap); margin-top: 4px; }
.logical { margin-left: 1px; margin-inline-start: 2px; }
.corner { corner-top-left-shape: bevel; corner-start-start-shape: notch; }
.autoprefixed { -webkit-border-image: url(a.png) 30 round; border-image: url(a.png) 30 round; }
.split { --v: "a;b"; --w: "c;d"; }
@media screen { ::selection { color: rgb(255 255 255); } :focus-visible { outline-offset: 2px; } }
</style>
</head>
<body>
<div class="page">
<h1>Rule coverage guard rail</h1>
<p class="lede">The two sides write one breakpoint in different notation and render the same, so only rule coverage tells the rewritten rule apart, and whether any shot reached it.</p>
<p class="lede">The two sides write their breakpoints in different notation and render the same, so only rule coverage tells a rule that really changed from one rewritten, and whether any shot reached it.</p>

<ul class="toc">
<li>Install</li>
Expand Down
15 changes: 12 additions & 3 deletions tests/fixtures/pages/rules/right.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,26 @@
<title>Rule coverage</title>
<link rel="stylesheet" href="/shared/base.css">
<style>
/* GUARD RAIL for --computed's rule coverage. This side keeps `min-width`, and has no .bubble rule. */
.toc { margin: 0 0 32px; padding: 0; list-style: none; color: rgb(0, 0, 0); }
/* GUARD RAIL for --computed's rule coverage. This side keeps `min-width` and `max-width`, and has no .bubble rule. */
.toc { color: rgb(0, 0, 0); list-style: none; padding: 0; margin: 0 0 32px; }
.toc li { height: 24px; }
@media (min-width: 960px) { .toc li { padding-left: 8px; } }
@media (max-width: 959px) { .toc li { padding-left: 0; } }
@media (min-width: 960px) { .toc { outline-offset: 2px; } }
.ghost { background-image: url(/shared/img/ghost.9b8c7d6e.png); }
.data { content: "a;b"; color: red; background: url("data:image/gif;base64,R0lGODlhAQABAAAAACw=") no-repeat; }
.swap { margin-top: 4px; margin: 0; }
.swap-var { margin-top: 4px; margin: var(--gap); }
.logical { margin-inline-start: 2px; margin-left: 1px; }
.corner { corner-start-start-shape: notch; corner-top-left-shape: bevel; }
.autoprefixed { border-image: url(a.png) 30 round; -webkit-border-image: url(a.png) 30 round; }
.split { --v: "a;d"; --w: "c;b"; }
</style>
</head>
<body>
<div class="page">
<h1>Rule coverage guard rail</h1>
<p class="lede">The two sides write one breakpoint in different notation and render the same, so only rule coverage tells the rewritten rule apart, and whether any shot reached it.</p>
<p class="lede">The two sides write their breakpoints in different notation and render the same, so only rule coverage tells a rule that really changed from one rewritten, and whether any shot reached it.</p>

<ul class="toc">
<li>Install</li>
Expand Down
26 changes: 17 additions & 9 deletions tests/visual/guardrails.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,15 +414,21 @@ describe("consent chrome", () => {
});
});

test("--computed shows whether a rewritten breakpoint, and a rule nothing has, ever applied", async () => {
test("--computed lists rules that changed meaning, not ones reordered or rewritten as ranges, and whether each applied", async () => {
const run = await capture(["/rules"], { widths: [960, 959], extra: ["--computed"] });
assert.equal(run.code, 0, context(run));
const at = (widths: number[]) => (widths.length ? [{ url: "/rules", widths }] : []);
const group = (side: string, conditions: string[], matched: number[], unmatched: number[]) => ({
const group = (
side: string,
conditions: string[],
matched: number[],
unmatched: number[],
rules = 1,
) => ({
side,
sheet: "inline#",
conditions,
rules: conditions[0] === "@media screen" ? 2 : 1,
rules,
matched: at(matched),
unmatched: at(unmatched),
});
Expand All @@ -433,26 +439,28 @@ test("--computed shows whether a rewritten breakpoint, and a rule nothing has, e
rules.every((g) => /^inline#[0-9a-f]{6}$/.test(g.sheet)),
context(run),
);
// The rgb() and url() hash rewrites mean the same, so they are no group; state-only rules match.
// Rewrites that mean the same are no group; state-only rules match.
assert.deepEqual(
rules.map((g) => ({ ...g, sheet: g.sheet.replace(hash, "inline#") })),
[
group("left", [], [], [960, 959]),
group("left", [], [], [960, 959], 7),
group("right", [], [], [960, 959], 6),
group("left", ["@media (width >= 960px)"], [960], [959]),
group("right", ["@media (min-width: 960px)"], [960], [959]),
group("left", ["@media screen"], [960, 959], []),
group("left", ["@media screen"], [960, 959], [], 2),
],
context(run),
);
assert.deepEqual(
result(results(run.outDir), "/rules", 960).computed?.rules?.map((g) => g.elements > 0),
[true, false, true, true],
[true, false, true, true, false],
);
assert.deepEqual(
parseSummary(run.stdout).rules.map((l) => l.replace(hash, "inline#")),
[
"rules: 4 changed groups, 1 never matched, 2 matched on some shots",
" +inline# (1 rule) matched nothing: /rules@960,959",
"rules: 5 changed groups, 2 never matched, 2 matched on some shots",
" +inline# (7 rules) matched nothing: /rules@960,959",
" -inline# (6 rules) matched nothing: /rules@960,959",
" +inline# @media (width >= 960px) (1 rule) matched /rules@960; nothing /rules@959",
" -inline# @media (min-width: 960px) (1 rule) matched /rules@960; nothing /rules@959",
],
Expand Down
Loading