cssupgrade 2026.0.0 → 2026.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,8 @@ on:
4
4
  branches:
5
5
  - main
6
6
  pull_request:
7
+ permissions:
8
+ contents: read
7
9
  jobs:
8
10
  readme:
9
11
  name: "Word Weasel / README.md"
@@ -19,7 +21,10 @@ jobs:
19
21
  text: ${{ steps.read_readme.outputs.content }}
20
22
  node-test:
21
23
  name: Test
22
- runs-on: ubuntu-latest
24
+ runs-on: macos-latest
25
+ permissions:
26
+ contents: read
27
+ id-token: write
23
28
  steps:
24
29
  - uses: actions/checkout@v7
25
30
  - name: Set up Node.js
@@ -27,9 +32,13 @@ jobs:
27
32
  with:
28
33
  node-version-file: package.json
29
34
  - run: npm ci
35
+ - run: npx playwright install
36
+ - run: sudo safaridriver --enable
30
37
  - run: npm test
38
+ env:
39
+ SAFARI: "1"
31
40
  - uses: codecov/codecov-action@v7
32
41
  with:
33
- token: ${{ secrets.CODECOV_TOKEN }}
42
+ use_oidc: true
34
43
  flags: css
35
44
  files: lcov.info
@@ -6,6 +6,7 @@ on:
6
6
  - opened
7
7
  - edited
8
8
  - reopened
9
+ permissions: {}
9
10
  jobs:
10
11
  pr-title:
11
12
  name: "PR Title"
package/CONTRIBUTING.md CHANGED
@@ -4,9 +4,21 @@ To run tests locally, follow these steps:
4
4
 
5
5
  ```console
6
6
  npm ci
7
+ npx playwright install
7
8
  npm test
8
9
  ```
9
10
 
11
+ The browser tests render every fixture with the original and transformed CSS and
12
+ compare the computed styles of all elements in Chromium, Firefox, WebKit, and
13
+ Safari. Register fixtures in the test file of the transformer they cover.
14
+
15
+ Real Safari runs only on macOS with Remote Automation enabled:
16
+
17
+ ```console
18
+ sudo safaridriver --enable
19
+ npm run test:safari
20
+ ```
21
+
10
22
  We use pre-commit hooks to lint and format code before committing.
11
23
 
12
24
  To install the pre-commit hooks, run:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cssupgrade",
3
- "version": "2026.0.0",
3
+ "version": "2026.0.2",
4
4
  "description": "Auto-upgrade your CSS syntax",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -8,7 +8,8 @@
8
8
  "cssupgrade": "./bin/cssupgrade.js"
9
9
  },
10
10
  "scripts": {
11
- "test": "node --test --experimental-test-coverage --test-reporter=spec --test-reporter=lcov --test-reporter-destination=stdout --test-reporter-destination=lcov.info --test-coverage-exclude=tests/**/* tests/*.test.js tests/**/*.test.js"
11
+ "test": "node --test --experimental-test-coverage --test-reporter=spec --test-reporter=lcov --test-reporter-destination=stdout --test-reporter-destination=lcov.info --test-coverage-exclude=tests/**/* tests/*.test.js tests/**/*.test.js",
12
+ "test:safari": "SAFARI=1 node --test --test-reporter=spec --test-name-pattern=\"computed styles safari\" tests/*.test.js tests/**/*.test.js"
12
13
  },
13
14
  "keywords": [
14
15
  "css",
@@ -32,5 +33,9 @@
32
33
  "repository": {
33
34
  "type": "git",
34
35
  "url": "https://github.com/codingjoe/cssupgrade.git"
36
+ },
37
+ "devDependencies": {
38
+ "playwright": "^1.65.0",
39
+ "selenium-webdriver": "^4.51.0"
35
40
  }
36
41
  }
@@ -1,3 +1,4 @@
1
+ import { generate } from "css-tree"
1
2
  import { parseCss } from "../parser.js"
2
3
 
3
4
  /**
@@ -40,13 +41,16 @@ const selectorListPseudoClasses = new Set([
40
41
  "-webkit-any",
41
42
  "any",
42
43
  "has",
43
- "host",
44
- "host-context",
45
44
  "is",
46
45
  "matches",
47
46
  "not",
48
47
  ])
49
48
 
49
+ /**
50
+ * Pseudo-classes whose specificity is a pseudo-class plus their argument.
51
+ */
52
+ const hostPseudoClasses = new Set(["host", "host-context"])
53
+
50
54
  /**
51
55
  * Pseudo-classes that select the nth element and accept an optional selector list.
52
56
  */
@@ -58,19 +62,9 @@ const nthPseudoClasses = new Set(["nth-child", "nth-last-child"])
58
62
  const plainFunctionalPseudoClasses = new Set(["dir", "lang", "state"])
59
63
 
