mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-09-28 11:22:57 +08:00
feat: add get_css_style tool (#2612)
Add `get_css_styles` tool which exposes matched styles and the CSS cascade to empower accurate CSS debugging workflows. It integrates `CssFormatter` into `McpPage` and `McpResponse`, adding container query resolution and rule pagination support. Co-authored-by: Samiya Caur <samiyac@chromium.org>
This commit is contained in:
co-authored by
Samiya Caur
parent
9c72209eee
commit
4454ae0a8a
+16
-1
@@ -30,9 +30,10 @@
|
||||
- **[Network](#network)** (2 tools)
|
||||
- [`get_network_request`](#get_network_request)
|
||||
- [`list_network_requests`](#list_network_requests)
|
||||
- **[Debugging](#debugging)** (8 tools)
|
||||
- **[Debugging](#debugging)** (9 tools)
|
||||
- [`evaluate_script`](#evaluate_script)
|
||||
- [`get_console_message`](#get_console_message)
|
||||
- [`get_css_styles`](#get_css_styles)
|
||||
- [`lighthouse_audit`](#lighthouse_audit)
|
||||
- [`list_console_messages`](#list_console_messages)
|
||||
- [`take_screenshot`](#take_screenshot)
|
||||
@@ -400,6 +401,20 @@
|
||||
|
||||
---
|
||||
|
||||
### `get_css_styles`
|
||||
|
||||
**Description:** Retrieve matched CSS rules, inline styles, inherited styles, and cascade information for an element identified by its UID.
|
||||
Use this tool to debug why specific CSS properties are applied, overridden, or conflicting. Supports pagination for elements with many matched rules. Requires a UID from [`take_snapshot`](#take_snapshot).
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **pageId** (number) **(required)**: Targets a specific page by ID.
|
||||
- **uid** (string) **(required)**: The uid of the element on the page from the page content snapshot to inspect CSS styles for
|
||||
- **pageIdx** (integer) _(optional)_: Page number to return (0-based). When omitted, returns the first page.
|
||||
- **pageSize** (integer) _(optional)_: Maximum number of CSS rules to return per page. When omitted, returns all rules.
|
||||
|
||||
---
|
||||
|
||||
### `lighthouse_audit`
|
||||
|
||||
**Description:** Get Lighthouse score and reports for accessibility, SEO, best practices, and agentic browsing. This excludes performance. For performance audits, run [`performance_start_trace`](#performance_start_trace)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {ScenarioArgs, ScenarioIterations, ToolCall} from '../types.ts';
|
||||
import {uidsFromSnapshotResult} from '../utils.ts';
|
||||
|
||||
export function getNumIterations(): ScenarioIterations {
|
||||
return {
|
||||
iterations: 10,
|
||||
warmupIterations: 10,
|
||||
};
|
||||
}
|
||||
|
||||
export function get(args: ScenarioArgs): ToolCall[] {
|
||||
const pageId = 1;
|
||||
const toolCalls: ToolCall[] = [
|
||||
{
|
||||
name: 'navigate_page',
|
||||
arguments: {
|
||||
pageId,
|
||||
type: 'url',
|
||||
url: args.targetUrl,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'take_snapshot',
|
||||
arguments: {
|
||||
pageId,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (let index = 0; index < 25; index++) {
|
||||
toolCalls.push({
|
||||
name: 'get_css_styles',
|
||||
arguments: context => {
|
||||
const snapshotResult = context.results[1];
|
||||
const uids = uidsFromSnapshotResult(snapshotResult);
|
||||
const uid = uids[index % uids.length];
|
||||
if (!uid) {
|
||||
throw new Error(
|
||||
`No element UID available in snapshot for index ${index}`,
|
||||
);
|
||||
}
|
||||
const pagination =
|
||||
index % 3 === 0
|
||||
? {pageSize: 3, pageIdx: 0}
|
||||
: index % 3 === 1
|
||||
? {pageSize: 3, pageIdx: 1}
|
||||
: {};
|
||||
return {
|
||||
pageId,
|
||||
uid,
|
||||
...pagination,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return toolCalls;
|
||||
}
|
||||
@@ -47,15 +47,65 @@ export async function startSourceMapTestServer(
|
||||
{length: scriptCount},
|
||||
(_, index) => `<script src="/scripts/script-${index}.js"></script>`,
|
||||
).join('\n');
|
||||
const styledButtons = Array.from(
|
||||
{length: 25},
|
||||
(_, index) =>
|
||||
`<button id="btn-${index}" class="styled-btn${index % 2 === 0 ? ' alternate' : ''}" style="${index % 3 === 0 ? 'color: red;' : ''}">Button ${index}</button>`,
|
||||
).join('\n');
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Source map profiler fixture</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #1a73e8;
|
||||
--accent-color: #34a853;
|
||||
}
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
color: #333;
|
||||
margin: 16px;
|
||||
}
|
||||
@layer base {
|
||||
.styled-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
& .styled-btn {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #dadce0;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
|
||||
&::before {
|
||||
content: '▶ ';
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #f1f3f4;
|
||||
}
|
||||
|
||||
&.alternate {
|
||||
border-color: var(--accent-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Source map profiler fixture</h1>
|
||||
<p>${scriptCount} scripts, each with a distinct source map.</p>
|
||||
<div class="styled-list">
|
||||
${styledButtons}
|
||||
</div>
|
||||
${scriptTags}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
@@ -65,3 +65,58 @@ export function selectedPageIdFromToolResult(result: unknown): number {
|
||||
export function isScenarioModule(value: unknown): value is ProfileScenario {
|
||||
return isObject(value) && typeof value.get === 'function';
|
||||
}
|
||||
|
||||
export function uidsFromSnapshotResult(result: unknown): string[] {
|
||||
if (isObject(result) && isObject(result.structuredContent)) {
|
||||
const snapshot = result.structuredContent.snapshot;
|
||||
if (isObject(snapshot)) {
|
||||
const uids: string[] = [];
|
||||
const collectUids = (node: Record<string, unknown>): void => {
|
||||
if (
|
||||
typeof node.id === 'string' &&
|
||||
node.id.length > 0 &&
|
||||
node.role !== 'RootWebArea' &&
|
||||
node.role !== 'StaticText'
|
||||
) {
|
||||
uids.push(node.id);
|
||||
}
|
||||
if (Array.isArray(node.children)) {
|
||||
for (const child of node.children) {
|
||||
if (isObject(child)) {
|
||||
collectUids(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
collectUids(snapshot);
|
||||
if (uids.length > 0) {
|
||||
return uids;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(result) && Array.isArray(result.content)) {
|
||||
const uids: string[] = [];
|
||||
for (const item of result.content) {
|
||||
if (isObject(item) && typeof item.text === 'string') {
|
||||
for (const line of item.text.split('\n')) {
|
||||
if (line.includes('RootWebArea') || line.includes('StaticText')) {
|
||||
continue;
|
||||
}
|
||||
const match = /\buid=([^\s]+)/.exec(line);
|
||||
const uid = match?.[1];
|
||||
if (uid !== undefined && uid.length > 0) {
|
||||
uids.push(uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (uids.length > 0) {
|
||||
return uids;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'take_snapshot did not return any inspectable element UIDs in its response',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -135,6 +135,8 @@ chrome-devtools list_network_requests 1 --includePreservedRequests true # Includ
|
||||
chrome-devtools evaluate_script "() => document.title" --pageId 1 # Evaluate a JavaScript function on page 1
|
||||
chrome-devtools evaluate_script "(a) => a.innerText" --pageId 1 --args 1_4 # Evaluate JS with UID arguments on page 1
|
||||
chrome-devtools get_console_message 1 1 # Gets a console message by its ID
|
||||
chrome-devtools get_css_styles 1 "1_4" # Retrieves resolved CSS styles (inline, matched, inherited, pseudo) for an element on page 1
|
||||
chrome-devtools get_css_styles 1 "1_4" --pageSize 20 --pageIdx 0 # Get CSS styles with pagination on page 1
|
||||
chrome-devtools lighthouse_audit 1 --mode "navigation" # Run Lighthouse audit for navigation
|
||||
chrome-devtools lighthouse_audit 1 --mode "snapshot" --device "mobile" # Run Lighthouse audit for a snapshot on mobile
|
||||
chrome-devtools lighthouse_audit 1 --outputDirPath ./out # Run Lighthouse audit and save reports
|
||||
|
||||
+73
-1
@@ -78,7 +78,7 @@ import {
|
||||
type Page,
|
||||
type ConsoleMessage,
|
||||
type HTTPRequest,
|
||||
type DevTools,
|
||||
DevTools,
|
||||
type JSONSchema7Definition,
|
||||
} from './third_party/index.js';
|
||||
import {takeSnapshot} from './tools/snapshot.js';
|
||||
@@ -88,6 +88,7 @@ const NAVIGATION_TIMEOUT = 10_000;
|
||||
import type {
|
||||
ContextPage,
|
||||
DevToolsData,
|
||||
MatchedStyles,
|
||||
Response,
|
||||
} from './tools/ToolDefinition.js';
|
||||
import type {
|
||||
@@ -103,6 +104,12 @@ import {
|
||||
type DialogAction,
|
||||
} from './utils/WaitForHelper.js';
|
||||
|
||||
function isBackendNodeId(
|
||||
id: unknown,
|
||||
): id is DevTools.Protocol.DOM.BackendNodeId {
|
||||
return typeof id === 'number';
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-page state wrapper. Consolidates dialog, snapshot, emulation,
|
||||
* and metadata that were previously scattered across Maps in McpContext.
|
||||
@@ -748,6 +755,71 @@ export class McpPage implements ContextPage {
|
||||
}
|
||||
}
|
||||
|
||||
async getMatchedStylesForUid(uid: string): Promise<MatchedStyles> {
|
||||
if (!this.textSnapshot) {
|
||||
throw new Error(
|
||||
`No snapshot found for page ${this.id ?? '?'}. Use ${takeSnapshot.name} to capture one.`,
|
||||
);
|
||||
}
|
||||
const node = this.textSnapshot.idToNode.get(uid);
|
||||
if (!node) {
|
||||
throw new Error(`Element uid "${uid}" not found on page ${this.id}.`);
|
||||
}
|
||||
|
||||
const backendNodeId = node.backendNodeId;
|
||||
if (!isBackendNodeId(backendNodeId)) {
|
||||
throw new Error(
|
||||
`Failed to resolve backend node ID for element with uid "${uid}".`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.#devtoolsUniverse) {
|
||||
throw new Error(
|
||||
`DevTools universe is not available for page ${this.id ?? '?'}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const targetManager = this.#devtoolsUniverse.universe.context.get(
|
||||
DevTools.TargetManager,
|
||||
);
|
||||
let domNode: DevTools.DOMModel.DOMNode | undefined;
|
||||
let cssModel: DevTools.CSSModel.CSSModel | null = null;
|
||||
|
||||
for (const dom of targetManager.models(DevTools.DOMModel.DOMModel)) {
|
||||
const nodeMap = await dom.pushNodesByBackendIdsToFrontend(
|
||||
new Set([backendNodeId]),
|
||||
);
|
||||
const frontendNode = nodeMap?.get(backendNodeId);
|
||||
if (frontendNode) {
|
||||
domNode = frontendNode;
|
||||
cssModel = dom.target().model(DevTools.CSSModel.CSSModel);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!domNode || !cssModel) {
|
||||
throw new Error(
|
||||
`Element with uid "${uid}" was detached or no longer exists on the page. Please take a new snapshot with ${takeSnapshot.name}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const targetElement = domNode.enclosingElementOrSelf();
|
||||
if (!targetElement) {
|
||||
throw new Error(
|
||||
`Element with uid "${uid}" is not an element node and has no parent element.`,
|
||||
);
|
||||
}
|
||||
|
||||
const matchedStyles = await cssModel.getMatchedStyles(targetElement.id);
|
||||
if (!matchedStyles) {
|
||||
throw new Error(
|
||||
`Could not retrieve matched styles for element with uid "${uid}".`,
|
||||
);
|
||||
}
|
||||
|
||||
return matchedStyles;
|
||||
}
|
||||
|
||||
async getDevToolsData(): Promise<DevToolsData> {
|
||||
try {
|
||||
logger?.('Getting DevTools UI data');
|
||||
|
||||
@@ -12,6 +12,12 @@ import {
|
||||
type StructuredCommentThread,
|
||||
} from './formatters/CommentFormatter.js';
|
||||
import {ConsoleFormatter} from './formatters/ConsoleFormatter.js';
|
||||
import {
|
||||
type CascadeRule,
|
||||
CssFormatter,
|
||||
type CssFormatterOptions,
|
||||
resolveContainerQueries,
|
||||
} from './formatters/CssFormatter.js';
|
||||
import {
|
||||
HeapSnapshotFormatter,
|
||||
isEdgeLike,
|
||||
@@ -46,6 +52,7 @@ import type {
|
||||
DevToolsData,
|
||||
ImageContentData,
|
||||
LighthouseData,
|
||||
MatchedStyles,
|
||||
Response,
|
||||
SnapshotParams,
|
||||
} from './tools/ToolDefinition.js';
|
||||
@@ -118,6 +125,10 @@ export class McpResponse implements Response {
|
||||
includeStackTraces?: boolean;
|
||||
serviceWorkerId?: string;
|
||||
};
|
||||
#cssStylesData?: {
|
||||
matchedStyles: MatchedStyles;
|
||||
options: CssFormatterOptions & PaginationOptions;
|
||||
};
|
||||
#listExtensions?: boolean;
|
||||
#listThirdPartyDeveloperTools?: boolean;
|
||||
#listWebMcpTools?: boolean;
|
||||
@@ -248,6 +259,16 @@ export class McpResponse implements Response {
|
||||
};
|
||||
}
|
||||
|
||||
setIncludeCssStyles(
|
||||
matchedStyles: MatchedStyles,
|
||||
options: CssFormatterOptions & PaginationOptions,
|
||||
): void {
|
||||
this.#cssStylesData = {
|
||||
matchedStyles,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
setError(error: Error): void {
|
||||
this.#error = error;
|
||||
}
|
||||
@@ -839,6 +860,7 @@ export class McpResponse implements Response {
|
||||
extensionServiceWorkers?: object[];
|
||||
extensionPages?: object[];
|
||||
comments?: StructuredCommentThread[];
|
||||
matchedStyles?: object;
|
||||
errorMessage?: string;
|
||||
navigatedToUrl?: string;
|
||||
geolocation?: {latitude: number; longitude: number};
|
||||
@@ -1434,6 +1456,56 @@ Call ${handleDialog.name} to handle it before continuing.`);
|
||||
);
|
||||
}
|
||||
|
||||
if (this.#cssStylesData) {
|
||||
const resolveUid = (backendNodeId: number) =>
|
||||
this.#page?.textSnapshot?.resolveCdpElementId(backendNodeId);
|
||||
|
||||
const containerDetails = await resolveContainerQueries(
|
||||
this.#cssStylesData.matchedStyles,
|
||||
resolveUid,
|
||||
);
|
||||
|
||||
const options = {
|
||||
...this.#cssStylesData.options,
|
||||
resolveUid,
|
||||
containerDetails,
|
||||
};
|
||||
|
||||
const allRules = CssFormatter.collectRules(
|
||||
this.#cssStylesData.matchedStyles,
|
||||
options,
|
||||
);
|
||||
|
||||
let rules: readonly CascadeRule[] = allRules;
|
||||
|
||||
const hasPagination =
|
||||
this.#cssStylesData.options.pageSize !== undefined ||
|
||||
this.#cssStylesData.options.pageIdx !== undefined;
|
||||
|
||||
if (hasPagination) {
|
||||
const paginationData = this.#dataWithPagination(
|
||||
allRules,
|
||||
this.#cssStylesData.options,
|
||||
);
|
||||
structuredContent.pagination = paginationData.pagination;
|
||||
response.push(...paginationData.info);
|
||||
rules = paginationData.items;
|
||||
}
|
||||
|
||||
const formatter = new CssFormatter(
|
||||
this.#cssStylesData.matchedStyles,
|
||||
options,
|
||||
rules,
|
||||
);
|
||||
|
||||
structuredContent.matchedStyles = formatter.toJSON();
|
||||
if (compactEncode) {
|
||||
response.push(compactEncode(structuredContent.matchedStyles));
|
||||
} else {
|
||||
response.push(formatter.toString());
|
||||
}
|
||||
}
|
||||
|
||||
if (data.errorMessage) {
|
||||
response.push(`Error: ${data.errorMessage}`);
|
||||
structuredContent.errorMessage = data.errorMessage;
|
||||
|
||||
@@ -405,6 +405,40 @@ export const commands: Commands = {
|
||||
},
|
||||
},
|
||||
},
|
||||
get_css_styles: {
|
||||
description:
|
||||
'Retrieve matched CSS rules, inline styles, inherited styles, and cascade information for an element identified by its UID.\nUse this tool to debug why specific CSS properties are applied, overridden, or conflicting. Supports pagination for elements with many matched rules. Requires a UID from take_snapshot.',
|
||||
category: 'Debugging',
|
||||
args: {
|
||||
pageId: {
|
||||
name: 'pageId',
|
||||
type: 'number',
|
||||
description: 'Targets a specific page by ID.',
|
||||
required: true,
|
||||
},
|
||||
uid: {
|
||||
name: 'uid',
|
||||
type: 'string',
|
||||
description:
|
||||
'The uid of the element on the page from the page content snapshot to inspect CSS styles for',
|
||||
required: true,
|
||||
},
|
||||
pageSize: {
|
||||
name: 'pageSize',
|
||||
type: 'integer',
|
||||
description:
|
||||
'Maximum number of CSS rules to return per page. When omitted, returns all rules.',
|
||||
required: false,
|
||||
},
|
||||
pageIdx: {
|
||||
name: 'pageIdx',
|
||||
type: 'integer',
|
||||
description:
|
||||
'Page number to return (0-based). When omitted, returns the first page.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
get_heapsnapshot_class_nodes: {
|
||||
description:
|
||||
'Loads a memory heapsnapshot and returns instances of a specific class with their IDs. (requires flag: --memoryDebugging=true)',
|
||||
|
||||
@@ -1177,3 +1177,70 @@ export class CssFormatter {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves container element details for all `@container` queries impacting the node.
|
||||
*
|
||||
* Container queries evaluate against an ancestor container element in the DOM tree.
|
||||
* The DevTools SDK performs an asynchronous CDP request (`CSS.getContainerForNode`)
|
||||
* to discover the concrete container node for each query.
|
||||
*
|
||||
* Resolving these upfront in parallel allows `CssFormatter` methods to remain purely
|
||||
* synchronous while providing container selectors and UIDs in headers and comments.
|
||||
*/
|
||||
export async function resolveContainerQueries(
|
||||
matchedStyles: MatchedStyles,
|
||||
resolveUid?: UidResolver,
|
||||
): Promise<Map<ContainerQuery, ResolvedContainerDetails>> {
|
||||
const resolved = new Map<ContainerQuery, ResolvedContainerDetails>();
|
||||
const targetNode = matchedStyles.node?.();
|
||||
if (!targetNode) {
|
||||
return resolved;
|
||||
}
|
||||
const queries = new Set<ContainerQuery>();
|
||||
|
||||
const allStyles = [...(matchedStyles.nodeStyles?.() ?? [])];
|
||||
for (const name of matchedStyles.customHighlightPseudoNames?.() ?? []) {
|
||||
allStyles.push(
|
||||
...(matchedStyles.customHighlightPseudoStyles?.(name) ?? []),
|
||||
);
|
||||
}
|
||||
for (const type of matchedStyles.pseudoTypes?.() ?? []) {
|
||||
allStyles.push(...(matchedStyles.pseudoStyles?.(type) ?? []));
|
||||
}
|
||||
for (const style of allStyles) {
|
||||
if (
|
||||
style.parentRule instanceof DevTools.CSSRule.CSSStyleRule &&
|
||||
style.parentRule.containerQueries
|
||||
) {
|
||||
for (const query of style.parentRule.containerQueries) {
|
||||
queries.add(query);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
[...queries].map(async query => {
|
||||
try {
|
||||
const container = await query.getContainerForNode?.(targetNode.id);
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const containerNode = container.containerNode;
|
||||
const selector = containerNode.simpleSelector();
|
||||
const uid = resolveUid?.(containerNode.backendNodeId());
|
||||
|
||||
resolved.set(query, {
|
||||
container: {
|
||||
...(uid ? {uid} : {}),
|
||||
selector,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Ignore container query resolution errors
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@@ -1053,5 +1053,18 @@
|
||||
"argType": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "get_css_styles",
|
||||
"args": [
|
||||
{
|
||||
"name": "page_size",
|
||||
"argType": "number"
|
||||
},
|
||||
{
|
||||
"name": "page_idx",
|
||||
"argType": "number"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
HeapQueryOptions,
|
||||
} from '../processors/HeapSnapshotManager.js';
|
||||
import type {McpPage} from '../McpPage.js';
|
||||
import type {CssFormatterOptions} from '../formatters/CssFormatter.js';
|
||||
import {zod} from '../third_party/index.js';
|
||||
import type {
|
||||
Dialog,
|
||||
@@ -174,6 +175,10 @@ export interface Response {
|
||||
serviceWorkerId?: string;
|
||||
},
|
||||
): void;
|
||||
setIncludeCssStyles(
|
||||
matchedStyles: MatchedStyles,
|
||||
options: CssFormatterOptions & PaginationOptions,
|
||||
): void;
|
||||
includeSnapshot(params?: SnapshotParams): void;
|
||||
attachImage(value: ImageContentData): void;
|
||||
attachNetworkRequest(
|
||||
@@ -335,6 +340,7 @@ export type ContextPage = Readonly<{
|
||||
readonly networkConditions: string | null;
|
||||
getAXNodeByUid(uid: string): TextSnapshotNode | undefined;
|
||||
getElementByUid(uid: string): Promise<ElementHandle<Element>>;
|
||||
getMatchedStylesForUid(uid: string): Promise<MatchedStyles>;
|
||||
|
||||
/**
|
||||
* Returns a reqid for a cdpRequestId.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {zod} from '../third_party/index.js';
|
||||
|
||||
import {ToolCategory} from './categories.js';
|
||||
import {definePageTool} from './ToolDefinition.js';
|
||||
|
||||
export const getCssStyles = definePageTool({
|
||||
name: 'get_css_styles',
|
||||
description: `Retrieve matched CSS rules, inline styles, inherited styles, and cascade information for an element identified by its UID.
|
||||
Use this tool to debug why specific CSS properties are applied, overridden, or conflicting. Supports pagination for elements with many matched rules. Requires a UID from take_snapshot.`,
|
||||
annotations: {
|
||||
category: ToolCategory.DEBUGGING,
|
||||
readOnlyHint: true,
|
||||
},
|
||||
schema: {
|
||||
uid: zod
|
||||
.string()
|
||||
.describe(
|
||||
'The uid of the element on the page from the page content snapshot to inspect CSS styles for',
|
||||
),
|
||||
pageSize: zod
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe(
|
||||
'Maximum number of CSS rules to return per page. When omitted, returns all rules.',
|
||||
),
|
||||
pageIdx: zod
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.describe(
|
||||
'Page number to return (0-based). When omitted, returns the first page.',
|
||||
),
|
||||
},
|
||||
blockedByDialog: true,
|
||||
verifyFilesSchema: {},
|
||||
handler: async (request, response) => {
|
||||
const matchedStyles = await request.page.getMatchedStylesForUid(
|
||||
request.params.uid,
|
||||
);
|
||||
response.setIncludeCssStyles(matchedStyles, {
|
||||
uid: request.params.uid,
|
||||
pageSize: request.params.pageSize,
|
||||
pageIdx: request.params.pageIdx,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import type {ParsedArguments} from '../config/mcp-options.js';
|
||||
|
||||
import * as commentsTools from './comments.js';
|
||||
import * as consoleTools from './console.js';
|
||||
import * as cssTools from './css.js';
|
||||
import * as emulationTools from './emulation.js';
|
||||
import * as extensionTools from './extensions.js';
|
||||
import * as inputTools from './input.js';
|
||||
@@ -32,6 +33,7 @@ export const createTools = (args: ParsedArguments) => {
|
||||
: [
|
||||
...(args.devtoolsComments ? Object.values(commentsTools) : []),
|
||||
...Object.values(consoleTools),
|
||||
...Object.values(cssTools),
|
||||
...Object.values(emulationTools),
|
||||
...Object.values(extensionTools),
|
||||
...Object.values(inputTools),
|
||||
|
||||
+218
-17
@@ -12,11 +12,13 @@ import sinon from 'sinon';
|
||||
import type {TargetUniverse} from '../src/devtools/DevtoolsUtils.js';
|
||||
import {McpPage} from '../src/McpPage.js';
|
||||
import {replaceHtmlElementsWithUids} from '../src/McpPage.js';
|
||||
import {Locator} from '../src/third_party/index.js';
|
||||
import {DevTools, Locator} from '../src/third_party/index.js';
|
||||
import type {JSONSchema7Definition} from '../src/third_party/index.js';
|
||||
import type {Page} from '../src/third_party/index.js';
|
||||
import {TextSnapshot} from '../src/TextSnapshot.js';
|
||||
import type {TextSnapshotNode} from '../src/types.js';
|
||||
import {createMockPuppeteerPage} from './mocks.js';
|
||||
|
||||
import {serverHooks} from './server.js';
|
||||
import {html, withMcpContext} from './utils.js';
|
||||
|
||||
describe('replaceHtmlElementsWithUids', () => {
|
||||
@@ -268,21 +270,6 @@ describe('replaceHtmlElementsWithUids', () => {
|
||||
});
|
||||
|
||||
describe('McpPage', () => {
|
||||
it('creates a handle on the page and disposes it as such', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const page = context.getSelectedMcpPage().pptrPage;
|
||||
|
||||
using handle = await page.evaluateHandle('new Set()');
|
||||
|
||||
{
|
||||
using _ = handle;
|
||||
}
|
||||
|
||||
// @ts-expect-error Internal Puppeteer API
|
||||
assert.ok(handle.disposed);
|
||||
});
|
||||
});
|
||||
|
||||
function createMcpPage(options: {hasNetworkBlockOrAllowlist?: boolean} = {}) {
|
||||
const pptrPage = createMockPuppeteerPage();
|
||||
const mcpPage = new McpPage(pptrPage as unknown as Page, 1, {
|
||||
@@ -298,6 +285,19 @@ describe('McpPage', () => {
|
||||
return {mcpPage, pptrPage, mockSession};
|
||||
}
|
||||
|
||||
function getUidForNode(mcpPage: McpPage, matcher: string): string {
|
||||
const textSnapshot = mcpPage.textSnapshot;
|
||||
if (!textSnapshot) {
|
||||
throw new Error('No textSnapshot on mcpPage');
|
||||
}
|
||||
for (const [uid, node] of textSnapshot.idToNode) {
|
||||
if (node.name?.includes(matcher)) {
|
||||
return uid;
|
||||
}
|
||||
}
|
||||
throw new Error(`Target element "${matcher}" not found in snapshot`);
|
||||
}
|
||||
|
||||
describe('emulate()', () => {
|
||||
afterEach(() => {
|
||||
sinon.restore();
|
||||
@@ -671,4 +671,205 @@ describe('McpPage', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMatchedStylesForUid()', () => {
|
||||
const server = serverHooks();
|
||||
|
||||
function getSelectors(
|
||||
matchedStyles: DevTools.CSSMatchedStyles.CSSMatchedStyles,
|
||||
): string[] {
|
||||
const styles = matchedStyles.nodeStyles();
|
||||
assert.ok(styles.length > 0);
|
||||
const selectors: string[] = [];
|
||||
for (const s of styles) {
|
||||
if (s.parentRule instanceof DevTools.CSSRule.CSSStyleRule) {
|
||||
selectors.push(s.parentRule.selectorText());
|
||||
}
|
||||
}
|
||||
return selectors;
|
||||
}
|
||||
|
||||
async function getSelectorsForUid(
|
||||
uid: string,
|
||||
mcpPage: McpPage,
|
||||
): Promise<string[]> {
|
||||
const matchedStyles = await mcpPage.getMatchedStylesForUid(uid);
|
||||
assert.ok(matchedStyles);
|
||||
return getSelectors(matchedStyles);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
it('throws when snapshot has not been captured', async () => {
|
||||
const {mcpPage} = createMcpPage();
|
||||
await assert.rejects(
|
||||
() => mcpPage.getMatchedStylesForUid('node_1'),
|
||||
/No snapshot found for page/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when element uid is not found in snapshot', async () => {
|
||||
const {mcpPage} = createMcpPage();
|
||||
const rootNode: TextSnapshotNode = {
|
||||
id: '1_0',
|
||||
role: 'root',
|
||||
children: [],
|
||||
elementHandle: async () => null,
|
||||
};
|
||||
mcpPage.textSnapshot = new TextSnapshot({
|
||||
root: rootNode,
|
||||
idToNode: new Map<string, TextSnapshotNode>(),
|
||||
snapshotId: '1',
|
||||
hasSelectedElement: false,
|
||||
verbose: false,
|
||||
});
|
||||
await assert.rejects(
|
||||
() => mcpPage.getMatchedStylesForUid('node_1'),
|
||||
/Element uid "node_1" not found on page/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when element has no backendNodeId', async () => {
|
||||
const {mcpPage} = createMcpPage();
|
||||
const node: TextSnapshotNode = {
|
||||
id: '1_1',
|
||||
role: 'button',
|
||||
children: [],
|
||||
elementHandle: async () => null,
|
||||
};
|
||||
const idToNode = new Map<string, TextSnapshotNode>([['node_1', node]]);
|
||||
mcpPage.textSnapshot = new TextSnapshot({
|
||||
root: node,
|
||||
idToNode,
|
||||
snapshotId: '1',
|
||||
hasSelectedElement: false,
|
||||
verbose: false,
|
||||
});
|
||||
await assert.rejects(
|
||||
() => mcpPage.getMatchedStylesForUid('node_1'),
|
||||
/Failed to resolve backend node ID for element with uid "node_1"/,
|
||||
);
|
||||
});
|
||||
|
||||
it('retrieves matched styles across elements, shadow roots, and iframes', async () => {
|
||||
server.addHtmlRoute(
|
||||
'/iframe_content.html',
|
||||
html`
|
||||
<style>
|
||||
.frame-btn {
|
||||
background-color: purple;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
<button
|
||||
id="iframe-btn"
|
||||
class="frame-btn"
|
||||
>Iframe Button</button
|
||||
>
|
||||
`,
|
||||
);
|
||||
server.addHtmlRoute(
|
||||
'/styles_combined_test.html',
|
||||
html`
|
||||
<style>
|
||||
.btn-primary {
|
||||
color: blue;
|
||||
font-size: 14px;
|
||||
}
|
||||
#my-button {
|
||||
color: green;
|
||||
}
|
||||
</style>
|
||||
<button
|
||||
id="my-button"
|
||||
class="btn-primary"
|
||||
style="font-size: 16px; padding: 8px;"
|
||||
>
|
||||
Click Me
|
||||
</button>
|
||||
<div id="open-host"></div>
|
||||
<div id="closed-host"></div>
|
||||
<iframe
|
||||
id="child-frame"
|
||||
src="/iframe_content.html"
|
||||
></iframe>
|
||||
<script>
|
||||
const openHost = document.getElementById('open-host');
|
||||
const openRoot = openHost.attachShadow({mode: 'open'});
|
||||
openRoot.innerHTML = \`
|
||||
<style>
|
||||
.shadow-btn-open {
|
||||
color: rgb(100, 200, 50);
|
||||
}
|
||||
</style>
|
||||
<button class="shadow-btn-open">Open Shadow Button</button>
|
||||
\`;
|
||||
|
||||
const closedHost = document.getElementById('closed-host');
|
||||
const closedRoot = closedHost.attachShadow({mode: 'closed'});
|
||||
closedRoot.innerHTML = \`
|
||||
<style>
|
||||
.shadow-btn-closed {
|
||||
color: rgb(200, 50, 100);
|
||||
}
|
||||
</style>
|
||||
<button class="shadow-btn-closed">Closed Shadow Button</button>
|
||||
\`;
|
||||
</script>
|
||||
`,
|
||||
);
|
||||
|
||||
await withMcpContext(async (_, context) => {
|
||||
const mcpPage = context.getSelectedMcpPage();
|
||||
await mcpPage.pptrPage.goto(
|
||||
server.getRoute('/styles_combined_test.html'),
|
||||
);
|
||||
const frame = await mcpPage.pptrPage.waitForFrame(
|
||||
f => f.url() === server.getRoute('/iframe_content.html'),
|
||||
);
|
||||
if (!frame) {
|
||||
throw new Error('Child frame not found');
|
||||
}
|
||||
await frame.waitForSelector('#iframe-btn');
|
||||
|
||||
mcpPage.textSnapshot = await TextSnapshot.create(mcpPage);
|
||||
|
||||
// 1. Regular element
|
||||
{
|
||||
const uid = getUidForNode(mcpPage, 'Click Me');
|
||||
const matchedStyles = await mcpPage.getMatchedStylesForUid(uid);
|
||||
const inlineStyle = matchedStyles
|
||||
.nodeStyles()
|
||||
.find(s => s.type === DevTools.CSSStyleDeclaration.Type.Inline);
|
||||
assert.ok(inlineStyle);
|
||||
const selectors = getSelectors(matchedStyles);
|
||||
assert.ok(selectors.includes('#my-button'));
|
||||
assert.ok(selectors.includes('.btn-primary'));
|
||||
}
|
||||
|
||||
// 2. Open shadow root
|
||||
{
|
||||
const uid = getUidForNode(mcpPage, 'Open Shadow Button');
|
||||
const selectors = await getSelectorsForUid(uid, mcpPage);
|
||||
assert.ok(selectors.includes('.shadow-btn-open'));
|
||||
}
|
||||
|
||||
// 3. Closed shadow root
|
||||
{
|
||||
const uid = getUidForNode(mcpPage, 'Closed Shadow Button');
|
||||
const selectors = await getSelectorsForUid(uid, mcpPage);
|
||||
assert.ok(selectors.includes('.shadow-btn-closed'));
|
||||
}
|
||||
|
||||
// 4. Iframe
|
||||
{
|
||||
const uid = getUidForNode(mcpPage, 'Iframe Button');
|
||||
const selectors = await getSelectorsForUid(uid, mcpPage);
|
||||
assert.ok(selectors.includes('.frame-btn'));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,12 @@ import {
|
||||
|
||||
import {serverHooks} from './server.js';
|
||||
import {loadTraceAsBuffer} from './trace-processing/fixtures/load.js';
|
||||
import {
|
||||
createMockCSSMatchedStyles,
|
||||
createMockCSSProperty,
|
||||
createMockCSSStyleDeclaration,
|
||||
createMockCSSStyleRule,
|
||||
} from './mocks.js';
|
||||
import {
|
||||
getImageContent,
|
||||
getMockAggregatedIssue,
|
||||
@@ -1160,4 +1166,28 @@ describe('webmcp', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('returns pagination info when css styles pagination options are provided', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const mockStyles = createMockCSSMatchedStyles({
|
||||
node: 'button#btn',
|
||||
nodeStyles: Array.from({length: 5}, (_, idx) =>
|
||||
createMockCSSStyleDeclaration(
|
||||
[createMockCSSProperty('color', `color-${idx}`)],
|
||||
{
|
||||
rule: createMockCSSStyleRule(`.rule-${idx}`),
|
||||
},
|
||||
),
|
||||
),
|
||||
});
|
||||
response.setIncludeCssStyles(mockStyles, {
|
||||
uid: '1_1',
|
||||
pageSize: 2,
|
||||
pageIdx: 0,
|
||||
});
|
||||
const {content} = await response.handle(context);
|
||||
const text = getTextContent(content[0]);
|
||||
assert.ok(text.includes('Showing 1-2 of 5'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {afterEach, describe, it} from 'node:test';
|
||||
|
||||
import sinon from 'sinon';
|
||||
|
||||
import {DevTools} from '../../src/third_party/index.js';
|
||||
import {getCssStyles} from '../../src/tools/css.js';
|
||||
import {createHandlerMocks} from '../mocks.js';
|
||||
|
||||
describe('get_css_styles', () => {
|
||||
afterEach(() => {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
it('retrieves matched styles for a uid and passes to response', async () => {
|
||||
const {page, context, response} = createHandlerMocks();
|
||||
const mockStyles = sinon.createStubInstance(
|
||||
DevTools.CSSMatchedStyles.CSSMatchedStyles,
|
||||
);
|
||||
page.getMatchedStylesForUid.resolves(mockStyles);
|
||||
|
||||
await getCssStyles.handler(
|
||||
{params: {uid: 'element-1'}, page},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
|
||||
sinon.assert.calledOnceWithExactly(
|
||||
page.getMatchedStylesForUid,
|
||||
'element-1',
|
||||
);
|
||||
sinon.assert.calledOnceWithExactly(
|
||||
response.setIncludeCssStyles,
|
||||
mockStyles,
|
||||
{
|
||||
uid: 'element-1',
|
||||
pageSize: undefined,
|
||||
pageIdx: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('passes pagination options to response', async () => {
|
||||
const {page, context, response} = createHandlerMocks();
|
||||
const mockStyles = sinon.createStubInstance(
|
||||
DevTools.CSSMatchedStyles.CSSMatchedStyles,
|
||||
);
|
||||
page.getMatchedStylesForUid.resolves(mockStyles);
|
||||
|
||||
await getCssStyles.handler(
|
||||
{params: {uid: 'element-1', pageSize: 10, pageIdx: 2}, page},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
|
||||
sinon.assert.calledOnceWithExactly(
|
||||
page.getMatchedStylesForUid,
|
||||
'element-1',
|
||||
);
|
||||
sinon.assert.calledOnceWithExactly(
|
||||
response.setIncludeCssStyles,
|
||||
mockStyles,
|
||||
{
|
||||
uid: 'element-1',
|
||||
pageSize: 10,
|
||||
pageIdx: 2,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user