diff --git a/docs/output.md b/docs/output.md index ba3cd6c..71b0ccd 100644 --- a/docs/output.md +++ b/docs/output.md @@ -65,6 +65,7 @@ per-shot output when piped, `--no-progress` the summary on a terminal. | `findings: N repeated on 2+ routes` | After the routes: the same `dom` entry, text line, `fonts` face or element-less band delta (`band -24px`) on two or more routes, once per line with `×N routes` and where (`/a@1440,375`, then `+N routes`), most widespread first, accepted ones aside. Text lines seen on exactly the same routes and widths share one line, `text -"Users" -"Billing" -"Docs" (+9 more)` (a nav folded into a menu button), and N counts lines as printed. Top 20; `summary.findings` in results.json has every finding, one per text line. One cause behind many rows: fix it once. A selector names an element per page, so the same path may be a different element on another route: check one crop. | | `computed: N findings` | With `--computed`: each `key prop left→right` once, with the routes and widths it was seen at; `dom identical`: the markup matches apart from asset hashes and generated class names, so look at CSS or script. See [below](#computed-styles---computed). | | `rules: N changed groups` | With `--computed`: style rules only one side has, by stylesheet and `@media`, and whether any shot had an element they could style. `matched nothing`: the run proved nothing about those rules. See [below](#rule-coverage). | +| `prefixes: N prefixed declarations only one side has` | With `--computed`: `-webkit-`, `-moz-`, `-ms-` and `-o-` declarations in one side's CSS source only, which Chrome's parsed rules can't show. See [below](#vendor-prefixes). | | `aria: identical on N of M route(s)` | With `--aria`: routes whose accessibility tree matches at every width, then each differing route's lines, `+` left only, `-` right only. See [below](#aria-snapshots---aria). | | `crops …` | Up to 3 PNGs per comparison, worst first, relative to `results.json`. Left is on top or on the left, and the change is boxed in red. | | `ERROR …` | Unmeasured, which is neither a pass nor a fail. Exits 3. | @@ -317,6 +318,34 @@ rules: 3 changed groups, 1 never matched, 2 matched on some shots rules then show as one side's. renderdiff's own CSS (hidden selectors, stilled animations, `extraCss`) isn't read. +### Vendor prefixes + +Chrome parses `-webkit-user-select: none` and `user-select: none` into the same rule, so a lint +autofix that drops a prefix Safari still needs is no pixel change and no rule change. So +`--computed` also reads each side's CSS as written and counts its prefixed properties: + +``` +prefixes: 3 prefixed declarations only one side has + only left has: -webkit-background-clip: text; -webkit-text-size-adjust: 100%; -webkit-user-select: none /a@1440,375 +``` + +- The source is every stylesheet the page loaded, as served (cross-origin and `@import`ed ones + too), and the text of every ` + + +
+

Vendor prefixes, rewritten

+

Both sides hold the same three -webkit- declarations. Only where they are written, and how, differs, so the prefix check finds nothing.

+

Gradient text

+

This line is not selectable.

+
+

Notes

+

A build that minifies or fingerprints its stylesheets keeps every declaration, so it is no change.

+
+
+ + diff --git a/tests/fixtures/pages/prefix-same/right.html b/tests/fixtures/pages/prefix-same/right.html new file mode 100644 index 0000000..dc1b581 --- /dev/null +++ b/tests/fixtures/pages/prefix-same/right.html @@ -0,0 +1,28 @@ + + + + +Vendor prefixes, rewritten + + + + + +
+

Vendor prefixes, rewritten

+

Both sides hold the same three -webkit- declarations. Only where they are written, and how, differs, so the prefix check finds nothing.

+

Gradient text

+

This line is not selectable.

+
+

Notes

+

A build that minifies or fingerprints its stylesheets keeps every declaration, so it is no change.

+
+
+ + diff --git a/tests/fixtures/pages/prefix/left.html b/tests/fixtures/pages/prefix/left.html new file mode 100644 index 0000000..4dd7325 --- /dev/null +++ b/tests/fixtures/pages/prefix/left.html @@ -0,0 +1,36 @@ + + + + +Vendor prefixes + + + + + + +
+

Vendor prefixes

+

Three declarations lost their -webkit- prefix on one side. Chromium honours both spellings, so the pixels match; Safari honours only the prefixed one.

+

Gradient text

+

This line is not selectable.

+
+

Notes

+

The prefix check reads every stylesheet as served and every style element's text, so a declaration counts wherever it is written.

+
+
+ + diff --git a/tests/fixtures/pages/prefix/right.html b/tests/fixtures/pages/prefix/right.html new file mode 100644 index 0000000..14ff063 --- /dev/null +++ b/tests/fixtures/pages/prefix/right.html @@ -0,0 +1,36 @@ + + + + +Vendor prefixes + + + + + + +
+

Vendor prefixes

+

Three declarations lost their -webkit- prefix on one side. Chromium honours both spellings, so the pixels match; Safari honours only the prefixed one.

+

Gradient text

+

This line is not selectable.

+
+

Notes

+

The prefix check reads every stylesheet as served and every style element's text, so a declaration counts wherever it is written.

+
+
+ + diff --git a/tests/fixtures/shared/prefix/cross.left.css b/tests/fixtures/shared/prefix/cross.left.css new file mode 100644 index 0000000..dd023fd --- /dev/null +++ b/tests/fixtures/shared/prefix/cross.left.css @@ -0,0 +1 @@ +.pick { -webkit-user-select: none; } diff --git a/tests/fixtures/shared/prefix/cross.right.css b/tests/fixtures/shared/prefix/cross.right.css new file mode 100644 index 0000000..b997f6a --- /dev/null +++ b/tests/fixtures/shared/prefix/cross.right.css @@ -0,0 +1 @@ +.pick { user-select: none; } diff --git a/tests/fixtures/shared/prefix/import.left.css b/tests/fixtures/shared/prefix/import.left.css new file mode 100644 index 0000000..fc6dbfc --- /dev/null +++ b/tests/fixtures/shared/prefix/import.left.css @@ -0,0 +1 @@ +button { -webkit-appearance: none; } diff --git a/tests/fixtures/shared/prefix/import.right.css b/tests/fixtures/shared/prefix/import.right.css new file mode 100644 index 0000000..23f8924 --- /dev/null +++ b/tests/fixtures/shared/prefix/import.right.css @@ -0,0 +1 @@ +button { appearance: none; } diff --git a/tests/fixtures/shared/prefix/link.left.css b/tests/fixtures/shared/prefix/link.left.css new file mode 100644 index 0000000..979e231 --- /dev/null +++ b/tests/fixtures/shared/prefix/link.left.css @@ -0,0 +1,2 @@ +@import "import.left.css"; +.clip { -webkit-background-clip: text; } diff --git a/tests/fixtures/shared/prefix/link.right.css b/tests/fixtures/shared/prefix/link.right.css new file mode 100644 index 0000000..76e4e57 --- /dev/null +++ b/tests/fixtures/shared/prefix/link.right.css @@ -0,0 +1,2 @@ +@import "import.right.css"; +.clip { background-clip: text; } diff --git a/tests/fixtures/shared/prefix/site.3f9a8c1d.css b/tests/fixtures/shared/prefix/site.3f9a8c1d.css new file mode 100644 index 0000000..e943115 --- /dev/null +++ b/tests/fixtures/shared/prefix/site.3f9a8c1d.css @@ -0,0 +1 @@ +.clip{background-image:linear-gradient(90deg,#0b5,#05b);-webkit-background-clip:text}.pick{-webkit-user-select:none}html{-webkit-text-size-adjust:100%} diff --git a/tests/fixtures/shared/prefix/site.css b/tests/fixtures/shared/prefix/site.css new file mode 100644 index 0000000..d1d01bc --- /dev/null +++ b/tests/fixtures/shared/prefix/site.css @@ -0,0 +1,9 @@ +/* prefix-same, left: pretty-printed, in source order. */ +html { + -webkit-text-size-adjust: 100%; +} + +.clip { + -webkit-background-clip: text; + background-image: linear-gradient(90deg, #0b5, #05b); +} diff --git a/tests/helpers/corpus.ts b/tests/helpers/corpus.ts index fa08c93..364c032 100644 --- a/tests/helpers/corpus.ts +++ b/tests/helpers/corpus.ts @@ -69,6 +69,8 @@ export const GUARDRAILS = [ "appshell", "smooth-scroll", "rules", + "prefix", + "prefix-same", "reflowing", "waapi", "geometry", diff --git a/tests/helpers/parse.ts b/tests/helpers/parse.ts index f89e404..15415b3 100644 --- a/tests/helpers/parse.ts +++ b/tests/helpers/parse.ts @@ -53,6 +53,8 @@ export interface Summary { stale: string[]; /** With `--computed`: the `rules:` line and the group lines under it; empty without one. */ rules: string[]; + /** With `--computed`: the `prefixes:` line and the side lines under it; empty without one. */ + prefixes: string[]; /** The `rerun:` line of a `--rerun-failed` run, else null. */ rerun: { shots: number; routes: number; reused: number; untouched: number } | null; /** Each differing route: widths that were clean or accepted, then one row per collapsed width group. */ @@ -144,6 +146,7 @@ export function parseSummary(stdout: string): Summary { unstable: listed("unstable ≤100px"), stale, rules: /^rules: .*(?:\n .*)*/m.exec(stdout)?.[0].split("\n") ?? [], + prefixes: /^prefixes: .*(?:\n .*)*/m.exec(stdout)?.[0].split("\n") ?? [], rerun: rerun && { shots: Number(rerun[1]), routes: Number(rerun[2]), diff --git a/tests/unit/computed.test.ts b/tests/unit/computed.test.ts index b89eaa8..755d328 100644 --- a/tests/unit/computed.test.ts +++ b/tests/unit/computed.test.ts @@ -13,6 +13,8 @@ import { groupFindings, normalizeAssets, pageError, + prefixDiff, + prefixed, type Snapshot, } from "../../src/computed.ts"; import { SNAP_PROPS } from "../../src/locate.ts"; @@ -414,6 +416,32 @@ describe("groupFindings", () => { ); }); + test("fmtComputed lists each side's prefixed declarations, capped, with where", () => { + const none = { dom: 0, changes: [], truncated: 0, unmatched: { left: [], right: [] } }; + const many = Array.from({ length: 12 }, (_, i) => `-webkit-x${String(i).padStart(2, "0")}: 1`); + assert.deepEqual( + fmtComputed( + [ + shot("/a", 1440, { computed: { ...none, prefixes: { left: many, right: [] } } }), + shot("/a", 375, { computed: { ...none, prefixes: { left: [], right: ["-moz-y: 2"] } } }), + ], + ["/a"], + ).filter((l) => /^(prefixes| only)/.test(l)), + [ + "prefixes: 13 prefixed declarations only one side has", + ` only left has: ${many.slice(0, 10).join("; ")} (+2 more) /a@1440`, + " only right has: -moz-y: 2 /a@375", + ], + ); + assert.deepEqual( + fmtComputed( + [shot("/a", 1440, { computed: { ...none, prefixes: { left: [], right: [] } } })], + ["/a"], + ).at(-1), + "prefixes: no prefixed declaration differs between the sides", + ); + }); + test("without --computed there is no block", () => { assert.deepEqual(fmtComputed([shot("/a", 1440, {})], ["/a"]), []); }); @@ -432,6 +460,78 @@ test("a snapshot error keeps the page's message, not page.evaluate's prefix or s assert.equal(pageError(new Error(timeout)), timeout); }); +describe("prefixed", () => { + test("reads declarations as written, comments dropped, whitespace and importance normalised", () => { + assert.deepEqual( + prefixed(`html{-webkit-text-size-adjust:100%}/* -moz-x: y; */ + .a { -webkit-mask : linear-gradient( red , blue ) !important ; -MS-filter: none }`), + [ + "-webkit-text-size-adjust: 100%", + "-webkit-mask: linear-gradient( red , blue ) !important", + "-ms-filter: none", + ], + ); + }); + + test("a /* in url() or a string is no comment, and a data URI's ; stays in its value", () => { + assert.deepEqual(prefixed(".a{background:url(/img/*.png)} .b{-webkit-user-select:none}"), [ + "-webkit-user-select: none", + ]); + assert.deepEqual( + prefixed('.a{content:"/*"} .b{-webkit-user-select:none} /* .c{-webkit-x:1} */'), + ["-webkit-user-select: none"], + ); + assert.deepEqual( + prefixed( + '.a{-webkit-mask:url("data:image/svg+xml;utf8,") center;-webkit-x:1 !IMPORTANT}', + ), + ['-webkit-mask: url("data:image/svg+xml;utf8,") center', "-webkit-x: 1 !important"], + ); + }); + + test("leaves out selectors, media features, values and unprefixed names", () => { + assert.deepEqual( + prefixed(`::-webkit-scrollbar:hover { width: 0 } + input::-webkit-inner-spin-button, a:focus { display: none } + @media (-webkit-min-device-pixel-ratio: 2) { .b { transition: -webkit-transform 1s } } + .c { user-select: none; x-webkit-y: 1 }`), + [], + ); + }); + + test("a minifier's rewrite of a value is no finding; a dropped prefix still is", () => { + const none = { left: [], right: [] }; + assert.deepEqual( + prefixDiff( + [".a { -webkit-transition: opacity 0.3s ease; }"], + [".a{-webkit-transition:opacity .3s ease}"], + ), + none, + ); + assert.deepEqual( + prefixDiff( + [ + ".a{-webkit-transition:opacity 0.3s;-webkit-user-select:none} .b{-webkit-user-select:none}", + ], + [".a{-webkit-transition:opacity .3s;user-select:none} .b{-webkit-user-select:none}"], + ), + { left: ["-webkit-user-select: none"], right: [] }, + ); + }); + + test("a side's extra copy is a finding; which file or rule holds it is not", () => { + const decl = "-webkit-user-select: none"; + assert.deepEqual(prefixDiff([`.a{${decl}}`, `.b{${decl}}`], [`.z { ${decl}; }`]), { + left: [decl], + right: [], + }); + assert.deepEqual(prefixDiff([`.a{${decl}}`, ".b{}"], [".b{}", `.c{${decl}}`]), { + left: [], + right: [], + }); + }); +}); + describe("diffAria", () => { const same = { added: [], removed: [], truncated: 0 }; diff --git a/tests/visual/controls.test.ts b/tests/visual/controls.test.ts index 6f07d83..0dd9253 100644 --- a/tests/visual/controls.test.ts +++ b/tests/visual/controls.test.ts @@ -729,6 +729,7 @@ test("--computed names the changed rule's property, once across widths, and the (f) => /swatch/.test(f.key) && f.prop === "background-color" && f.at[0]!.url === "/color", ), ); + assert.deepEqual(summary.prefixes, [], context(r)); // /margin's Contact chip only moved, so it is no finding of its own. assert.ok(!summary.computed!.some((f) => /#contact/.test(f.key)), context(r)); const inserted = rows.find((x) => x.url === "/inserted" && x.status === "compared"); diff --git a/tests/visual/guardrails.test.ts b/tests/visual/guardrails.test.ts index 6715647..8179918 100644 --- a/tests/visual/guardrails.test.ts +++ b/tests/visual/guardrails.test.ts @@ -459,6 +459,36 @@ test("--computed shows whether a rewritten breakpoint, and a rule nothing has, e ); }); +test("--computed lists the prefixed declarations only one side's CSS source has, where pixels match", async () => { + const run = await capture(["/prefix", "/prefix-same"], { + widths: [1440, 375], + extra: ["--computed"], + }); + assert.equal(run.code, 0, context(run)); + const out = results(run.outDir); + for (const route of ["/prefix", "/prefix-same"]) + for (const width of [1440, 375]) assert.equal(result(out, route, width).diffPixels, 0); + // One in the