60
64
  /**
61
- * Text that can follow a parent selector when a child selector extends it.
62
- */
63
- const selectorContinuationPattern = /^[\s>+~.#:[]/
64
-
65
- /**
66
- * Relative combinator at the start of a nested selector.
65
+ * CSS whitespace characters.
67
66
  */
68
- const relativeCombinatorPattern = /^[>+~]/
69
-
70
- /**
71
- * Leading whitespace of the first selector in a nested selector.
72
- */
73
- const leadingWhitespacePattern = /^\s/
67
+ const cssWhitespaceCharacters = new Set([" ", "\t", "\n", "\r", "\f"])
74
68
 
75
69
  /**
76
70
  * Add two specificity values.
@@ -200,6 +194,16 @@ function calculatePseudoClassSpecificity(node) {
200
194
  return [0, 1, 0]
201
195
  case selectorListPseudoClasses.has(name):
202
196
  return calculateSelectorArgumentSpecificity(node)
197
+ case hostPseudoClasses.has(name): {
198
+ const argumentSpecificity = calculateSelectorArgumentSpecificity(node)
199
+
200
+ switch (argumentSpecificity) {
201
+ case null:
202
+ return null
203
+ default:
204
+ return addSpecificity([0, 1, 0], argumentSpecificity)
205
+ }
206
+ }
203
207
  case nthPseudoClasses.has(name): {
204
208
  const argumentSpecificity = calculateNthSelectorSpecificity(node)
205
209
 
@@ -286,16 +290,14 @@ function isPlainRule(rule) {
286
290
  }
287
291
 
288
292
  /**
289
- * Read the source text of every selector in a rule.
293
+ * Check whether two selector nodes serialize identically.
290
294
  *
291
- * @param {string} code - CSS source text.
292
- * @param {import("css-tree").CssNode} rule - Style rule.
293
- * @returns {string[]} Selector texts.
295
+ * @param {import("css-tree").CssNode} left - First selector node.
296
+ * @param {import("css-tree").CssNode} right - Second selector node.
297
+ * @returns {boolean} True when both nodes are equal.
294
298
  */
295
- function extractSelectorTexts(code, rule) {
296
- return Array.from(rule.prelude.children, (selector) =>
297
- code.slice(selector.loc.start.offset, selector.loc.end.offset).trim(),
298
- )
299
+ function isEqualSelectorNode(left, right) {
300
+ return generate(left) === generate(right)
299
301
  }
300
302
 
301
303
  /**
@@ -332,47 +334,146 @@ function hasCommentOutsideBlock(comments, rule) {
332
334
  }
333
335
 
334
336
  /**
335
- * Match a parent selector against the start of a child selector.
337
+ * Describe the remainder of a child selector that starts with a combinator.
336
338
  *
337
- * @param {string} parentSelector - Parent selector text.
338
- * @param {string} childSelector - Child selector text.
339
- * @returns {string | null} Remaining child selector text, or null without a match.
339
+ * @param {string} code - CSS source text.
340
+ * @param {import("css-tree").CssNode} childSelector - Child selector.
341
+ * @param {import("css-tree").CssNode} combinator - Combinator node.
342
+ * @param {import("css-tree").CssNode} next - First node after the combinator.
343
+ * @returns {{kind: string, text: string}} Child selector remainder.
340
344
  */
341
- function matchSelectorPrefix(parentSelector, childSelector) {
342
- switch (true) {
343
- case !childSelector.startsWith(parentSelector):
344
- return null
345
- default: {
346
- const remainder = childSelector.slice(parentSelector.length)
345
+ function describeCombinatorRemainder(code, childSelector, combinator, next) {
346
+ switch (combinator.name.trim()) {
347
+ case "":
348
+ return {
349
+ kind: "descendant",
350
+ text: code.slice(next.loc.start.offset, childSelector.loc.end.offset),
351
+ }
352
+ default:
353
+ return {
354
+ kind: "relative",
355
+ text: code.slice(combinator.loc.start.offset, childSelector.loc.end.offset),
356
+ }
357
+ }
358
+ }
347
359
 
348
- switch (true) {
349
- case remainder.trim() === "":
350
- return null
351
- case !selectorContinuationPattern.test(remainder):
360
+ /**
361
+ * Describe the remainder of a child selector after a parent selector.
362
+ *
363
+ * @param {string} code - CSS source text.
364
+ * @param {import("css-tree").CssNode} childSelector - Child selector.
365
+ * @param {import("css-tree").CssNode[]} childNodes - Child selector nodes.
366
+ * @param {number} index - Index of the first node after the parent selector.
367
+ * @returns {{kind: string, text: string} | null} Remainder, or null when incomplete.
368
+ */
369
+ function describeSelectorRemainder(code, childSelector, childNodes, index) {
370
+ const boundary = childNodes[index]
371
+
372
+ switch (boundary.type) {
373
+ case "Combinator": {
374
+ const next = childNodes[index + 1]
375
+
376
+ switch (next === undefined) {
377
+ case true:
352
378
  return null
353
379
  default:
354
- return remainder
380
+ return describeCombinatorRemainder(code, childSelector, boundary, next)
355
381
  }
356
382
  }
383
+ default:
384
+ return {
385
+ kind: "compound",
386
+ text: code.slice(boundary.loc.start.offset, childSelector.loc.end.offset),
387
+ }
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Match a parent selector against the start of a child selector.
393
+ *
394
+ * @param {string} code - CSS source text.
395
+ * @param {import("css-tree").CssNode} parentSelector - Parent selector.
396
+ * @param {import("css-tree").CssNode} childSelector - Child selector.
397
+ * @returns {{kind: string, text: string} | null} Remainder, or null without a match.
398
+ */
399
+ function matchSelectorPrefix(code, parentSelector, childSelector) {
400
+ const parentNodes = [...parentSelector.children]
401
+ const childNodes = [...childSelector.children]
402
+
403
+ switch (true) {
404
+ case parentNodes.length >= childNodes.length:
405
+ return null
406
+ case !parentNodes.every((node, index) =>
407
+ isEqualSelectorNode(node, childNodes[index]),
408
+ ):
409
+ return null
410
+ default:
411
+ return describeSelectorRemainder(
412
+ code,
413
+ childSelector,
414
+ childNodes,
415
+ parentNodes.length,
416
+ )
357
417
  }
358
418
  }
359
419
 
360
420
  /**
361
421
  * Render the remainder of a child selector as a nested selector.
362
422
  *
363
- * @param {string} remainder - Child selector text after the parent selector.
423
+ * @param {{kind: string, text: string}} remainder - Child selector remainder.
364
424
  * @returns {string} Nested selector.
365
425
  */
366
426
  function renderNestedSelector(remainder) {
367
- const text = remainder.trim()
427
+ switch (remainder.kind) {
428
+ case "descendant":
429
+ return remainder.text
430
+ case "relative":
431
+ return `& ${remainder.text}`
432
+ default:
433
+ return `&${remainder.text}`
434
+ }
435
+ }
368
436
 
437
+ /**
438
+ * Count the backslashes that end a string.
439
+ *
440
+ * @param {string} text - CSS source text.
441
+ * @returns {number} Number of trailing backslashes.
442
+ */
443
+ function countTrailingBackslashes(text) {
444
+ switch (text.endsWith("\\")) {
445
+ case true:
446
+ return countTrailingBackslashes(text.slice(0, -1)) + 1
447
+ default:
448
+ return 0
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Check whether a character is escaped by a preceding backslash.
454
+ *
455
+ * @param {string} text - CSS source text.
456
+ * @param {number} index - Index of the character to inspect.
457
+ * @returns {boolean} True when the character is escaped.
458
+ */
459
+ function isEscapedCharacter(text, index) {
460
+ return countTrailingBackslashes(text.slice(0, index)) % 2 === 1
461
+ }
462
+
463
+ /**
464
+ * Remove trailing whitespace that is not part of an escape.
465
+ *
466
+ * @param {string} text - Declaration source text.
467
+ * @returns {string} Text without insignificant trailing whitespace.
468
+ */
469
+ function trimDeclarationText(text) {
369
470
  switch (true) {
370
- case relativeCombinatorPattern.test(text):
371
- return `& ${text}`
372
- case leadingWhitespacePattern.test(remainder):
471
+ case !cssWhitespaceCharacters.has(text.at(-1)):
472
+ return text
473
+ case isEscapedCharacter(text, text.length - 1):
373
474
  return text
374
475
  default:
375
- return `&${text}`
476
+ return trimDeclarationText(text.slice(0, -1))
376
477
  }
377
478
  }
378
479
 
@@ -397,13 +498,15 @@ function findNestedPrelude(code, parent, child, comments) {
397
498
  break
398
499
  }
399
500
 
400
- const parentSelectors = extractSelectorTexts(code, parent)
401
- const childSelectors = extractSelectorTexts(code, child)
501
+ const parentSelectors = [...parent.prelude.children]
502
+ const childSelectors = [...child.prelude.children]
402
503
 
403
504
  switch (true) {
404
- case new Set(parentSelectors).size !== parentSelectors.length:
505
+ case new Set(parentSelectors.map((selector) => generate(selector))).size !==
506
+ parentSelectors.length:
405
507
  return null
406
- case new Set(childSelectors).size !== childSelectors.length:
508
+ case new Set(childSelectors.map((selector) => generate(selector))).size !==
509
+ childSelectors.length:
407
510
  return null
408
511
  default:
409
512
  break
@@ -423,11 +526,10 @@ function findNestedPrelude(code, parent, child, comments) {
423
526
  }
424
527
 
425
528
  const matches = childSelectors.map((selector) => ({
426
- selector,
427
529
  parents: parentSelectors
428
530
  .map((parentSelector) => ({
429
531
  parentSelector,
430
- remainder: matchSelectorPrefix(parentSelector, selector),
532
+ remainder: matchSelectorPrefix(code, parentSelector, selector),
431
533
  }))
432
534
  .filter(({ remainder }) => remainder !== null),
433
535
  }))
@@ -443,7 +545,7 @@ function findNestedPrelude(code, parent, child, comments) {
443
545
  parentSelector,
444
546
  remainders: matches
445
547
  .filter(({ parents }) => parents[0].parentSelector === parentSelector)
446
- .map(({ parents }) => parents[0].remainder),
548
+ .map(({ parents }) => renderNestedSelector(parents[0].remainder)),
447
549
  }))
448
550
  const remainders = suffixes[0].remainders
449
551
 
@@ -451,13 +553,11 @@ function findNestedPrelude(code, parent, child, comments) {
451
553
  case suffixes.some(
452
554
  ({ remainders: candidate }) =>
453
555
  candidate.length !== remainders.length ||
454
- candidate.some(
455
- (remainder, index) => remainder.trim() !== remainders[index].trim(),
456
- ),
556
+ candidate.some((remainder, index) => remainder !== remainders[index]),
457
557
  ):
458
558
  return null
459
559
  default:
460
- return remainders.map(renderNestedSelector).join(", ")
560
+ return remainders.join(", ")
461
561
  }
462
562
  }
463
563
 
@@ -575,7 +675,10 @@ class NestingModernizer {
575
675
  case true: {
576
676
  const group = {
577
677
  node: child,
578
- prelude: extractSelectorTexts(this.#code, child).join(", "),
678
+ prelude: this.#code.slice(
679
+ child.prelude.loc.start.offset,
680
+ child.prelude.loc.end.offset,
681
+ ),
579
682
  children: [],
580
683
  leadingComments: [],
581
684
  }
@@ -763,9 +866,9 @@ class NestingModernizer {
763
866
  * @returns {string} Declaration text.
764
867
  */
765
868
  #declarationText(declaration) {
766
- return `${this.#code
767
- .slice(declaration.loc.start.offset, declaration.loc.end.offset)
768
- .trim()};`
869
+ return `${trimDeclarationText(
870
+ this.#code.slice(declaration.loc.start.offset, declaration.loc.end.offset),
871
+ )};`
769
872
  }
770
873
 
771
874
  /**
@@ -0,0 +1,319 @@
1
+ import assert from "node:assert/strict"
2
+ import { after, before, describe, test } from "node:test"
3
+ import { chromium, firefox, webkit } from "playwright"
4
+ import { Builder, By } from "selenium-webdriver"
5
+ import { transform } from "../src/index.js"
6
+
7
+ /**
8
+ * @typedef {Object} ComputedStyleFixture
9
+ * @property {string} name - Scenario in imperative mood.
10
+ * @property {string} html - Markup rendered with the original and transformed CSS.
11
+ * @property {string} css - CSS source that the transformation rewrites.
12
+ * @property {string[]} [hover] - Selectors to hover before reading the computed styles.
13
+ */
14
+
15
+ /**
16
+ * @typedef {Object} ComputedElement
17
+ * @property {string} tagName - Lowercase tag name.
18
+ * @property {string} id - Element id, or an empty string.
19
+ * @property {string[]} classes - Class names.
20
+ * @property {Record<string, string>} styles - Computed value per pseudo-element and property.
21
+ */
22
+
23
+ /**
24
+ * @typedef {Object} BrowserSession
25
+ * @property {function({html: string, hover?: string[]}, string): Promise<Record<string, string>>} readStyles - Render a stylesheet and read the computed styles.
26
+ * @property {function(): Promise<void>} close - Stop the browser.
27
+ */
28
+
29
+ /**
30
+ * Playwright engines that must show the same styles before and after a transformation.
31
+ */
32
+ const playwrightEngines = { chromium, firefox, webkit }
33
+
34
+ /**
35
+ * Baselines whose transformations must preserve the computed styles.
36
+ */
37
+ const baselines = ["widely-available", "newly-available"]
38
+
39
+ /**
40
+ * Read every computed property of every element and its pseudo-elements.
41
+ *
42
+ * Runs in the browser, so it must not reference the module scope.
43
+ *
44
+ * @returns {ComputedElement[]} Computed styles in document order.
45
+ */
46
+ function collectComputedStyles() {
47
+ const pseudoElements = [null, "::before", "::after", "::marker"]
48
+
49
+ return [...document.querySelectorAll("*")].map((element) => ({
50
+ tagName: element.tagName.toLowerCase(),
51
+ id: element.id,
52
+ classes: [...element.classList],
53
+ styles: Object.fromEntries(
54
+ pseudoElements.flatMap((pseudoElement) => {
55
+ const computed = getComputedStyle(element, pseudoElement)
56
+
57
+ return [...computed].map((property) => [
58
+ `${pseudoElement ?? "self"} ${property}`,
59
+ computed.getPropertyValue(property),
60
+ ])
61
+ }),
62
+ ),
63
+ }))
64
+ }
65
+
66
+ /**
67
+ * Format an element for a difference message.
68
+ *
69
+ * @param {ComputedElement} element - Computed element.
70
+ * @param {number} index - Position in document order.
71
+ * @returns {string} Element name with id and classes.
72
+ */
73
+ function formatElement({ tagName, id, classes }, index) {
74
+ return `${index} ${tagName}${id === "" ? "" : `#${id}`}${classes
75
+ .map((name) => `.${name}`)
76
+ .join("")}`
77
+ }
78
+
79
+ /**
80
+ * Flatten the computed styles of all elements into one record.
81
+ *
82
+ * @param {ComputedElement[]} elements - Computed styles in document order.
83
+ * @returns {Record<string, string>} Computed value per element, pseudo-element, and property.
84
+ */
85
+ function flattenStyles(elements) {
86
+ return Object.fromEntries(
87
+ elements.flatMap((element, index) =>
88
+ Object.entries(element.styles).map(([key, value]) => [
89
+ `${formatElement(element, index)} ${key}`,
90
+ value,
91
+ ]),
92
+ ),
93
+ )
94
+ }
95
+
96
+ /**
97
+ * List the computed styles that differ between two snapshots.
98
+ *
99
+ * @param {Record<string, string>} before - Styles of the original source.
100
+ * @param {Record<string, string>} after - Styles of the transformed source.
101
+ * @returns {string[]} One entry per changed property.
102
+ */
103
+ function calculateDifferences(before, after) {
104
+ return [...new Set([...Object.keys(before), ...Object.keys(after)])]
105
+ .filter((key) => before[key] !== after[key])
106
+ .sort()
107
+ .map((key) => `${key}: ${before[key]} -> ${after[key]}`)
108
+ }
109
+
110
+ /**
111
+ * Replace the document with markup.
112
+ *
113
+ * Runs in the browser, so it must not reference the module scope.
114
+ *
115
+ * @param {string} markup - Document markup.
116
+ * @returns {void}
117
+ */
118
+ function writeDocument(markup) {
119
+ document.open()
120
+ document.write(markup)
121
+ document.close()
122
+ }
123
+
124
+ /**
125
+ * Drives a Playwright browser.
126
+ */
127
+ class PlaywrightSession {
128
+ #browser
129
+
130
+ /**
131
+ * @param {import("playwright").Browser} browser - Launched browser.
132
+ */
133
+ constructor(browser) {
134
+ this.#browser = browser
135
+ }
136
+
137
+ /**
138
+ * Launch a Playwright browser engine.
139
+ *
140
+ * @param {import("playwright").BrowserType} engine - Browser engine.
141
+ * @returns {Promise<PlaywrightSession>} Launched session.
142
+ */
143
+ static async launch(engine) {
144
+ return new PlaywrightSession(await engine.launch())
145
+ }
146
+
147
+ /**
148
+ * Render markup with a stylesheet and read the computed styles.
149
+ *
150
+ * @param {{html: string, hover?: string[]}} fixture - Markup and selectors to hover.
151
+ * @param {string} css - Stylesheet to apply.
152
+ * @returns {Promise<Record<string, string>>} Computed styles of all elements.
153
+ */
154
+ async readStyles(fixture, css) {
155
+ const page = await this.#browser.newPage()
156
+
157
+ try {
158
+ await page.setContent(`<!doctype html><style>${css}</style>${fixture.html}`)
159
+
160
+ for (const selector of fixture.hover ?? []) {
161
+ await page.hover(selector)
162
+ }
163
+
164
+ return flattenStyles(await page.evaluate(collectComputedStyles))
165
+ } finally {
166
+ await page.close()
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Stop the browser.
172
+ *
173
+ * @returns {Promise<void>} Resolves when the browser stopped.
174
+ */
175
+ async close() {
176
+ await this.#browser.close()
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Drives Safari through SafariDriver.
182
+ */
183
+ class SafariSession {
184
+ #driver
185
+
186
+ /**
187
+ * @param {import("selenium-webdriver").WebDriver} driver - SafariDriver session.
188
+ */
189
+ constructor(driver) {
190
+ this.#driver = driver
191
+ }
192
+
193
+ /**
194
+ * Launch Safari.
195
+ *
196
+ * @returns {Promise<SafariSession>} Launched session.
197
+ */
198
+ static async launch() {
199
+ return new SafariSession(await new Builder().forBrowser("safari").build())
200
+ }
201
+
202
+ /**
203
+ * Render markup with a stylesheet and read the computed styles.
204
+ *
205
+ * @param {{html: string, hover?: string[]}} fixture - Markup and selectors to hover.
206
+ * @param {string} css - Stylesheet to apply.
207
+ * @returns {Promise<Record<string, string>>} Computed styles of all elements.
208
+ */
209
+ async readStyles(fixture, css) {
210
+ await this.#driver.get("about:blank")
211
+ await this.#driver.actions().move({ x: 0, y: 0 }).perform()
212
+ await this.#driver.executeScript(
213
+ writeDocument,
214
+ `<!doctype html><style>${css}</style>${fixture.html}`,
215
+ )
216
+
217
+ for (const selector of fixture.hover ?? []) {
218
+ const element = await this.#driver.findElement(By.css(selector))
219
+
220
+ await this.#driver.actions().move({ origin: element }).perform()
221
+ }
222
+
223
+ return flattenStyles(await this.#driver.executeScript(collectComputedStyles))
224
+ }
225
+
226
+ /**
227
+ * Stop Safari.
228
+ *
229
+ * @returns {Promise<void>} Resolves when Safari stopped.
230
+ */
231
+ async close() {
232
+ await this.#driver.quit()
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Verify that a fixture keeps its computed styles after the transformation.
238
+ *
239
+ * @param {BrowserSession} session - Browser session.
240
+ * @param {ComputedStyleFixture} fixture - Fixture to transform.
241
+ * @returns {Promise<void>} Resolves when the styles match.
242
+ */
243
+ async function verifyComputedStyles(session, fixture) {
244
+ const transformed = baselines
245
+ .map((baseline) => ({ baseline, ...transform(fixture.css, baseline) }))
246
+ .filter(({ modified }) => modified)
247
+
248
+ assert(transformed.length > 0, "must transform the fixture")
249
+
250
+ const before = await session.readStyles(fixture, fixture.css)
251
+
252
+ for (const { baseline, code } of transformed) {
253
+ const after = await session.readStyles(fixture, code)
254
+
255
+ assert.deepEqual(
256
+ calculateDifferences(before, after),
257
+ [],
258
+ `differs with the ${baseline} baseline`,
259
+ )
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Register computed style tests for one browser.
265
+ *
266
+ * @param {string} name - Browser name.
267
+ * @param {function(): Promise<BrowserSession>} launch - Starts the browser.
268
+ * @param {ComputedStyleFixture[]} fixtures - Fixtures to test.
269
+ * @returns {void}
270
+ */
271
+ function describeBrowser(name, launch, fixtures) {
272
+ describe(name, () => {
273
+ let session
274
+
275
+ before(async () => {
276
+ session = await launch()
277
+ })
278
+
279
+ after(async () => {
280
+ await session.close()
281
+ })
282
+
283
+ for (const fixture of fixtures) {
284
+ test(fixture.name, async () => {
285
+ await verifyComputedStyles(session, fixture)
286
+ })
287
+ }
288
+
289
+ test("detect a changed declaration", async () => {
290
+ const fixture = { html: `<div class="box">x</div>` }
291
+ const before = await session.readStyles(fixture, ".box { color: red }")
292
+ const after = await session.readStyles(fixture, ".box { color: blue }")
293
+
294
+ assert.notDeepEqual(calculateDifferences(before, after), [])
295
+ })
296
+ })
297
+ }
298
+
299
+ /**
300
+ * Compare the computed styles of every fixture before and after the transformation.
301
+ *
302
+ * Call inside the `describe` block of a transformer. The tests run in Chromium,
303
+ * Firefox, WebKit, and Safari for every baseline that changes the fixture source.
304
+ * Safari runs only when `SAFARI=1` is set.
305
+ *
306
+ * @param {ComputedStyleFixture[]} fixtures - Stylesheets and markup to test.
307
+ * @returns {void}
308
+ */
309
+ export function testComputedStyles(fixtures) {
310
+ describe("computed styles", () => {
311
+ for (const [engineName, engine] of Object.entries(playwrightEngines)) {
312
+ describeBrowser(engineName, () => PlaywrightSession.launch(engine), fixtures)
313
+ }
314
+
315
+ if (process.env.SAFARI === "1") {
316
+ describeBrowser("safari", () => SafariSession.launch(), fixtures)
317
+ }
318
+ })
319
+ }
@@ -1,6 +1,8 @@
1
1
  import assert from "node:assert/strict"
2
2
  import { describe, test } from "node:test"
3
3
  import { transform } from "../src/index.js"
4
+ import { testComputedStyles } from "./computedStyles.js"
5
+ import { cssNestingFixtures } from "./fixtures/cssNesting.js"
4
6
 
5
7
  /**
6
8
  * Assert that a transformation produces the expected source text.
@@ -510,6 +512,8 @@ describe("cssNesting", () => {
510
512
 
511
513
  assert.equal(transform(input).modified, false)
512
514
  })
515
+
516
+ testComputedStyles(cssNestingFixtures)
513
517
  })
514
518
 
515
519
  describe("cssNesting edge cases", () => {
@@ -819,6 +823,140 @@ describe("cssNesting host selectors", () => {
819
823
  }`,
820
824
  )
821
825
  })
826
+
827
+ test("leave :host with a different specificity unchanged", () => {
828
+ const input = `:host(.a), .b { color: red; }
829
+ :host(.a) .c, .b .c { color: blue; }`
830
+
831
+ assert.equal(transform(input).modified, false)
832
+ })
833
+
834
+ test("leave :host-context with a different specificity unchanged", () => {
835
+ const input = `:host-context(.a), .b { color: red; }
836
+ :host-context(.a) .c, .b .c { color: blue; }`
837
+
838
+ assert.equal(transform(input).modified, false)
839
+ })
840
+
841
+ test("leave :host with an unknown selector argument unchanged", () => {
842
+ const input = `:host(:foo(.a)) { color: red; }
843
+ :host(:foo(.a)) .c { color: blue; }`
844
+
845
+ assert.equal(transform(input).modified, false)
846
+ })
847
+ })
848
+
849
+ describe("cssNesting escaped selectors", () => {
850
+ test("leave a hex escape that consumes whitespace unchanged", () => {
851
+ const input = String.raw`.\31 { color: red; }
852
+ .\31 23 { color: blue; }`
853
+
854
+ assert.equal(transform(input).modified, false)
855
+ })
856
+
857
+ test("leave a class name that continues a hex escape unchanged", () => {
858
+ const input = String.raw`.m-1\31 { color: red; }
859
+ .m-1\31 0 { color: blue; }`
860
+
861
+ assert.equal(transform(input).modified, false)
862
+ })
863
+
864
+ test("nest a compound selector with an escaped space", () => {
865
+ assertTransform(
866
+ String.raw`.a\ { color: red; }
867
+ .a\ .b { color: blue; }`,
868
+ String.raw`.a\ {
869
+ color: red;
870
+ &.b {
871
+ color: blue;
872
+ }
873
+ }`,
874
+ )
875
+ })
876
+
877
+ test("nest a parent list with escaped spaces", () => {
878
+ assertTransform(
879
+ String.raw`.a\ , .b { color: red; }
880
+ .a\ .c, .b .c { color: blue; }`,
881
+ String.raw`.a\ , .b {
882
+ color: red;
883
+ .c {
884
+ color: blue;
885
+ }
886
+ }`,
887
+ )
888
+ })
889
+
890
+ test("nest a parent with a non-breaking space in its selector", () => {
891
+ assertTransform(
892
+ ".a\u00A0 { color: red; }\n.a\u00A0 .b { color: blue; }",
893
+ ".a\u00A0 {\n color: red;\n .b {\n color: blue;\n }\n}",
894
+ )
895
+ })
896
+
897
+ test("leave a child joined by a non-breaking space unchanged", () => {
898
+ const input = ".a { color: red; }\n.a\u00A0.b { color: blue; }"
899
+
900
+ assert.equal(transform(input).modified, false)
901
+ })
902
+ })
903
+
904
+ describe("cssNesting escaped declarations", () => {
905
+ test("preserve a declaration with a trailing escaped space", () => {
906
+ assertTransform(
907
+ String.raw`.a { --x: \ }
908
+ .a .b { color: blue; }`,
909
+ String.raw`.a {
910
+ --x: \ ;
911
+ .b {
912
+ color: blue;
913
+ }
914
+ }`,
915
+ )
916
+ })
917
+
918
+ test("preserve declarations after a trailing escaped space", () => {
919
+ assertTransform(
920
+ String.raw`.a { --x: \ ; color: red; }
921
+ .a .b { color: blue; }`,
922
+ String.raw`.a {
923
+ --x: \ ;
924
+ color: red;
925
+ .b {
926
+ color: blue;
927
+ }
928
+ }`,
929
+ )
930
+ })
931
+
932
+ test("trim whitespace after an escaped backslash", () => {
933
+ assertTransform(
934
+ String.raw`.a { --x: \\ }
935
+ .a .b { color: blue; }`,
936
+ String.raw`.a {
937
+ --x: \\;
938
+ .b {
939
+ color: blue;
940
+ }
941
+ }`,
942
+ )
943
+ })
944
+ })
945
+
946
+ describe("cssNesting selector boundaries", () => {
947
+ test("leave a child with a trailing combinator unchanged", () => {
948
+ const input = `.a { color: red; }
949
+ .a > { color: blue; }`
950
+
951
+ assert.equal(transform(input).modified, false)
952
+ })
953
+
954
+ test("leave a shorter child selector unchanged", () => {
955
+ const input = `.a .b { color: red; }
956
+ .a { color: blue; }`
957
+
958
+ assert.equal(transform(input).modified, false)
959
+ })
822
960
  })
823
961
 
824
962
  describe("cssNesting line endings", () => {
@@ -0,0 +1,109 @@
1
+ /**
2
+ * @typedef {import("../computedStyles.js").ComputedStyleFixture} ComputedStyleFixture
3
+ */
4
+
5
+ /**
6
+ * Stylesheets and matching markup for the cssNesting computed style tests.
7
+ *
8
+ * @type {ComputedStyleFixture[]}
9
+ */
10
+ export const cssNestingFixtures = [
11
+ {
12
+ name: "nest descendant and compound selectors",
13
+ html: `
14
+ <article class="card primary"><h2 class="title">A</h2></article>
15
+ <article class="card"><h2 class="title">B</h2></article>`,
16
+ css: `
17
+ .card {
18
+ color: red;
19
+ padding: 1rem;
20
+ }
21
+ .card .title {
22
+ color: blue;
23
+ font-weight: bold;
24
+ }
25
+ .card.primary {
26
+ border: 1px solid green;
27
+ }
28
+ .card.primary .title {
29
+ text-decoration: underline;
30
+ }`,
31
+ },
32
+ {
33
+ name: "nest combinators",
34
+ html: `
35
+ <div class="card"><span class="title">A</span><span class="subtitle">B</span></div>
36
+ <div class="card"><span class="title">C</span><span class="subtitle">D</span></div>
37
+ <div class="card"><span class="title">E</span><span class="subtitle">F</span></div>`,
38
+ css: `
39
+ .card { color: red }
40
+ .card > .title { color: blue }
41
+ .card + .card { margin-top: 1rem }
42
+ .card ~ .card { opacity: 0.5 }
43
+ .card > .title + .subtitle { color: green }`,
44
+ },
45
+ {
46
+ name: "nest selectors of equal specificity",
47
+ html: `
48
+ <div class="a"><span class="child">A</span></div>
49
+ <div class="b"><span class="child">B</span></div>
50
+ <ul class="list"><li>1</li><li class="skip">2</li><li>3</li></ul>`,
51
+ css: `
52
+ .a, .b { color: red }
53
+ .a .child, .b .child { color: blue }
54
+ .a .child:hover, .b .child:hover { color: green }
55
+ .list li { color: purple }
56
+ .list li:nth-child(2n + 1) { color: orange }
57
+ .list li:not(.skip) { text-decoration: underline }
58
+ .list li:where(.skip) { opacity: 0.5 }`,
59
+ },
60
+ {
61
+ name: "nest pseudo-classes on functional selectors",
62
+ html: `
63
+ <div class="alpha"><span class="content">A</span></div>
64
+ <div class="beta"><span class="content">B</span></div>`,
65
+ css: `
66
+ :is(.alpha, .beta) .content { color: red }
67
+ :is(.alpha, .beta) .content:hover { color: blue }
68
+ :not(.alpha) .content { font-style: italic }`,
69
+ },
70
+ {
71
+ name: "nest pseudo-elements",
72
+ html: `<p class="item">text <strong>bold</strong></p>`,
73
+ css: `
74
+ .item { color: red }
75
+ .item::before { content: "before"; color: blue }
76
+ .item::after { content: "after"; color: green }
77
+ .item strong { color: purple }`,
78
+ },
79
+ {
80
+ name: "nest rules inside at-rules",
81
+ html: `
82
+ <div class="card"><h2 class="title">A <span>B</span></h2></div>
83
+ <div class="grid"><div>C</div></div>
84
+ <div class="layered">D <span>E</span></div>`,
85
+ css: `
86
+ @media (min-width: 1px) {
87
+ .card .title { color: blue }
88
+ .card .title span { color: pink }
89
+ }
90
+ @supports (display: grid) {
91
+ .grid { display: grid }
92
+ .grid > * { display: block }
93
+ }
94
+ @layer base {
95
+ .layered { color: teal }
96
+ .layered span { color: maroon }
97
+ }`,
98
+ },
99
+ {
100
+ name: "nest interactive states",
101
+ html: `<button class="button"><span class="label">A</span></button>`,
102
+ hover: [".button"],
103
+ css: `
104
+ .button { color: red }
105
+ .button:hover { color: green }
106
+ .button .label { color: blue }
107
+ .button:hover .label { color: purple }`,
108
+ },
109
+ ]
@@ -1,14 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <module type="PYTHON_MODULE" version="4">
3
- <component name="NewModuleRootManager">
4
- <content url="file://$MODULE_DIR$">
5
- <sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
6
- </content>
7
- <orderEntry type="inheritedJdk" />
8
- <orderEntry type="sourceFolder" forTests="false" />
9
- </component>
10
- <component name="PackageRequirementsSettings" />
11
- <component name="PyDocumentationSettings" />
12
- <component name="ReSTService" />
13
- <component name="TestRunnerService" />
14
- </module>
@@ -1,28 +0,0 @@
1
- <component name="InspectionProjectProfileManager">
2
- <profile version="1.0">
3
- <option name="myName" value="Project Default" />
4
- <inspection_tool class="DjangoUnresolvedUrlInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
5
- <inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
6
- <inspection_tool class="JsonSchemaCompliance" enabled="false" level="WARNING" enabled_by_default="false" />
7
- <inspection_tool class="PyCompatibilityInspection" enabled="true" level="WARNING" enabled_by_default="true">
8
- <option name="ourVersions">
9
- <value>
10
- <list size="2">
11
- <item index="0" class="java.lang.String" itemvalue="3.14" />
12
- <item index="1" class="java.lang.String" itemvalue="3.15" />
13
- </list>
14
- </value>
15
- </option>
16
- </inspection_tool>
17
- <inspection_tool class="PyUnresolvedReferencesInspection" enabled="true" level="WARNING" enabled_by_default="true">
18
- <option name="ignoredIdentifiers">
19
- <list>
20
- <option value="ssdp.lexers.content_callback" />
21
- </list>
22
- </option>
23
- </inspection_tool>
24
- <inspection_tool class="StandardJS" enabled="true" level="ERROR" enabled_by_default="true" />
25
- <inspection_tool class="Stylelint" enabled="true" level="ERROR" enabled_by_default="true" />
26
- <inspection_tool class="TsLint" enabled="true" level="WARNING" enabled_by_default="true" />
27
- </profile>
28
- </component>
@@ -1,6 +0,0 @@
1
- <component name="InspectionProjectProfileManager">
2
- <settings>
3
- <option name="USE_PROJECT_PROFILE" value="false" />
4
- <version value="1.0" />
5
- </settings>
6
- </component>
package/.idea/misc.xml DELETED
@@ -1,4 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <project version="4">
3
- <component name="KubernetesApiProvider">{}</component>
4
- </project>
package/.idea/modules.xml DELETED
@@ -1,8 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <project version="4">
3
- <component name="ProjectModuleManager">
4
- <modules>
5
- <module fileurl="file://$PROJECT_DIR$/.idea/cssupgrade.iml" filepath="$PROJECT_DIR$/.idea/cssupgrade.iml" />
6
- </modules>
7
- </component>
8
- </project>
package/.idea/vcs.xml DELETED
@@ -1,6 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <project version="4">
3
- <component name="VcsDirectoryMappings">
4
- <mapping directory="" vcs="Git" />
5
- </component>
6
- </project>