feat: add css formatter class (#2707)

Add the CssFormatter class which will be used to format CSS styles
(returned by getMatchedStyles) into structured JSON and text
representations

This PR adds support for inline styles, attributes, animations and
transitions

Co-authored-by: Samiya Caur <samiyac@chromium.org>
This commit is contained in:
Samiya Caur
2026-09-10 09:24:47 +00:00
committed by GitHub
co-authored by Samiya Caur
parent c54a49383a
commit f0b5fa4bde
5 changed files with 628 additions and 1 deletions
+285
View File
@@ -0,0 +1,285 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {DevTools} from '../third_party/index.js';
import type {MatchedStyles} from '../tools/ToolDefinition.js';
export interface CssFormatterOptions {
uid: string;
}
/**
* Status of a CSS property in the cascade:
* - `active`: Winning declaration for this property name (printed without prefix tag).
* - `overloaded`: Overridden by a more specific or later CSS rule (`[overloaded]`).
* - `invalid`: Property name or value failed CSS parsing (`[invalid]`).
* - `disabled`: Commented out or programmatically disabled (`[disabled]`).
*/
export type CssPropertyStatus =
'active' | 'overloaded' | 'invalid' | 'disabled';
export interface StructuredCssProperty {
name: string;
value: string;
status: CssPropertyStatus;
important?: boolean;
}
export interface NodeStyleRule {
type: 'inline' | 'attributes' | 'transition';
selector: string;
properties: StructuredCssProperty[];
}
export interface AnimationRule {
type: 'animation';
name?: string;
selector: string;
properties: StructuredCssProperty[];
}
export type CascadeRule = NodeStyleRule | AnimationRule;
export interface StructuredCssStyles {
element: {
uid: string;
selector: string;
};
rules: CascadeRule[];
}
const PROPERTY_STATE_MAP: Record<string, CssPropertyStatus> = {
[DevTools.CSSMatchedStyles.PropertyState.ACTIVE]: 'active',
[DevTools.CSSMatchedStyles.PropertyState.OVERLOADED]: 'overloaded',
};
class IndentedWriter {
readonly #lines: string[] = [];
#indent = 0;
constructor(baseIndent = 0) {
this.#indent = baseIndent;
}
indent(): void {
this.#indent += 2;
}
dedent(): void {
this.#indent = Math.max(0, this.#indent - 2);
}
writeLine(text: string): void {
this.#lines.push(' '.repeat(this.#indent) + text);
}
writeEmptyLine(): void {
this.#lines.push('');
}
writeComment(comment: string): void {
this.writeLine(`/* ${comment} */`);
}
lines(): string[] {
return this.#lines;
}
}
/**
* Formats a CSS property into standard CSS syntax with optional status tags.
*
* Status prefix convention:
* - 'active': Clean output without tags (e.g. `color: red;`).
* - 'overloaded' | 'invalid' | 'disabled': Tagged prefix (e.g. `[overloaded] color: blue;`).
*/
function formatPropertyLine(prop: StructuredCssProperty): string {
const stateStr = prop.status === 'active' ? '' : `[${prop.status}] `;
const imp =
prop.important && !/\s*!\s*important$/i.test(prop.value)
? ' !important'
: '';
return `${stateStr}${prop.name}: ${prop.value}${imp};`;
}
function getCascadeRuleHeader(rule: CascadeRule): string {
let selector: string;
switch (rule.type) {
case 'inline':
case 'transition':
case 'animation':
case 'attributes':
selector = rule.selector;
break;
}
const source = 'source' in rule ? rule.source : undefined;
return source ? `${selector} (${source})` : selector;
}
function appendRule(writer: IndentedWriter, rule: CascadeRule): void {
const header = getCascadeRuleHeader(rule);
writer.writeLine(`${header} {`);
writer.indent();
for (const prop of rule.properties) {
writer.writeLine(formatPropertyLine(prop));
}
writer.dedent();
writer.writeLine('}');
}
function appendCssSectionsToString(
writer: IndentedWriter,
styles: StructuredCssStyles,
): void {
for (const rule of styles.rules) {
writer.writeEmptyLine();
appendRule(writer, rule);
}
}
export class CssFormatter {
static #getStyleProperties(
style: DevTools.CSSStyleDeclaration.CSSStyleDeclaration,
): DevTools.CSSProperty.CSSProperty[] {
return style.leadingProperties?.() ?? style.allProperties();
}
/**
* Aggregates all cascading rules impacting the target node.
*/
static collectRules(matchedStyles: MatchedStyles): CascadeRule[] {
const rules: CascadeRule[] = [];
CssFormatter.#collectNodeStyles(rules, matchedStyles);
return rules;
}
static #collectNodeStyles(
rules: CascadeRule[],
matchedStyles: MatchedStyles,
): void {
for (const style of matchedStyles.nodeStyles?.() ?? []) {
const properties = CssFormatter.#getStyleProperties(style);
if (!properties.length) {
continue;
}
if (style.type === DevTools.CSSStyleDeclaration.Type.Transition) {
rules.push({
type: 'transition',
selector: 'transitions style',
properties: CssFormatter.#formatProperties(properties, matchedStyles),
});
} else if (style.type === DevTools.CSSStyleDeclaration.Type.Animation) {
const animName = style.animationName();
rules.push({
type: 'animation',
...(animName ? {name: animName} : {}),
selector: animName ? `${animName} animation` : 'animation style',
properties: CssFormatter.#formatProperties(properties, matchedStyles),
});
} else if (style.type === DevTools.CSSStyleDeclaration.Type.Attributes) {
const node = matchedStyles.nodeForStyle(style);
const tag = node ? node.nodeNameInCorrectCase() : '';
rules.push({
type: 'attributes',
selector: tag ? `${tag}[attributes style]` : '[attributes style]',
properties: CssFormatter.#formatProperties(properties, matchedStyles),
});
} else if (style.type === DevTools.CSSStyleDeclaration.Type.Inline) {
rules.push({
type: 'inline',
selector: 'element.style',
properties: CssFormatter.#formatProperties(properties, matchedStyles),
});
}
}
}
static #formatProperties(
props: DevTools.CSSProperty.CSSProperty[],
matchedStyles: MatchedStyles,
): StructuredCssProperty[] {
return props.map(p =>
CssFormatter.#formatStructuredProperty(p, matchedStyles),
);
}
static #formatStructuredProperty(
prop: DevTools.CSSProperty.CSSProperty,
matchedStyles: MatchedStyles,
): StructuredCssProperty {
let status: CssPropertyStatus = 'active';
if (prop.parsedOk === false) {
status = 'invalid';
} else if (prop.disabled) {
status = 'disabled';
} else {
const state = matchedStyles.propertyState?.(prop);
if (state) {
status = PROPERTY_STATE_MAP[state] ?? 'active';
}
}
let value = prop.value;
const isImportant = Boolean(prop.important);
if (isImportant) {
value = value.replace(/\s*!\s*important$/i, '').trimEnd();
}
return {
name: prop.name,
value,
status,
...(isImportant ? {important: true} : {}),
};
}
readonly #matchedStyles: MatchedStyles;
readonly #options: CssFormatterOptions;
readonly #cascadeRules: readonly CascadeRule[];
constructor(
matchedStyles: MatchedStyles,
options: CssFormatterOptions,
cascadeRules?: readonly CascadeRule[],
) {
this.#matchedStyles = matchedStyles;
this.#options = options;
this.#cascadeRules =
cascadeRules ?? CssFormatter.collectRules(matchedStyles);
}
get rules(): readonly CascadeRule[] {
return this.#cascadeRules;
}
toString(): string {
const json = this.toJSON();
const lines: string[] = [
`Styles for ${json.element.selector} (uid: "${json.element.uid}"):`,
];
if (this.#cascadeRules.length === 0) {
lines.push('', ' (no styles)');
return lines.join('\n');
}
const writer = new IndentedWriter(2);
appendCssSectionsToString(writer, json);
lines.push(...writer.lines());
return lines.join('\n');
}
toJSON(): StructuredCssStyles {
return {
element: {
uid: this.#options.uid,
selector: this.#matchedStyles.node?.()?.simpleSelector() ?? '',
},
rules: [...this.#cascadeRules],
};
}
}
+2
View File
@@ -322,6 +322,8 @@ export type Context = Readonly<{
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange>;
}>;
export type MatchedStyles = DevTools.CSSMatchedStyles.CSSMatchedStyles;
/**
* Only add methods used by tools/*.
*/
@@ -0,0 +1,113 @@
exports[`CssFormatter > formats element label with id, class, and uid and no styles toJSON 1`] = `
{
"element": {
"uid": "1_1",
"selector": "div#main"
},
"rules": []
}
`;
exports[`CssFormatter > formats element label with id, class, and uid and no styles toString 1`] = `
Styles for div#main (uid: "1_1"):
(no styles)
`;
exports[`CssFormatter > formats inline styles with active and overloaded properties toJSON 1`] = `
{
"element": {
"uid": "1_2",
"selector": "button"
},
"rules": [
{
"type": "inline",
"selector": "element.style",
"properties": [
{
"name": "color",
"value": "red",
"status": "overloaded"
},
{
"name": "font-size",
"value": "14px",
"status": "active",
"important": true
}
]
}
]
}
`;
exports[`CssFormatter > formats inline styles with active and overloaded properties toString 1`] = `
Styles for button (uid: "1_2"):
element.style {
[overloaded] color: red;
font-size: 14px !important;
}
`;
exports[`CssFormatter > formats transition, animation, and attributes styles toJSON 1`] = `
{
"element": {
"uid": "table-1",
"selector": "table#data"
},
"rules": [
{
"type": "transition",
"selector": "transitions style",
"properties": [
{
"name": "opacity",
"value": "1",
"status": "active"
}
]
},
{
"type": "animation",
"name": "pulse",
"selector": "pulse animation",
"properties": [
{
"name": "transform",
"value": "scale(1.2)",
"status": "active"
}
]
},
{
"type": "attributes",
"selector": "table[attributes style]",
"properties": [
{
"name": "border",
"value": "1px",
"status": "active"
}
]
}
]
}
`;
exports[`CssFormatter > formats transition, animation, and attributes styles toString 1`] = `
Styles for table#data (uid: "table-1"):
transitions style {
opacity: 1;
}
pulse animation {
transform: scale(1.2);
}
table[attributes style] {
border: 1px;
}
`;
+90
View File
@@ -0,0 +1,90 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {afterEach, describe, it} from 'node:test';
import sinon from 'sinon';
import {CssFormatter} from '../../src/formatters/CssFormatter.js';
import {DevTools} from '../../src/third_party/index.js';
import {
createMockCSSInlineStyle,
createMockCSSMatchedStyles,
createMockCSSProperty,
createMockCSSStyleDeclaration,
createMockDOMNode,
} from '../mocks.js';
describe('CssFormatter', () => {
afterEach(() => {
sinon.restore();
});
function formatterTest(
label: string,
setup: (t: it.TestContext) => CssFormatter | Promise<CssFormatter>,
) {
it(label + ' toString', async t => {
const formatter = await setup(t);
t.assert.snapshot(formatter.toString());
});
it(label + ' toJSON', async t => {
const formatter = await setup(t);
t.assert.snapshot(JSON.stringify(formatter.toJSON(), null, 2));
});
}
formatterTest(
'formats element label with id, class, and uid and no styles',
() => {
const matchedStyles = createMockCSSMatchedStyles({node: 'div#main'});
return new CssFormatter(matchedStyles, {uid: '1_1'});
},
);
formatterTest(
'formats inline styles with active and overloaded properties',
() => {
const prop1 = createMockCSSProperty('color', 'red');
const prop2 = createMockCSSProperty('font-size', '14px', {
important: true,
});
const matchedStyles = createMockCSSMatchedStyles({
nodeStyles: [createMockCSSInlineStyle([prop1, prop2])],
propertyStates: new Map([[prop1, 'Overloaded']]),
});
return new CssFormatter(matchedStyles, {uid: '1_2'});
},
);
formatterTest('formats transition, animation, and attributes styles', () => {
const transitionStyle = createMockCSSStyleDeclaration(
[createMockCSSProperty('opacity', '1')],
{type: DevTools.CSSStyleDeclaration.Type.Transition},
);
const animationStyle = createMockCSSStyleDeclaration(
[createMockCSSProperty('transform', 'scale(1.2)')],
{
type: DevTools.CSSStyleDeclaration.Type.Animation,
animationName: 'pulse',
},
);
const tableNode = createMockDOMNode({selector: 'table#data'});
const attributesStyle = createMockCSSStyleDeclaration(
[createMockCSSProperty('border', '1px')],
{type: DevTools.CSSStyleDeclaration.Type.Attributes},
);
const matchedStyles = createMockCSSMatchedStyles({
node: tableNode,
nodeStyles: [transitionStyle, animationStyle, attributesStyle],
nodeForStyleMap: new Map([[attributesStyle, tableNode]]),
});
return new CssFormatter(matchedStyles, {uid: 'table-1'});
});
});
+138 -1
View File
@@ -28,7 +28,7 @@ import sinon from 'sinon';
import {McpContext} from '../src/McpContext.js';
import {McpPage} from '../src/McpPage.js';
import {McpResponse} from '../src/McpResponse.js';
import {CdpPage} from '../src/third_party/index.js';
import {CdpPage, DevTools} from '../src/third_party/index.js';
import type {Page} from '../src/third_party/index.js';
export type MockMcpPage = sinon.SinonStubbedInstance<McpPage> & {
@@ -36,6 +36,13 @@ export type MockMcpPage = sinon.SinonStubbedInstance<McpPage> & {
};
export type MockMcpContext = sinon.SinonStubbedInstance<McpContext>;
export type MockMcpResponse = sinon.SinonStubbedInstance<McpResponse>;
export type MockDOMNode = sinon.SinonStubbedInstance<DevTools.DOMModel.DOMNode>;
export type MockCSSProperty =
sinon.SinonStubbedInstance<DevTools.CSSProperty.CSSProperty>;
export type MockCSSStyleDeclaration =
sinon.SinonStubbedInstance<DevTools.CSSStyleDeclaration.CSSStyleDeclaration>;
export type MockCSSMatchedStyles =
sinon.SinonStubbedInstance<DevTools.CSSMatchedStyles.CSSMatchedStyles>;
/**
* A minimal event emitter used to back mocked `on`/`off`/`emit` methods on
@@ -135,3 +142,133 @@ export function createHandlerMocks(): {
const response = createMockMcpResponse();
return {page, context, response};
}
function isBackendNodeId(
id: unknown,
): id is DevTools.Protocol.DOM.BackendNodeId {
return typeof id === 'number';
}
export interface MockDOMNodeOptions {
selector?: string;
backendNodeId?: number;
}
export function createMockDOMNode(
options: MockDOMNodeOptions = {},
): MockDOMNode {
const node = sinon.createStubInstance(DevTools.DOMModel.DOMNode);
const selector = options.selector ?? 'button';
const backendNodeId = options.backendNodeId ?? 1;
if (isBackendNodeId(backendNodeId)) {
node.backendNodeId.returns(backendNodeId);
}
node.simpleSelector.returns(selector);
node.nodeNameInCorrectCase.returns(selector.split(/[#.]/)[0] || selector);
return node;
}
export interface MockCSSPropertyOptions {
important?: boolean;
parsedOk?: boolean;
disabled?: boolean;
}
export function createMockCSSProperty(
name: string,
value: string,
options: MockCSSPropertyOptions = {},
): MockCSSProperty {
const prop = sinon.createStubInstance(DevTools.CSSProperty.CSSProperty);
prop.name = name;
prop.value = value;
prop.important = options.important ?? false;
prop.parsedOk = options.parsedOk ?? true;
prop.disabled = options.disabled ?? false;
return prop;
}
export interface MockCSSStyleDeclarationOptions {
rule?: DevTools.CSSRule.CSSRule | null;
type?: DevTools.CSSStyleDeclaration.Type;
animationName?: string;
range?: {
startLine: number;
startColumn: number;
endLine: number;
endColumn: number;
};
}
export function createMockCSSStyleDeclaration(
properties: DevTools.CSSProperty.CSSProperty[],
options: MockCSSStyleDeclarationOptions = {},
): MockCSSStyleDeclaration {
const style = sinon.createStubInstance(
DevTools.CSSStyleDeclaration.CSSStyleDeclaration,
);
style.type = options.type ?? DevTools.CSSStyleDeclaration.Type.Regular;
style.allProperties.returns(properties);
style.leadingProperties.returns(properties);
style.parentRule = options.rule ?? null;
style.animationName.returns(options.animationName ?? '');
if (options.range) {
Object.assign(style, {range: options.range});
}
return style;
}
export function createMockCSSInlineStyle(
properties: DevTools.CSSProperty.CSSProperty[],
): MockCSSStyleDeclaration {
return createMockCSSStyleDeclaration(properties, {
type: DevTools.CSSStyleDeclaration.Type.Inline,
});
}
export interface MockCSSMatchedStylesParams {
node?: string | DevTools.DOMModel.DOMNode;
nodeStyles?: DevTools.CSSStyleDeclaration.CSSStyleDeclaration[];
parentNode?: string | DevTools.DOMModel.DOMNode;
nodeForStyleMap?: Map<
DevTools.CSSStyleDeclaration.CSSStyleDeclaration,
DevTools.DOMModel.DOMNode
>;
propertyStates?: Map<DevTools.CSSProperty.CSSProperty, string>;
matchingSelectorsMap?: Map<unknown, number[]>;
}
export function createMockCSSMatchedStyles(
params: MockCSSMatchedStylesParams = {},
): MockCSSMatchedStyles {
const mockNode =
typeof params.node === 'string'
? createMockDOMNode({selector: params.node})
: (params.node ?? createMockDOMNode());
const nodeStyles = params.nodeStyles ?? [];
const defaultParentNode =
typeof params.parentNode === 'string'
? createMockDOMNode({selector: params.parentNode})
: params.parentNode;
const nodeForStyleMap = params.nodeForStyleMap ?? new Map();
const propertyStates = params.propertyStates ?? new Map();
const mock = sinon.createStubInstance(
DevTools.CSSMatchedStyles.CSSMatchedStyles,
);
mock.node.returns(mockNode);
mock.nodeStyles.returns(nodeStyles);
mock.nodeForStyle.callsFake(
style => nodeForStyleMap.get(style) ?? defaultParentNode ?? null,
);
mock.propertyState.callsFake(prop => propertyStates.get(prop) ?? 'Active');
mock.getMatchingSelectors.callsFake(
rule => params.matchingSelectorsMap?.get(rule) ?? [],
);
return mock;
}