feat: update css formatter class to add matched rules (#2713)

Updates CssFormatter class to support matched CSS rules

It also adds formatting for ancestor rules, including `@media`,
`@supports`, `@layer`, `@container`, `@scope`, `@starting-style`, and
nested styles

Co-authored-by: Samiya Caur <samiyac@chromium.org>
This commit is contained in:
Samiya Caur
2026-09-10 13:17:39 +00:00
committed by GitHub
co-authored by Samiya Caur
parent aa255629f2
commit 8c8616fcc4
4 changed files with 1349 additions and 7 deletions
+380 -6
View File
@@ -7,8 +7,21 @@
import {DevTools} from '../third_party/index.js';
import type {MatchedStyles} from '../tools/ToolDefinition.js';
export type UidResolver = (backendNodeId: number) => string | undefined;
export type ContainerQuery =
DevTools.CSSRule.CSSStyleRule['containerQueries'][number];
export interface ResolvedContainerDetails {
container?: {
uid?: string;
selector: string;
};
}
export interface CssFormatterOptions {
uid: string;
resolveUid?: UidResolver;
containerDetails?: Map<ContainerQuery, ResolvedContainerDetails>;
}
/**
@@ -21,6 +34,41 @@ export interface CssFormatterOptions {
export type CssPropertyStatus =
'active' | 'overloaded' | 'invalid' | 'disabled';
export type AncestorCSSRule =
| {
type: 'layer';
name?: string;
}
| {
type: 'media' | 'supports' | 'navigation';
query: string;
}
| {
type: 'scope';
query?: string;
}
| {
type: 'container';
query: string;
name?: string;
container?: {
uid?: string;
selector: string;
};
}
| {
type: 'starting-style';
}
| {
type: 'nesting';
selector: string;
}
| {
type: 'at-rule';
atRuleType: string;
name?: string;
};
export interface StructuredCssProperty {
name: string;
value: string;
@@ -41,7 +89,17 @@ export interface AnimationRule {
properties: StructuredCssProperty[];
}
export type CascadeRule = NodeStyleRule | AnimationRule;
export interface MatchedRule {
type: 'matched';
selector: string;
matchingSelectors?: string[];
source?: string;
isUserAgent?: boolean;
ancestors?: AncestorCSSRule[];
properties: StructuredCssProperty[];
}
export type CascadeRule = NodeStyleRule | AnimationRule | MatchedRule;
export interface StructuredCssStyles {
element: {
@@ -89,6 +147,224 @@ class IndentedWriter {
}
}
function getFilenameFromUrl(sheetUrl: string): string {
const parsed = new DevTools.Common.ParsedURL.ParsedURL(sheetUrl);
if (parsed.isDataURL()) {
return 'data-uri';
}
if (parsed.isBlobURL()) {
return 'blob';
}
if (parsed.lastPathComponent) {
return parsed.lastPathComponent;
}
return 'index';
}
/**
* Resolves the human-readable source location
*
* Checks in precedence order:
* 1. Special origins: 'user agent stylesheet', 'injected stylesheet', 'via inspector', 'constructed stylesheet'.
* 2. 1-based line coordinates from rule header or style range.
* 3. File source: `<style>` for inline sheets, `(index)` for root documents, or filename for external stylesheets.
*/
function getSourceLocation(rule: DevTools.CSSRule.CSSRule): string {
if (rule.isUserAgent?.()) {
return 'user agent stylesheet';
}
if (rule.isInjected?.()) {
return 'injected stylesheet';
}
if (rule.isViaInspector?.()) {
return 'via inspector';
}
if (rule.header?.isConstructedByNew?.() && !rule.sourceURL) {
return 'constructed stylesheet';
}
let locSuffix = '';
if (rule instanceof DevTools.CSSRule.CSSStyleRule) {
const lineNum = rule.lineNumberInSource(0);
if (lineNum >= 0) {
locSuffix = `:${lineNum + 1}`;
}
} else if (rule.header && rule.style?.range) {
locSuffix = `:${rule.header.lineNumberInSource(rule.style.range.startLine) + 1}`;
}
const sheetUrl = rule.sourceURL;
if (!sheetUrl) {
return `<style>${locSuffix}`;
}
return `${getFilenameFromUrl(sheetUrl)}${locSuffix}`;
}
/**
* Returns the subset of selectors in a comma-separated selector group that
* actually matched the target element. Returns undefined if single selector
* or no filter is available.
*/
function getMatchingSelectors(
matchedStyles: MatchedStyles,
rule: DevTools.CSSRule.CSSStyleRule,
): string[] | undefined {
if (!rule.selectors || rule.selectors.length <= 1) {
return undefined;
}
const indexes = matchedStyles.getMatchingSelectors?.(rule);
if (!indexes || indexes.length === 0) {
return undefined;
}
const indexSet = new Set(indexes);
const matching: string[] = [];
for (const [idx, sel] of rule.selectors.entries()) {
if (indexSet.has(idx)) {
matching.push(sel.text);
}
}
return matching.length > 0 ? matching : undefined;
}
function createLayerAncestor(layer?: {
text?: string;
}): AncestorCSSRule | undefined {
if (!layer) {
return undefined;
}
return {type: 'layer', ...(layer.text ? {name: layer.text} : {})};
}
function createQueryAncestor(
type: 'media' | 'scope' | 'supports' | 'navigation',
query?: {text?: string},
): AncestorCSSRule | undefined {
if (!query) {
return undefined;
}
if (type === 'scope') {
return {type, ...(query.text ? {query: query.text} : {})};
}
return query.text ? {type, query: query.text} : undefined;
}
function createContainerQueryAncestor(
containerQuery?: ContainerQuery,
containerDetails?: Map<ContainerQuery, ResolvedContainerDetails>,
): AncestorCSSRule | undefined {
if (!containerQuery) {
return undefined;
}
const details = containerDetails?.get(containerQuery);
return {
type: 'container',
query: containerQuery.text ?? '',
...(containerQuery.name ? {name: containerQuery.name} : {}),
...(details?.container ? {container: details.container} : {}),
};
}
/**
* Collects enclosing ancestor rules (@media, @container, @supports, @layer,
* @scope, @starting-style, @navigation, and CSS nesting) for a style rule.
*/
function collectAncestorRules(
rule: DevTools.CSSRule.CSSStyleRule,
containerDetails?: Map<ContainerQuery, ResolvedContainerDetails>,
): AncestorCSSRule[] | undefined {
if (!rule.ruleTypes || rule.ruleTypes.length === 0) {
return undefined;
}
let mediaIndex = 0;
let containerIndex = 0;
let scopeIndex = 0;
let supportsIndex = 0;
let nestingIndex = 0;
let layerIndex = 0;
let navigationsIndex = 0;
const ancestors: AncestorCSSRule[] = [];
for (const ruleType of rule.ruleTypes) {
let item: AncestorCSSRule | undefined;
switch (ruleType) {
case DevTools.Protocol.CSS.CSSRuleType.MediaRule:
item = createQueryAncestor('media', rule.media?.[mediaIndex++]);
break;
case DevTools.Protocol.CSS.CSSRuleType.ContainerRule:
item = createContainerQueryAncestor(
rule.containerQueries?.[containerIndex++],
containerDetails,
);
break;
case DevTools.Protocol.CSS.CSSRuleType.LayerRule:
item = createLayerAncestor(rule.layers?.[layerIndex++]);
break;
case DevTools.Protocol.CSS.CSSRuleType.ScopeRule:
item = createQueryAncestor('scope', rule.scopes?.[scopeIndex++]);
break;
case DevTools.Protocol.CSS.CSSRuleType.SupportsRule:
item = createQueryAncestor(
'supports',
rule.supports?.[supportsIndex++],
);
break;
case DevTools.Protocol.CSS.CSSRuleType.StartingStyleRule:
item = {type: 'starting-style'};
break;
case DevTools.Protocol.CSS.CSSRuleType.StyleRule: {
const selector = rule.nestingSelectors?.[nestingIndex++];
if (selector) {
item = {type: 'nesting', selector};
}
break;
}
case DevTools.Protocol.CSS.CSSRuleType.NavigationRule:
item = createQueryAncestor(
'navigation',
rule.navigations?.[navigationsIndex++],
);
break;
}
if (item) {
ancestors.push(item);
}
}
ancestors.reverse();
return ancestors.length > 0 ? ancestors : undefined;
}
interface RuleMetadata {
ancestors?: AncestorCSSRule[];
matchingSelectors?: string[];
source?: string;
}
/**
* Extracts common metadata (`ancestors`, `matchingSelectors`, `source`)
* uniformly across matched rules, inherited rules, and pseudo-element rules.
*/
function getRuleMetadata(
rule: DevTools.CSSRule.CSSStyleRule | undefined,
matchedStyles: MatchedStyles,
containerDetails?: Map<ContainerQuery, ResolvedContainerDetails>,
): RuleMetadata {
if (!rule) {
return {};
}
const matchingSelectors = getMatchingSelectors(matchedStyles, rule);
const ancestors = collectAncestorRules(rule, containerDetails);
const source = getSourceLocation(rule);
return {
...(ancestors ? {ancestors} : {}),
...(matchingSelectors ? {matchingSelectors} : {}),
...(source ? {source} : {}),
};
}
/**
* Formats a CSS property into standard CSS syntax with optional status tags.
*
@@ -112,6 +388,7 @@ function getCascadeRuleHeader(rule: CascadeRule): string {
case 'transition':
case 'animation':
case 'attributes':
case 'matched':
selector = rule.selector;
break;
}
@@ -119,7 +396,70 @@ function getCascadeRuleHeader(rule: CascadeRule): string {
return source ? `${selector} (${source})` : selector;
}
function appendRule(writer: IndentedWriter, rule: CascadeRule): void {
function formatAncestorRuleHeader(ancestor: AncestorCSSRule): {
comment?: string;
header: string;
} {
switch (ancestor.type) {
case 'layer':
return {header: ancestor.name ? `@layer ${ancestor.name}` : '@layer'};
case 'media':
return {header: `@media ${ancestor.query}`};
case 'container': {
let comment: string | undefined;
if (ancestor.container?.selector) {
comment = ancestor.container.selector;
}
if (ancestor.container?.uid) {
const uidStr = `(uid: "${ancestor.container.uid}")`;
comment = comment ? `${comment} ${uidStr}` : uidStr;
}
if (comment) {
comment = `container: ${comment}`;
}
const shouldPrependName =
ancestor.name && !ancestor.query.startsWith(ancestor.name);
const nameStr = shouldPrependName ? `${ancestor.name} ` : '';
return {comment, header: `@container ${nameStr}${ancestor.query}`};
}
case 'scope': {
const queryStr = ancestor.query?.trim();
return {header: queryStr ? `@scope ${queryStr}` : '@scope'};
}
case 'supports':
return {header: `@supports ${ancestor.query}`};
case 'starting-style':
return {header: '@starting-style'};
case 'nesting':
return {header: ancestor.selector};
case 'navigation':
return {header: `@navigation ${ancestor.query}`};
case 'at-rule': {
const nameStr = ancestor.name ? ` ${ancestor.name}` : '';
return {header: `@${ancestor.atRuleType}${nameStr}`};
}
}
}
function appendRuleWithAncestors(
writer: IndentedWriter,
rule: CascadeRule,
): void {
const ancestors = 'ancestors' in rule ? rule.ancestors : undefined;
let ancestorCount = 0;
if (ancestors && ancestors.length > 0) {
for (const ancestor of ancestors) {
const {comment, header} = formatAncestorRuleHeader(ancestor);
if (comment) {
writer.writeComment(comment);
}
writer.writeLine(`${header} {`);
writer.indent();
ancestorCount++;
}
}
const header = getCascadeRuleHeader(rule);
writer.writeLine(`${header} {`);
writer.indent();
@@ -128,6 +468,11 @@ function appendRule(writer: IndentedWriter, rule: CascadeRule): void {
}
writer.dedent();
writer.writeLine('}');
for (let i = 0; i < ancestorCount; i++) {
writer.dedent();
writer.writeLine('}');
}
}
function appendCssSectionsToString(
@@ -136,7 +481,7 @@ function appendCssSectionsToString(
): void {
for (const rule of styles.rules) {
writer.writeEmptyLine();
appendRule(writer, rule);
appendRuleWithAncestors(writer, rule);
}
}
@@ -150,15 +495,19 @@ export class CssFormatter {
/**
* Aggregates all cascading rules impacting the target node.
*/
static collectRules(matchedStyles: MatchedStyles): CascadeRule[] {
static collectRules(
matchedStyles: MatchedStyles,
options: CssFormatterOptions,
): CascadeRule[] {
const rules: CascadeRule[] = [];
CssFormatter.#collectNodeStyles(rules, matchedStyles);
CssFormatter.#collectNodeStyles(rules, matchedStyles, options);
return rules;
}
static #collectNodeStyles(
rules: CascadeRule[],
matchedStyles: MatchedStyles,
options: CssFormatterOptions,
): void {
for (const style of matchedStyles.nodeStyles?.() ?? []) {
const properties = CssFormatter.#getStyleProperties(style);
@@ -194,10 +543,35 @@ export class CssFormatter {
selector: 'element.style',
properties: CssFormatter.#formatProperties(properties, matchedStyles),
});
} else if (style.parentRule instanceof DevTools.CSSRule.CSSStyleRule) {
rules.push(
CssFormatter.#createMatchedRule(
style.parentRule,
properties,
matchedStyles,
options,
),
);
}
}
}
static #createMatchedRule(
rule: DevTools.CSSRule.CSSStyleRule,
properties: DevTools.CSSProperty.CSSProperty[],
matchedStyles: MatchedStyles,
options: CssFormatterOptions,
): MatchedRule {
const meta = getRuleMetadata(rule, matchedStyles, options.containerDetails);
return {
type: 'matched',
selector: rule.selectorText(),
...meta,
...(rule.isUserAgent?.() ? {isUserAgent: true} : {}),
properties: CssFormatter.#formatProperties(properties, matchedStyles),
};
}
static #formatProperties(
props: DevTools.CSSProperty.CSSProperty[],
matchedStyles: MatchedStyles,
@@ -249,7 +623,7 @@ export class CssFormatter {
this.#matchedStyles = matchedStyles;
this.#options = options;
this.#cascadeRules =
cascadeRules ?? CssFormatter.collectRules(matchedStyles);
cascadeRules ?? CssFormatter.collectRules(matchedStyles, options);
}
get rules(): readonly CascadeRule[] {
@@ -1,3 +1,231 @@
exports[`CssFormatter > formats @layer, @media, @supports, and @starting-style ancestor rules toJSON 1`] = `
{
"element": {
"uid": "btn-1",
"selector": "button"
},
"rules": [
{
"type": "matched",
"selector": ".test-btn",
"ancestors": [
{
"type": "layer",
"name": "base"
},
{
"type": "media",
"query": "(min-width: 500px)"
},
{
"type": "supports",
"query": "(display: flex)"
},
{
"type": "starting-style"
}
],
"source": "<style>",
"properties": [
{
"name": "display",
"value": "flex",
"status": "active"
}
]
}
]
}
`;
exports[`CssFormatter > formats @layer, @media, @supports, and @starting-style ancestor rules toString 1`] = `
Styles for button (uid: "btn-1"):
@layer base {
@media (min-width: 500px) {
@supports (display: flex) {
@starting-style {
.test-btn (<style>) {
display: flex;
}
}
}
}
}
`;
exports[`CssFormatter > formats @navigation ancestor rule toJSON 1`] = `
{
"element": {
"uid": "elem-nav",
"selector": "button"
},
"rules": [
{
"type": "matched",
"selector": ".nav-link",
"ancestors": [
{
"type": "navigation",
"query": "same-document"
}
],
"source": "<style>",
"properties": [
{
"name": "color",
"value": "navy",
"status": "active"
}
]
}
]
}
`;
exports[`CssFormatter > formats @navigation ancestor rule toString 1`] = `
Styles for button (uid: "elem-nav"):
@navigation same-document {
.nav-link (<style>) {
color: navy;
}
}
`;
exports[`CssFormatter > formats @scope ancestor rule toJSON 1`] = `
{
"element": {
"uid": "item-1",
"selector": "button"
},
"rules": [
{
"type": "matched",
"selector": ".scoped-item",
"ancestors": [
{
"type": "scope",
"query": "(:root)"
}
],
"source": "<style>",
"properties": [
{
"name": "color",
"value": "blue",
"status": "active"
}
]
}
]
}
`;
exports[`CssFormatter > formats @scope ancestor rule toString 1`] = `
Styles for button (uid: "item-1"):
@scope (:root) {
.scoped-item (<style>) {
color: blue;
}
}
`;
exports[`CssFormatter > formats constructed stylesheets with and without sourceURL pragma toJSON 1`] = `
{
"element": {
"uid": "elem-constructed",
"selector": "button"
},
"rules": [
{
"type": "matched",
"selector": ".constructed-btn",
"source": "constructed stylesheet",
"properties": [
{
"name": "color",
"value": "purple",
"status": "active"
}
]
},
{
"type": "matched",
"selector": ".themed-btn",
"source": "theme.css:11",
"properties": [
{
"name": "color",
"value": "orange",
"status": "active"
}
]
}
]
}
`;
exports[`CssFormatter > formats constructed stylesheets with and without sourceURL pragma toString 1`] = `
Styles for button (uid: "elem-constructed"):
.constructed-btn (constructed stylesheet) {
color: purple;
}
.themed-btn (theme.css:11) {
color: orange;
}
`;
exports[`CssFormatter > formats data: and blob: stylesheet URLs correctly toJSON 1`] = `
{
"element": {
"uid": "data-elem",
"selector": "button"
},
"rules": [
{
"type": "matched",
"selector": ".data-rule",
"source": "data-uri",
"properties": [
{
"name": "color",
"value": "blue",
"status": "active"
}
]
},
{
"type": "matched",
"selector": ".blob-rule",
"source": "blob",
"properties": [
{
"name": "color",
"value": "green",
"status": "active"
}
]
}
]
}
`;
exports[`CssFormatter > formats data: and blob: stylesheet URLs correctly toString 1`] = `
Styles for button (uid: "data-elem"):
.data-rule (data-uri) {
color: blue;
}
.blob-rule (blob) {
color: green;
}
`;
exports[`CssFormatter > formats element label with id, class, and uid and no styles toJSON 1`] = `
{
"element": {
@@ -14,6 +242,37 @@ Styles for div#main (uid: "1_1"):
(no styles)
`;
exports[`CssFormatter > formats injected stylesheet rules toJSON 1`] = `
{
"element": {
"uid": "elem-injected",
"selector": "button"
},
"rules": [
{
"type": "matched",
"selector": ".extension-override",
"source": "injected stylesheet",
"properties": [
{
"name": "display",
"value": "none",
"status": "active"
}
]
}
]
}
`;
exports[`CssFormatter > formats injected stylesheet rules toString 1`] = `
Styles for button (uid: "elem-injected"):
.extension-override (injected stylesheet) {
display: none;
}
`;
exports[`CssFormatter > formats inline styles with active and overloaded properties toJSON 1`] = `
{
"element": {
@@ -51,6 +310,122 @@ Styles for button (uid: "1_2"):
}
`;
exports[`CssFormatter > formats inspector stylesheet rules toJSON 1`] = `
{
"element": {
"uid": "elem-inspector",
"selector": "button"
},
"rules": [
{
"type": "matched",
"selector": "#interactive-test",
"source": "via inspector",
"properties": [
{
"name": "outline",
"value": "2px solid red",
"status": "active"
}
]
}
]
}
`;
exports[`CssFormatter > formats inspector stylesheet rules toString 1`] = `
Styles for button (uid: "elem-inspector"):
#interactive-test (via inspector) {
outline: 2px solid red;
}
`;
exports[`CssFormatter > formats mixed inline and matched rules toJSON 1`] = `
{
"element": {
"uid": "1_1",
"selector": "button#btn-id"
},
"rules": [
{
"type": "inline",
"selector": "element.style",
"properties": [
{
"name": "color",
"value": "red",
"status": "active"
}
]
},
{
"type": "matched",
"selector": ".btn",
"source": "style.css:6",
"properties": [
{
"name": "font-size",
"value": "16px",
"status": "active"
}
]
}
]
}
`;
exports[`CssFormatter > formats mixed inline and matched rules toString 1`] = `
Styles for button#btn-id (uid: "1_1"):
element.style {
color: red;
}
.btn (style.css:6) {
font-size: 16px;
}
`;
exports[`CssFormatter > formats nested CSS rules with nesting ancestors toJSON 1`] = `
{
"element": {
"uid": "elem-child",
"selector": "button"
},
"rules": [
{
"type": "matched",
"selector": "& .child",
"ancestors": [
{
"type": "nesting",
"selector": ".card"
}
],
"source": "styles.css:16",
"properties": [
{
"name": "color",
"value": "blue",
"status": "active"
}
]
}
]
}
`;
exports[`CssFormatter > formats nested CSS rules with nesting ancestors toString 1`] = `
Styles for button (uid: "elem-child"):
.card {
& .child (styles.css:16) {
color: blue;
}
}
`;
exports[`CssFormatter > formats transition, animation, and attributes styles toJSON 1`] = `
{
"element": {
@@ -111,3 +486,184 @@ Styles for table#data (uid: "table-1"):
border: 1px;
}
`;
exports[`CssFormatter > maps invalid and disabled property statuses toJSON 1`] = `
{
"element": {
"uid": "elem-diag",
"selector": "button"
},
"rules": [
{
"type": "inline",
"selector": "element.style",
"properties": [
{
"name": "color",
"value": "red",
"status": "active"
},
{
"name": "background",
"value": "invalid-val",
"status": "invalid"
},
{
"name": "opacity",
"value": "0.5",
"status": "disabled"
}
]
}
]
}
`;
exports[`CssFormatter > maps invalid and disabled property statuses toString 1`] = `
Styles for button (uid: "elem-diag"):
element.style {
color: red;
[invalid] background: invalid-val;
[disabled] opacity: 0.5;
}
`;
exports[`CssFormatter > resolves container queries with node uid toJSON 1`] = `
{
"element": {
"uid": "elem-widget",
"selector": "button"
},
"rules": [
{
"type": "matched",
"selector": ".widget",
"ancestors": [
{
"type": "container",
"query": "(min-width: 300px)",
"name": "sidebar-cq",
"container": {
"uid": "uid-42",
"selector": "aside#sidebar"
}
}
],
"source": "<style>",
"properties": [
{
"name": "padding",
"value": "10px",
"status": "active"
}
]
}
]
}
`;
exports[`CssFormatter > resolves container queries with node uid toString 1`] = `
Styles for button (uid: "elem-widget"):
/* container: aside#sidebar (uid: "uid-42") */
@container sidebar-cq (min-width: 300px) {
.widget (<style>) {
padding: 10px;
}
}
`;
exports[`CssFormatter > rule subsets > matched rules all 3 rules toJSON 1`] = `
{
"element": {
"uid": "btn-1",
"selector": "button"
},
"rules": [
{
"type": "matched",
"selector": ".rule-1",
"source": "app.css:11",
"properties": [
{
"name": "color",
"value": "red",
"status": "active"
}
]
},
{
"type": "matched",
"selector": ".rule-2",
"source": "app.css:21",
"properties": [
{
"name": "color",
"value": "blue",
"status": "active"
}
]
},
{
"type": "matched",
"selector": ".rule-3",
"source": "app.css:31",
"properties": [
{
"name": "color",
"value": "green",
"status": "active"
}
]
}
]
}
`;
exports[`CssFormatter > rule subsets > matched rules all 3 rules toString 1`] = `
Styles for button (uid: "btn-1"):
.rule-1 (app.css:11) {
color: red;
}
.rule-2 (app.css:21) {
color: blue;
}
.rule-3 (app.css:31) {
color: green;
}
`;
exports[`CssFormatter > rule subsets > matched rules subset - last rule toJSON 1`] = `
{
"element": {
"uid": "btn-1",
"selector": "button"
},
"rules": [
{
"type": "matched",
"selector": ".rule-3",
"source": "app.css:31",
"properties": [
{
"name": "color",
"value": "green",
"status": "active"
}
]
}
]
}
`;
exports[`CssFormatter > rule subsets > matched rules subset - last rule toString 1`] = `
Styles for button (uid: "btn-1"):
.rule-3 (app.css:31) {
color: green;
}
`;
+289 -1
View File
@@ -7,7 +7,11 @@
import {afterEach, describe, it} from 'node:test';
import sinon from 'sinon';
import {CssFormatter} from '../../src/formatters/CssFormatter.js';
import {
type ContainerQuery,
CssFormatter,
type ResolvedContainerDetails,
} from '../../src/formatters/CssFormatter.js';
import {DevTools} from '../../src/third_party/index.js';
import {
createMockCSSInlineStyle,
@@ -15,6 +19,7 @@ import {
createMockCSSProperty,
createMockCSSStyleDeclaration,
createMockDOMNode,
createMockCSSStyleRule,
} from '../mocks.js';
describe('CssFormatter', () => {
@@ -87,4 +92,287 @@ describe('CssFormatter', () => {
return new CssFormatter(matchedStyles, {uid: 'table-1'});
});
describe('rule subsets', () => {
function createMatchedStylesForRuleSubsets() {
return createMockCSSMatchedStyles({
nodeStyles: [
createMockCSSStyleDeclaration(
[createMockCSSProperty('color', 'red')],
{
rule: createMockCSSStyleRule('.rule-1', {
sourceURL: 'app.css',
lineNumber: 10,
}),
},
),
createMockCSSStyleDeclaration(
[createMockCSSProperty('color', 'blue')],
{
rule: createMockCSSStyleRule('.rule-2', {
sourceURL: 'app.css',
lineNumber: 20,
}),
},
),
createMockCSSStyleDeclaration(
[createMockCSSProperty('color', 'green')],
{
rule: createMockCSSStyleRule('.rule-3', {
sourceURL: 'app.css',
lineNumber: 30,
}),
},
),
],
});
}
formatterTest('matched rules all 3 rules', () => {
return new CssFormatter(createMatchedStylesForRuleSubsets(), {
uid: 'btn-1',
});
});
formatterTest('matched rules subset - last rule', () => {
const matchedStyles = createMatchedStylesForRuleSubsets();
const fullFormatter = new CssFormatter(matchedStyles, {uid: 'btn-1'});
return new CssFormatter(
matchedStyles,
{uid: 'btn-1'},
fullFormatter.rules.slice(2, 3),
);
});
});
formatterTest('formats data: and blob: stylesheet URLs correctly', () => {
const matchedStyles = createMockCSSMatchedStyles({
nodeStyles: [
createMockCSSStyleDeclaration(
[createMockCSSProperty('color', 'blue')],
{
rule: createMockCSSStyleRule('.data-rule', {
sourceURL: 'data:text/css;base64,LmRhdGEte30=',
}),
},
),
createMockCSSStyleDeclaration(
[createMockCSSProperty('color', 'green')],
{
rule: createMockCSSStyleRule('.blob-rule', {
sourceURL: 'blob:http://example.com/1234-5678-90ab',
}),
},
),
],
});
return new CssFormatter(matchedStyles, {uid: 'data-elem'});
});
formatterTest('formats mixed inline and matched rules', () => {
const matchedStyles = createMockCSSMatchedStyles({
node: 'button#btn-id',
nodeStyles: [
createMockCSSInlineStyle([createMockCSSProperty('color', 'red')]),
createMockCSSStyleDeclaration(
[createMockCSSProperty('font-size', '16px')],
{
rule: createMockCSSStyleRule('.btn', {
sourceURL: 'style.css',
lineNumber: 5,
}),
},
),
],
});
return new CssFormatter(matchedStyles, {uid: '1_1'});
});
formatterTest('formats nested CSS rules with nesting ancestors', () => {
const matchedStyles = createMockCSSMatchedStyles({
nodeStyles: [
createMockCSSStyleDeclaration(
[createMockCSSProperty('color', 'blue')],
{
rule: createMockCSSStyleRule('& .child', {
sourceURL: 'styles.css',
lineNumber: 15,
columnNumber: 2,
nestingSelectors: ['.card'],
}),
},
),
],
});
return new CssFormatter(matchedStyles, {uid: 'elem-child'});
});
formatterTest(
'formats constructed stylesheets with and without sourceURL pragma',
() => {
const matchedStyles = createMockCSSMatchedStyles({
nodeStyles: [
createMockCSSStyleDeclaration(
[createMockCSSProperty('color', 'purple')],
{
rule: createMockCSSStyleRule('.constructed-btn', {
isConstructed: true,
}),
},
),
createMockCSSStyleDeclaration(
[createMockCSSProperty('color', 'orange')],
{
rule: createMockCSSStyleRule('.themed-btn', {
sourceURL: 'theme.css',
lineNumber: 10,
columnNumber: 5,
isConstructed: true,
}),
},
),
],
});
return new CssFormatter(matchedStyles, {uid: 'elem-constructed'});
},
);
formatterTest('formats injected stylesheet rules', () => {
const matchedStyles = createMockCSSMatchedStyles({
nodeStyles: [
createMockCSSStyleDeclaration(
[createMockCSSProperty('display', 'none')],
{
rule: createMockCSSStyleRule('.extension-override', {
origin: 'injected',
}),
},
),
],
});
return new CssFormatter(matchedStyles, {uid: 'elem-injected'});
});
formatterTest('formats inspector stylesheet rules', () => {
const matchedStyles = createMockCSSMatchedStyles({
nodeStyles: [
createMockCSSStyleDeclaration(
[createMockCSSProperty('outline', '2px solid red')],
{
rule: createMockCSSStyleRule('#interactive-test', {
sourceURL: 'inspector-stylesheet',
origin: 'inspector',
}),
},
),
],
});
return new CssFormatter(matchedStyles, {uid: 'elem-inspector'});
});
formatterTest('formats @navigation ancestor rule', () => {
const rule = createMockCSSStyleRule('.nav-link', {
navigations: [{text: 'same-document'}],
});
const style = createMockCSSStyleDeclaration(
[createMockCSSProperty('color', 'navy')],
{rule},
);
const matchedStyles = createMockCSSMatchedStyles({nodeStyles: [style]});
return new CssFormatter(matchedStyles, {uid: 'elem-nav'});
});
formatterTest('resolves container queries with node uid', async () => {
const containerNode = createMockDOMNode({
selector: 'aside#sidebar',
backendNodeId: 42,
});
const query = {
text: '(min-width: 300px)',
name: 'sidebar-cq',
getContainerForNode: async () => ({
containerNode,
getContainerSizeDetails: async () => ({
queryAxis: 'inline-size',
width: '350px',
}),
}),
};
const rule = createMockCSSStyleRule('.widget', {
containerQueries: [query],
});
const style = createMockCSSStyleDeclaration(
[createMockCSSProperty('padding', '10px')],
{rule},
);
const matchedStyles = createMockCSSMatchedStyles({nodeStyles: [style]});
const containerDetails = new Map<
ContainerQuery,
ResolvedContainerDetails
>();
containerDetails.set(query as unknown as ContainerQuery, {
container: {
uid: `uid-42`,
selector: 'aside#sidebar',
},
});
return new CssFormatter(matchedStyles, {
uid: 'elem-widget',
containerDetails,
});
});
formatterTest('maps invalid and disabled property statuses', () => {
const validProp = createMockCSSProperty('color', 'red');
const invalidProp = createMockCSSProperty('background', 'invalid-val', {
parsedOk: false,
});
const disabledProp = createMockCSSProperty('opacity', '0.5', {
disabled: true,
});
const style = createMockCSSInlineStyle([
validProp,
invalidProp,
disabledProp,
]);
const matchedStyles = createMockCSSMatchedStyles({nodeStyles: [style]});
return new CssFormatter(matchedStyles, {uid: 'elem-diag'});
});
formatterTest(
'formats @layer, @media, @supports, and @starting-style ancestor rules',
() => {
const rule = createMockCSSStyleRule('.test-btn', {
layers: [{text: 'base'}],
media: [{text: '(min-width: 500px)'}],
supports: [{text: '(display: flex)'}],
startingStyles: [{}],
});
const style = createMockCSSStyleDeclaration(
[createMockCSSProperty('display', 'flex')],
{rule},
);
const matchedStyles = createMockCSSMatchedStyles({nodeStyles: [style]});
return new CssFormatter(matchedStyles, {uid: 'btn-1'});
},
);
formatterTest('formats @scope ancestor rule', () => {
const rule = createMockCSSStyleRule('.scoped-item', {
scopes: [{text: '(:root)'}],
});
const style = createMockCSSStyleDeclaration(
[createMockCSSProperty('color', 'blue')],
{rule},
);
const matchedStyles = createMockCSSMatchedStyles({nodeStyles: [style]});
return new CssFormatter(matchedStyles, {uid: 'item-1'});
});
});
+124
View File
@@ -43,6 +43,8 @@ export type MockCSSStyleDeclaration =
sinon.SinonStubbedInstance<DevTools.CSSStyleDeclaration.CSSStyleDeclaration>;
export type MockCSSMatchedStyles =
sinon.SinonStubbedInstance<DevTools.CSSMatchedStyles.CSSMatchedStyles>;
export type MockCSSStyleRule =
sinon.SinonStubbedInstance<DevTools.CSSRule.CSSStyleRule>;
/**
* A minimal event emitter used to back mocked `on`/`off`/`emit` methods on
@@ -168,6 +170,8 @@ export function createHandlerMocks(): {
return {page, context, response};
}
type RuleOrigin = 'regular' | 'user-agent' | 'injected' | 'inspector';
function isBackendNodeId(
id: unknown,
): id is DevTools.Protocol.DOM.BackendNodeId {
@@ -251,6 +255,126 @@ export function createMockCSSInlineStyle(
});
}
export interface MockRuleOptions {
sourceURL?: string;
lineNumber?: number;
columnNumber?: number;
origin?: RuleOrigin;
isConstructed?: boolean;
nestingSelectors?: string[];
selectors?: Array<{text: string}>;
layers?: Array<{text?: string}>;
media?: Array<{text: string}>;
containerQueries?: Array<{
text?: string;
name?: string;
getContainerForNode?: (nodeId: number) => Promise<unknown>;
}>;
scopes?: Array<{text: string}>;
supports?: Array<{text: string}>;
startingStyles?: unknown[];
navigations?: Array<{text?: string}>;
ruleTypes?: DevTools.Protocol.CSS.CSSRuleType[];
}
export function synthesizeRuleTypes(
options: MockRuleOptions,
): DevTools.Protocol.CSS.CSSRuleType[] | undefined {
if (options.ruleTypes !== undefined) {
return options.ruleTypes;
}
const ruleTypes: DevTools.Protocol.CSS.CSSRuleType[] = [];
const mappings: Array<
[unknown[] | undefined, DevTools.Protocol.CSS.CSSRuleType]
> = [
[options.navigations, DevTools.Protocol.CSS.CSSRuleType.NavigationRule],
[options.nestingSelectors, DevTools.Protocol.CSS.CSSRuleType.StyleRule],
[
options.startingStyles,
DevTools.Protocol.CSS.CSSRuleType.StartingStyleRule,
],
[options.scopes, DevTools.Protocol.CSS.CSSRuleType.ScopeRule],
[options.supports, DevTools.Protocol.CSS.CSSRuleType.SupportsRule],
[options.containerQueries, DevTools.Protocol.CSS.CSSRuleType.ContainerRule],
[options.media, DevTools.Protocol.CSS.CSSRuleType.MediaRule],
[options.layers, DevTools.Protocol.CSS.CSSRuleType.LayerRule],
];
for (const [items, ruleType] of mappings) {
if (items) {
for (const _ of items) {
ruleTypes.push(ruleType);
}
}
}
return ruleTypes.length > 0 ? ruleTypes : undefined;
}
export function attachRuleMeta(
rule: sinon.SinonStubbedInstance<DevTools.CSSRule.CSSRule>,
sourceURL?: string,
origin: RuleOrigin = 'regular',
isConstructed = false,
): void {
rule.isUserAgent.returns(origin === 'user-agent');
rule.isInjected.returns(origin === 'injected');
rule.isViaInspector.returns(origin === 'inspector');
if (sourceURL || isConstructed) {
const header = {
sourceURL: sourceURL ?? '',
lineNumberInSource: (line: number) => line,
columnNumberInSource: (_line: number, col: number) => col,
isConstructedByNew: () => isConstructed,
};
Object.assign(rule, {header});
}
Object.defineProperty(rule, 'sourceURL', {
value: sourceURL,
writable: true,
configurable: true,
});
}
function createCSSValue(text: string) {
return {
text,
rebase() {
// no-op
},
};
}
export function createMockCSSStyleRule(
selector: string,
options: MockRuleOptions = {},
): MockCSSStyleRule {
const rule = sinon.createStubInstance(DevTools.CSSRule.CSSStyleRule);
attachRuleMeta(
rule,
options.sourceURL,
options.origin,
options.isConstructed ?? false,
);
rule.selectorText.returns(selector);
rule.lineNumberInSource.returns(options.lineNumber ?? -1);
rule.columnNumberInSource.returns(options.columnNumber);
const selectors = options.selectors
? options.selectors.map(s => ({...createCSSValue(s.text), ...s}))
: [createCSSValue(selector)];
Object.assign(rule, {
selectors,
nestingSelectors: options.nestingSelectors,
layers: options.layers,
media: options.media,
containerQueries: options.containerQueries,
scopes: options.scopes,
supports: options.supports,
startingStyles: options.startingStyles,
navigations: options.navigations,
ruleTypes: synthesizeRuleTypes(options),
});
return rule;
}
export interface MockCSSMatchedStylesParams {
node?: string | DevTools.DOMModel.DOMNode;
nodeStyles?: DevTools.CSSStyleDeclaration.CSSStyleDeclaration[];