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') +}