From b857c8584e87d4cca36a538361142fa07a540ec8 Mon Sep 17 00:00:00 2001 From: Timur Mashara <62305878+tmashara@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:15:17 +0100 Subject: [PATCH 1/2] --computed: list prefixed declarations only one side's CSS source has Chromium's CSSOM serialises -webkit-user-select as user-select, so a lint autofix that drops a prefix Safari needs passes pixels and rule coverage alike. Read each load's stylesheet bodies as served and 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..ad533d2 --- /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..dc20c28 --- /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/link.left.css b/tests/fixtures/shared/prefix/link.left.css new file mode 100644 index 0000000..c49178d --- /dev/null +++ b/tests/fixtures/shared/prefix/link.left.css @@ -0,0 +1 @@ +.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..8cb8fa7 --- /dev/null +++ b/tests/fixtures/shared/prefix/link.right.css @@ -0,0 +1 @@ +.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..84e7aa0 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,46 @@ 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, whatever the whitespace, comments or importance", () => { + 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", + ], + ); + assert.deepEqual( + prefixed(".a { -webkit-mask: linear-gradient(red, blue) !important; }"), + prefixed(".a{-webkit-mask:linear-gradient(red,blue)!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 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/guardrails.test.ts b/tests/visual/guardrails.test.ts index 6715647..951d5af 100644 --- a/tests/visual/guardrails.test.ts +++ b/tests/visual/guardrails.test.ts @@ -37,7 +37,8 @@ import { results, row, } from "../helpers/parse.ts"; -import { PAGES_DIR, startFixtureSite, type FixtureSite } from "../helpers/server.ts"; +import { CONTROLS, EQUIVALENCE } from "../helpers/corpus.ts"; +import { PAGES_DIR, routePath, startFixtureSite, type FixtureSite } from "../helpers/server.ts"; import { EXPECT_HEIGHT } from "../fixtures/pages/tall/page.ts"; /** Inside the lottie fixture's top-right player box on the 1440 shot. */ @@ -459,6 +460,45 @@ 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