add support for styles
This commit is contained in:
@@ -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(`
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; color: #333; }
|
||||
.container { padding: 20px; margin: 10px; }
|
||||
#main-btn { background-color: blue; color: white; border-radius: 4px; }
|
||||
.btn { padding: 8px 16px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<button id="main-btn" class="btn" style="font-weight: bold;">Click Me</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
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)
|
||||
|
||||
@@ -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<CDPSession>
|
||||
createDebugger: (options: { cdp: CDPSession }) => Debugger
|
||||
createEditor: (options: { cdp: CDPSession }) => Editor
|
||||
getStylesForLocator: (options: { locator: any }) => Promise<StylesResult>
|
||||
formatStylesAsText: (styles: StylesResult) => string
|
||||
require: NodeRequire
|
||||
import: (specifier: string) => Promise<any>
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string>
|
||||
|
||||
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<StylesResult> {
|
||||
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<string, string>()
|
||||
|
||||
const processStyleSheetId = async (styleSheetId: string | undefined): Promise<StyleSource | null> => {
|
||||
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<string, string> = {}
|
||||
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<string> {
|
||||
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<string, StyleRule[]>()
|
||||
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')
|
||||
}
|
||||
Reference in New Issue
Block a user