diff --git a/playwriter/src/mcp.test.ts b/playwriter/src/mcp.test.ts
index bae6c27..2c7375f 100644
--- a/playwriter/src/mcp.test.ts
+++ b/playwriter/src/mcp.test.ts
@@ -1550,6 +1550,193 @@ describe('MCP Server Tests', () => {
await page.close()
}, 60000)
+ it('should get styles for element using getStylesForLocator', async () => {
+ const browserContext = getBrowserContext()
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
+
+ const page = await browserContext.newPage()
+ await page.setContent(`
+
+
+
+
+
+
+
+
+
+
+ `)
+ await page.bringToFront()
+
+ await serviceWorker.evaluate(async () => {
+ await globalThis.toggleExtensionForActiveTab()
+ })
+
+ await new Promise(r => setTimeout(r, 400))
+
+ const stylesResult = await client.callTool({
+ name: 'execute',
+ arguments: {
+ code: js`
+ let testPage;
+ for (const p of context.pages()) {
+ const html = await p.content();
+ if (html.includes('main-btn')) { testPage = p; break; }
+ }
+ if (!testPage) throw new Error('Test page not found');
+ const btn = testPage.locator('#main-btn');
+ const styles = await getStylesForLocator({ locator: btn });
+ return styles;
+ `,
+ timeout: 30000,
+ },
+ })
+
+ expect(stylesResult.isError).toBeFalsy()
+ const stylesText = (stylesResult.content as any)[0]?.text || ''
+ expect(stylesText).toMatchInlineSnapshot(`
+ "Return value:
+ {
+ "element": "button#main-btn.btn",
+ "inlineStyle": {
+ "font-weight": "bold"
+ },
+ "rules": [
+ {
+ "selector": ".btn",
+ "source": null,
+ "origin": "regular",
+ "declarations": {
+ "padding": "8px 16px",
+ "padding-top": "8px",
+ "padding-right": "16px",
+ "padding-bottom": "8px",
+ "padding-left": "16px"
+ },
+ "inheritedFrom": null
+ },
+ {
+ "selector": "#main-btn",
+ "source": null,
+ "origin": "regular",
+ "declarations": {
+ "background-color": "blue",
+ "color": "white",
+ "border-radius": "4px",
+ "border-top-left-radius": "4px",
+ "border-top-right-radius": "4px",
+ "border-bottom-right-radius": "4px",
+ "border-bottom-left-radius": "4px"
+ },
+ "inheritedFrom": null
+ },
+ {
+ "selector": ".container",
+ "source": null,
+ "origin": "regular",
+ "declarations": {
+ "padding": "20px",
+ "margin": "10px",
+ "padding-top": "20px",
+ "padding-right": "20px",
+ "padding-bottom": "20px",
+ "padding-left": "20px",
+ "margin-top": "10px",
+ "margin-right": "10px",
+ "margin-bottom": "10px",
+ "margin-left": "10px"
+ },
+ "inheritedFrom": "ancestor[1]"
+ },
+ {
+ "selector": "body",
+ "source": null,
+ "origin": "regular",
+ "declarations": {
+ "font-family": "Arial, sans-serif",
+ "color": "rgb(51, 51, 51)"
+ },
+ "inheritedFrom": "ancestor[2]"
+ }
+ ]
+ }"
+ `)
+
+ const formattedResult = await client.callTool({
+ name: 'execute',
+ arguments: {
+ code: js`
+ let testPage;
+ for (const p of context.pages()) {
+ const html = await p.content();
+ if (html.includes('main-btn')) { testPage = p; break; }
+ }
+ if (!testPage) throw new Error('Test page not found');
+ const btn = testPage.locator('#main-btn');
+ const styles = await getStylesForLocator({ locator: btn });
+ return formatStylesAsText(styles);
+ `,
+ timeout: 30000,
+ },
+ })
+
+ expect(formattedResult.isError).toBeFalsy()
+ const formattedText = (formattedResult.content as any)[0]?.text || ''
+ expect(formattedText).toMatchInlineSnapshot(`
+ "Return value:
+ Element: button#main-btn.btn
+
+ Inline styles:
+ font-weight: bold
+
+ Matched rules:
+ .btn {
+ padding: 8px 16px;
+ padding-top: 8px;
+ padding-right: 16px;
+ padding-bottom: 8px;
+ padding-left: 16px;
+ }
+ #main-btn {
+ background-color: blue;
+ color: white;
+ border-radius: 4px;
+ border-top-left-radius: 4px;
+ border-top-right-radius: 4px;
+ border-bottom-right-radius: 4px;
+ border-bottom-left-radius: 4px;
+ }
+
+ Inherited from ancestor[1]:
+ .container {
+ padding: 20px;
+ margin: 10px;
+ padding-top: 20px;
+ padding-right: 20px;
+ padding-bottom: 20px;
+ padding-left: 20px;
+ margin-top: 10px;
+ margin-right: 10px;
+ margin-bottom: 10px;
+ margin-left: 10px;
+ }
+
+ Inherited from ancestor[2]:
+ body {
+ font-family: Arial, sans-serif;
+ color: rgb(51, 51, 51);
+ }"
+ `)
+
+ await page.close()
+ }, 60000)
+
it('should return correct layout metrics via CDP', async () => {
const browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext)
diff --git a/playwriter/src/mcp.ts b/playwriter/src/mcp.ts
index f602ea2..bfee227 100644
--- a/playwriter/src/mcp.ts
+++ b/playwriter/src/mcp.ts
@@ -18,6 +18,7 @@ import { waitForPageLoad, WaitForPageLoadOptions, WaitForPageLoadResult } from '
import { getCDPSessionForPage, CDPSession } from './cdp-session.js'
import { Debugger } from './debugger.js'
import { Editor } from './editor.js'
+import { getStylesForLocator, formatStylesAsText, type StylesResult } from './styles.js'
class CodeExecutionTimeoutError extends Error {
constructor(timeout: number) {
@@ -77,6 +78,8 @@ interface VMContext {
getCDPSession: (options: { page: Page }) => Promise
createDebugger: (options: { cdp: CDPSession }) => Debugger
createEditor: (options: { cdp: CDPSession }) => Editor
+ getStylesForLocator: (options: { locator: any }) => Promise
+ formatStylesAsText: (styles: StylesResult) => string
require: NodeRequire
import: (specifier: string) => Promise
}
@@ -701,6 +704,11 @@ server.tool(
return new Editor(options)
}
+ const getStylesForLocatorFn = async (options: { locator: any }) => {
+ const cdp = await getCDPSession({ page: options.locator.page() })
+ return getStylesForLocator({ locator: options.locator, cdp })
+ }
+
let vmContextObj: VMContextWithGlobals = {
page,
context,
@@ -714,6 +722,8 @@ server.tool(
getCDPSession,
createDebugger,
createEditor,
+ getStylesForLocator: getStylesForLocatorFn,
+ formatStylesAsText,
resetPlaywright: async () => {
const { page: newPage, context: newContext } = await resetConnection()
@@ -730,6 +740,8 @@ server.tool(
getCDPSession,
createDebugger,
createEditor,
+ getStylesForLocator: getStylesForLocatorFn,
+ formatStylesAsText,
resetPlaywright: vmContextObj.resetPlaywright,
require,
// TODO --experimental-vm-modules is needed to make import work in vm
diff --git a/playwriter/src/styles.ts b/playwriter/src/styles.ts
new file mode 100644
index 0000000..b055bf6
--- /dev/null
+++ b/playwriter/src/styles.ts
@@ -0,0 +1,343 @@
+import type { CDPSession } from './cdp-session.js'
+import type { Locator } from 'playwright-core'
+
+export interface StyleSource {
+ url: string
+ line: number
+ column: number
+}
+
+export type StyleDeclarations = Record
+
+export interface StyleRule {
+ selector: string
+ source: StyleSource | null
+ origin: 'regular' | 'user-agent' | 'injected' | 'inspector'
+ declarations: StyleDeclarations
+ inheritedFrom: string | null
+}
+
+export interface StylesResult {
+ element: string
+ inlineStyle: StyleDeclarations | null
+ rules: StyleRule[]
+}
+
+interface CSSProperty {
+ name: string
+ value: string
+ important?: boolean
+}
+
+interface CSSStyle {
+ cssProperties: CSSProperty[]
+ cssText?: string
+}
+
+interface CSSRule {
+ selectorList: { text: string }
+ style: CSSStyle
+ styleSheetId?: string
+ origin: string
+}
+
+interface RuleMatch {
+ rule: CSSRule
+ matchingSelectors: number[]
+}
+
+interface InheritedStyleEntry {
+ inlineStyle?: CSSStyle
+ matchedCSSRules: RuleMatch[]
+}
+
+interface SourceRange {
+ startLine: number
+ startColumn: number
+ endLine: number
+ endColumn: number
+}
+
+interface CSSStyleSheetHeader {
+ styleSheetId: string
+ sourceURL?: string
+ origin: string
+}
+
+export async function getStylesForLocator({
+ locator,
+ cdp,
+ includeUserAgentStyles = false,
+}: {
+ locator: Locator
+ cdp: CDPSession
+ includeUserAgentStyles?: boolean
+}): Promise {
+ await cdp.send('DOM.enable')
+ await cdp.send('CSS.enable')
+
+ const elementHandle = await locator.elementHandle()
+ if (!elementHandle) {
+ throw new Error('Could not get element handle from locator')
+ }
+
+ const remoteObject = (elementHandle as any)._channel?.objectId
+ ? { objectId: (elementHandle as any)._channel.objectId }
+ : null
+
+ let backendNodeId: number
+ if (remoteObject?.objectId) {
+ const nodeInfo = await cdp.send('DOM.describeNode', {
+ objectId: remoteObject.objectId,
+ })
+ backendNodeId = nodeInfo.node.backendNodeId
+ } else {
+ const box = await elementHandle.boundingBox()
+ if (!box) {
+ throw new Error('Element has no bounding box')
+ }
+ const docResult = await cdp.send('DOM.getDocument', { depth: 0 })
+ const nodeAtPoint = await cdp.send('DOM.getNodeForLocation', {
+ x: Math.round(box.x + box.width / 2),
+ y: Math.round(box.y + box.height / 2),
+ })
+ backendNodeId = nodeAtPoint.backendNodeId
+ }
+
+ const pushResult = await cdp.send('DOM.pushNodesByBackendIdsToFrontend', {
+ backendNodeIds: [backendNodeId],
+ })
+ const nodeId = pushResult.nodeIds[0]
+
+ if (!nodeId) {
+ throw new Error('Could not get nodeId for element')
+ }
+
+ const nodeInfo = await cdp.send('DOM.describeNode', { nodeId })
+ const elementDescription = formatElementDescription(nodeInfo.node)
+
+ const matchedStyles = await cdp.send('CSS.getMatchedStylesForNode', { nodeId })
+
+ const stylesheetUrls = new Map()
+
+ const processStyleSheetId = async (styleSheetId: string | undefined): Promise => {
+ if (!styleSheetId) {
+ return null
+ }
+
+ if (!stylesheetUrls.has(styleSheetId)) {
+ try {
+ const header = await cdp.send('CSS.getStyleSheetText', { styleSheetId })
+ stylesheetUrls.set(styleSheetId, '')
+ } catch {
+ stylesheetUrls.set(styleSheetId, '')
+ }
+ }
+
+ return null
+ }
+
+ const rules: StyleRule[] = []
+
+ if (matchedStyles.matchedCSSRules) {
+ for (const ruleMatch of matchedStyles.matchedCSSRules) {
+ const rule = ruleMatch.rule
+ const sourceRange = (rule as any).selectorList?.range as SourceRange | undefined
+ const styleSheetId = rule.styleSheetId
+
+ let source: StyleSource | null = null
+ if (styleSheetId && sourceRange) {
+ const styleSheet = (matchedStyles as any).cssStyleSheetHeaders?.find(
+ (h: CSSStyleSheetHeader) => h.styleSheetId === styleSheetId
+ )
+ const url = styleSheet?.sourceURL || (rule as any).origin === 'user-agent' ? 'user-agent' : `stylesheet:${styleSheetId}`
+
+ source = {
+ url: (rule as any).styleSheetId ? await getStylesheetUrl(cdp, styleSheetId) : 'user-agent',
+ line: sourceRange.startLine + 1,
+ column: sourceRange.startColumn,
+ }
+ }
+
+ rules.push({
+ selector: rule.selectorList.text,
+ source,
+ origin: rule.origin as StyleRule['origin'],
+ declarations: extractDeclarations(rule.style),
+ inheritedFrom: null,
+ })
+ }
+ }
+
+ if (matchedStyles.inherited) {
+ for (let i = 0; i < matchedStyles.inherited.length; i++) {
+ const inheritedEntry = matchedStyles.inherited[i] as InheritedStyleEntry
+ const ancestorDesc = `ancestor[${i + 1}]`
+
+ if (inheritedEntry.inlineStyle) {
+ const declarations = extractDeclarations(inheritedEntry.inlineStyle)
+ if (Object.keys(declarations).length > 0) {
+ rules.push({
+ selector: 'element.style',
+ source: null,
+ origin: 'regular',
+ declarations,
+ inheritedFrom: ancestorDesc,
+ })
+ }
+ }
+
+ for (const ruleMatch of inheritedEntry.matchedCSSRules) {
+ const rule = ruleMatch.rule
+ const sourceRange = (rule as any).selectorList?.range as SourceRange | undefined
+ const styleSheetId = rule.styleSheetId
+
+ let source: StyleSource | null = null
+ if (styleSheetId && sourceRange) {
+ source = {
+ url: await getStylesheetUrl(cdp, styleSheetId),
+ line: sourceRange.startLine + 1,
+ column: sourceRange.startColumn,
+ }
+ }
+
+ const declarations = extractDeclarations(rule.style)
+ if (Object.keys(declarations).length > 0) {
+ rules.push({
+ selector: rule.selectorList.text,
+ source,
+ origin: rule.origin as StyleRule['origin'],
+ declarations,
+ inheritedFrom: ancestorDesc,
+ })
+ }
+ }
+ }
+ }
+
+ let inlineStyle: StyleDeclarations | null = null
+ if (matchedStyles.inlineStyle) {
+ const declarations = extractDeclarations(matchedStyles.inlineStyle as CSSStyle)
+ if (Object.keys(declarations).length > 0) {
+ inlineStyle = declarations
+ }
+ }
+
+ const filteredRules = includeUserAgentStyles
+ ? rules
+ : rules.filter((r) => r.origin !== 'user-agent')
+
+ return {
+ element: elementDescription,
+ inlineStyle,
+ rules: filteredRules,
+ }
+}
+
+function extractDeclarations(style: CSSStyle): StyleDeclarations {
+ if (!style?.cssProperties) {
+ return {}
+ }
+
+ const result: StyleDeclarations = {}
+ for (const prop of style.cssProperties) {
+ if (!prop.value || prop.value === 'initial' || prop.name.startsWith('-webkit-')) {
+ continue
+ }
+ const value = prop.important ? `${prop.value} !important` : prop.value
+ result[prop.name] = value
+ }
+ return result
+}
+
+function formatElementDescription(node: any): string {
+ let desc = node.localName || node.nodeName?.toLowerCase() || 'element'
+
+ if (node.attributes) {
+ const attrs: Record = {}
+ for (let i = 0; i < node.attributes.length; i += 2) {
+ attrs[node.attributes[i]] = node.attributes[i + 1]
+ }
+
+ if (attrs.id) {
+ desc += `#${attrs.id}`
+ }
+ if (attrs.class) {
+ desc += `.${attrs.class.split(' ').join('.')}`
+ }
+ }
+
+ return desc
+}
+
+async function getStylesheetUrl(cdp: CDPSession, styleSheetId: string): Promise {
+ try {
+ await cdp.send('CSS.getStyleSheetText', { styleSheetId })
+ return `stylesheet:${styleSheetId}`
+ } catch {
+ return `stylesheet:${styleSheetId}`
+ }
+}
+
+export function formatStylesAsText(styles: StylesResult): string {
+ const lines: string[] = []
+
+ lines.push(`Element: ${styles.element}`)
+ lines.push('')
+
+ if (styles.inlineStyle) {
+ lines.push('Inline styles:')
+ for (const [prop, value] of Object.entries(styles.inlineStyle)) {
+ lines.push(` ${prop}: ${value}`)
+ }
+ lines.push('')
+ }
+
+ const directRules = styles.rules.filter((r) => !r.inheritedFrom)
+ const inheritedRules = styles.rules.filter((r) => r.inheritedFrom)
+
+ if (directRules.length > 0) {
+ lines.push('Matched rules:')
+ for (const rule of directRules) {
+ lines.push(` ${rule.selector} {`)
+ const sourceInfo = rule.source ? ` /* ${rule.source.url}:${rule.source.line}:${rule.source.column} */` : ''
+ if (sourceInfo) {
+ lines.push(` ${sourceInfo}`)
+ }
+ for (const [prop, value] of Object.entries(rule.declarations)) {
+ lines.push(` ${prop}: ${value};`)
+ }
+ lines.push(' }')
+ }
+ lines.push('')
+ }
+
+ if (inheritedRules.length > 0) {
+ const byAncestor = new Map()
+ for (const rule of inheritedRules) {
+ const key = rule.inheritedFrom!
+ if (!byAncestor.has(key)) {
+ byAncestor.set(key, [])
+ }
+ byAncestor.get(key)!.push(rule)
+ }
+
+ for (const [ancestor, rules] of byAncestor) {
+ lines.push(`Inherited from ${ancestor}:`)
+ for (const rule of rules) {
+ lines.push(` ${rule.selector} {`)
+ const sourceInfo = rule.source ? ` /* ${rule.source.url}:${rule.source.line}:${rule.source.column} */` : ''
+ if (sourceInfo) {
+ lines.push(` ${sourceInfo}`)
+ }
+ for (const [prop, value] of Object.entries(rule.declarations)) {
+ lines.push(` ${prop}: ${value};`)
+ }
+ lines.push(' }')
+ }
+ lines.push('')
+ }
+ }
+
+ return lines.join('\n')
+}