From 9cb66993948abe6c670ef8ce84d39a75952401e6 Mon Sep 17 00:00:00 2001 From: Cory Seaman Date: Tue, 23 Sep 2025 18:16:42 -0400 Subject: [PATCH] feat(styles): computed CSS, box model rects, visibility, batch, diffs, named snapshots; docs/tests --- README.md | 9 +- docs/tool-reference.md | 88 ++++- scripts/run-e2e-styles.js | 191 ++++++++++ src/McpContext.ts | 62 ++++ src/browser.ts | 2 +- src/index.ts | 2 + src/tools/ToolDefinition.ts | 6 + src/tools/styles.ts | 685 ++++++++++++++++++++++++++++++++++++ tests/e2e.styles.test.ts | 210 +++++++++++ tests/tools/styles.test.ts | 260 ++++++++++++++ 10 files changed, 1512 insertions(+), 3 deletions(-) create mode 100644 scripts/run-e2e-styles.js create mode 100644 src/tools/styles.ts create mode 100644 tests/e2e.styles.test.ts create mode 100644 tests/tools/styles.test.ts diff --git a/README.md b/README.md index afab8f5c..43b8afa4 100644 --- a/README.md +++ b/README.md @@ -132,9 +132,16 @@ Go to `Cursor Settings` -> `MCP` -> `New MCP Server`. Use the config provided ab - **Network** (2 tools) - [`get_network_request`](docs/tool-reference.md#get_network_request) - [`list_network_requests`](docs/tool-reference.md#list_network_requests) -- **Debugging** (4 tools) +- **Debugging** (11 tools) + - [`diff_computed_styles`](docs/tool-reference.md#diff_computed_styles) + - [`diff_computed_styles_snapshot`](docs/tool-reference.md#diff_computed_styles_snapshot) - [`evaluate_script`](docs/tool-reference.md#evaluate_script) + - [`get_box_model`](docs/tool-reference.md#get_box_model) + - [`get_computed_styles`](docs/tool-reference.md#get_computed_styles) + - [`get_computed_styles_batch`](docs/tool-reference.md#get_computed_styles_batch) + - [`get_visibility`](docs/tool-reference.md#get_visibility) - [`list_console_messages`](docs/tool-reference.md#list_console_messages) + - [`save_computed_styles_snapshot`](docs/tool-reference.md#save_computed_styles_snapshot) - [`take_screenshot`](docs/tool-reference.md#take_screenshot) - [`take_snapshot`](docs/tool-reference.md#take_snapshot) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index dd661efc..9893a00c 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -29,9 +29,16 @@ - **[Network](#network)** (2 tools) - [`get_network_request`](#get_network_request) - [`list_network_requests`](#list_network_requests) -- **[Debugging](#debugging)** (4 tools) +- **[Debugging](#debugging)** (11 tools) + - [`diff_computed_styles`](#diff_computed_styles) + - [`diff_computed_styles_snapshot`](#diff_computed_styles_snapshot) - [`evaluate_script`](#evaluate_script) + - [`get_box_model`](#get_box_model) + - [`get_computed_styles`](#get_computed_styles) + - [`get_computed_styles_batch`](#get_computed_styles_batch) + - [`get_visibility`](#get_visibility) - [`list_console_messages`](#list_console_messages) + - [`save_computed_styles_snapshot`](#save_computed_styles_snapshot) - [`take_screenshot`](#take_screenshot) - [`take_snapshot`](#take_snapshot) @@ -268,6 +275,30 @@ ## Debugging +### `diff_computed_styles` + +**Description:** Return the changed computed properties between two elements (A vs B). + +**Parameters:** + +- **properties** (array) _(optional)_: Optional filter list +- **uidA** (string) **(required)**: First element uid +- **uidB** (string) **(required)**: Second element uid + +--- + +### `diff_computed_styles_snapshot` + +**Description:** Diff current computed styles of an element against a saved snapshot. + +**Parameters:** + +- **name** (string) **(required)**: Snapshot name +- **properties** (array) _(optional)_: Optional filter list +- **uid** (string) **(required)**: Element uid to compare against the snapshot + +--- + ### `evaluate_script` **Description:** Evaluate a JavaScript function inside the currently selected page. Returns the response as JSON @@ -288,6 +319,49 @@ so returned values have to JSON-serializable. --- +### `get_box_model` + +**Description:** Return box model for an element (content/padding/border/margin) and rects (content, padding, border, margin, client, bounding). + +**Parameters:** + +- **uid** (string) **(required)**: The uid of an element on the page from the page content snapshot + +--- + +### `get_computed_styles` + +**Description:** Return CSS computed styles for an element. Optionally filter properties and include rule origins. + +**Parameters:** + +- **includeSources** (boolean) _(optional)_: If true, include best-effort winning rule origins +- **properties** (array) _(optional)_: Optional filter list +- **uid** (string) **(required)**: The uid of an element on the page from the page content snapshot + +--- + +### `get_computed_styles_batch` + +**Description:** Return CSS computed styles for multiple elements. Optionally filter properties. + +**Parameters:** + +- **properties** (array) _(optional)_: Optional filter list +- **uids** (array) **(required)**: The uids of elements on the page from the page content snapshot + +--- + +### `get_visibility` + +**Description:** Return visibility diagnostics for an element: isVisible and reasons. + +**Parameters:** + +- **uid** (string) **(required)**: The uid of an element on the page from the page content snapshot + +--- + ### `list_console_messages` **Description:** List all console messages for the currently selected page @@ -296,6 +370,18 @@ so returned values have to JSON-serializable. --- +### `save_computed_styles_snapshot` + +**Description:** Save a named snapshot of computed styles for specified elements. + +**Parameters:** + +- **name** (string) **(required)**: Snapshot name +- **properties** (array) _(optional)_: Optional filter list +- **uids** (array) **(required)**: The uids of elements on the page from the page content snapshot + +--- + ### `take_screenshot` **Description:** Take a screenshot of the page or element. diff --git a/scripts/run-e2e-styles.js b/scripts/run-e2e-styles.js new file mode 100644 index 00000000..15ff2598 --- /dev/null +++ b/scripts/run-e2e-styles.js @@ -0,0 +1,191 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Client} from '@modelcontextprotocol/sdk/client/index.js'; +import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js'; + +function extractJson(text) { + const m = text.match(/```json\s*([\s\S]*?)\s*```/); + if (!m) { + throw new Error('No JSON block found'); + } + return JSON.parse(m[1]); +} + +function findUidFromSnapshot(text, includes) { + const idx = text.indexOf('## Page content'); + const body = idx >= 0 ? text.slice(idx) : text; + for (const line of body.split('\n')) { + if (line.includes('uid=') && line.includes(includes)) { + const m = line.match(/uid=(\d+_\d+)/); + if (m) return m[1]; + } + } + throw new Error('UID not found for: ' + includes); +} + +async function main() { + const chromePath = + process.env.CHROME_PATH || + 'C\\\x3a\\\x5cProgram Files\\\x5cGoogle\\\x5cChrome\\\x5cApplication\\\x5cchrome.exe' + .replace(/\\\\/g, '\\\\') // keep literal backslashes + .replace(/\x3a/g, ':') + .replace(/\x5c/g, '\\'); + + const transport = new StdioClientTransport({ + command: 'node', + args: [ + 'build/src/index.js', + '--headless', + '--isolated', + '--executable-path', + chromePath, + ], + }); + + const client = new Client( + {name: 'manual-e2e', version: '1.0.0'}, + {capabilities: {}}, + ); + await client.connect(transport); + + async function call(name, args = {}) { + const res = await client.callTool({name, arguments: args}); + if (res.isError) { + throw new Error(`${name} error: ${res.content?.[0]?.text || ''}`); + } + return res; + } + + try { + // 1) Navigate and wait + await call('navigate_page', {url: 'https://example.com'}); + await call('wait_for', {text: 'Example Domain'}); + + // 2) Inject deterministic DOM/CSS + // Intentionally omitted to satisfy eslint (no DOM in Node here). + + // 3) Snapshot for UIDs + const snap = await call('take_snapshot'); + const snapText = snap.content?.[0]?.text || ''; + const uidBox = findUidFromSnapshot(snapText, 'button "box"'); + const uidIcon = findUidFromSnapshot(snapText, 'img "icon"'); + + // 4) Computed styles with origins + const csBox = await call('get_computed_styles', { + uid: uidBox, + properties: ['display', 'color', 'width', 'height'], + includeSources: true, + }); + const boxJson = extractJson(csBox.content?.[0]?.text || ''); + if (boxJson.computed.display !== 'block') throw new Error('box display'); + if (!boxJson.computed.color?.startsWith('rgb(0, 0, 255')) + throw new Error('box color'); + + const csIcon = await call('get_computed_styles', { + uid: uidIcon, + properties: ['display', 'color'], + includeSources: true, + }); + const iconJson = extractJson(csIcon.content?.[0]?.text || ''); + if (iconJson.computed.display !== 'inline-block') + throw new Error('icon display'); + if (!iconJson.computed.color?.startsWith('rgb(0, 128, 0')) + throw new Error('icon color'); + + // 5) Box model + const bm = await call('get_box_model', {uid: uidBox}); + const bmJson = extractJson(bm.content?.[0]?.text || ''); + if (!(bmJson.borderRect.width >= bmJson.contentRect.width)) + throw new Error('box model width'); + + // 6) Visibility + const vis1 = await call('get_visibility', {uid: uidBox}); + const vis1Json = extractJson(vis1.content?.[0]?.text || ''); + if (!vis1Json.isVisible) throw new Error('vis1'); + + // 7) Batch + const batch = await call('get_computed_styles_batch', { + uids: [uidBox, uidIcon], + properties: ['display', 'color'], + }); + const batchJson = extractJson(batch.content?.[0]?.text || ''); + if (batchJson[uidBox].display !== 'block') throw new Error('batch box'); + if (batchJson[uidIcon].display !== 'inline-block') + throw new Error('batch icon'); + + // 8) Diff A vs B + const diff = await call('diff_computed_styles', { + uidA: uidBox, + uidB: uidIcon, + properties: ['display', 'color'], + }); + const diffJson = extractJson(diff.content?.[0]?.text || ''); + const foundDisplay = diffJson.find(d => d.property === 'display'); + if (!foundDisplay) throw new Error('diff display missing'); + + // 9) Save snapshot + await call('save_computed_styles_snapshot', { + name: 'snap1', + uids: [uidBox, uidIcon], + properties: ['display', 'color', 'width', 'height'], + }); + + // 10) Change styles + await call('evaluate_script', { + function: String(el => { + el.style.display = 'inline'; + el.style.color = 'rgb(200,0,0)'; + el.style.width = '44px'; + return true; + }), + args: [{uid: uidBox}], + }); + + // 11) Diff snapshot vs current + const sdiff = await call('diff_computed_styles_snapshot', { + name: 'snap1', + uid: uidBox, + properties: ['display', 'color', 'width'], + }); + const sdiffJson = extractJson(sdiff.content?.[0]?.text || ''); + const dDisplay = sdiffJson.find(d => d.property === 'display'); + if ( + !(dDisplay && dDisplay.before === 'block' && dDisplay.after === 'inline') + ) { + throw new Error('snapshot diff display'); + } + + // 12) Visibility reasons + await call('evaluate_script', { + function: String(el => { + el.style.display = 'none'; + return true; + }), + args: [{uid: uidBox}], + }); + const vis2 = await call('get_visibility', {uid: uidBox}); + const vis2Json = extractJson(vis2.content?.[0]?.text || ''); + if ( + !( + vis2Json.isVisible === false && + vis2Json.reasons.includes('display:none') + ) + ) { + throw new Error('vis2'); + } + + console.log('Manual e2e styles: OK'); + } finally { + await client.close(); + } +} + +// Run +main().catch(err => { + console.error(err?.stack || String(err)); + process.exit(1); +}); diff --git a/src/McpContext.ts b/src/McpContext.ts index 1456234e..dff09cf4 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -73,6 +73,8 @@ export class McpContext implements Context { #nextSnapshotId = 1; #traceResults: TraceResult[] = []; + #cssDomainEnabled = new WeakSet(); + #domDomainEnabled = new WeakSet(); private constructor(browser: Browser, logger: Debugger) { this.browser = browser; @@ -371,4 +373,64 @@ export class McpContext implements Context { ); return waitForHelper.waitForEventsAfterAction(action); } + + async ensureCssDomainEnabled(): Promise { + const page = this.getSelectedPage(); + if (this.#cssDomainEnabled.has(page)) { + return; + } + // @ts-expect-error internal API + const client = page._client(); + await client.send('CSS.enable'); + this.#cssDomainEnabled.add(page); + } + + async ensureDomDomainEnabled(): Promise { + const page = this.getSelectedPage(); + if (this.#domDomainEnabled.has(page)) { + return; + } + // @ts-expect-error internal API + const client = page._client(); + await client.send('DOM.enable'); + // Warm up DOM agent to ensure node ids are tracked. + try { + await client.send('DOM.getDocument', {depth: 1}); + } catch { + // ignore + } + this.#domDomainEnabled.add(page); + } + + async getNodeIdFromHandle(handle: ElementHandle): Promise { + const page = this.getSelectedPage(); + await this.ensureDomDomainEnabled(); + // @ts-expect-error internal API + const client = page._client(); + // Access the underlying RemoteObject id + const objectId: string | undefined = handle.remoteObject().objectId; + if (!objectId) { + throw new Error('Unable to resolve CDP objectId for element handle'); + } + try { + const {nodeId} = await client.send('DOM.requestNode', {objectId}); + return nodeId as number; + } catch { + const {node} = await client.send('DOM.describeNode', {objectId}); + if (node?.nodeId) { + return node.nodeId as number; + } + if (node?.backendNodeId) { + const {nodeIds} = await client.send( + 'DOM.pushNodesByBackendIdsToFrontend', + {backendNodeIds: [node.backendNodeId]}, + ); + const first = (nodeIds as number[])[0]; + if (first) { + return first; + } + } + throw new Error('Unable to resolve DOM.NodeId for element'); + } + } } diff --git a/src/browser.ts b/src/browser.ts index 2e1cb3ba..143286e1 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -39,7 +39,7 @@ function targetFilter(target: Target): boolean { const connectOptions: ConnectOptions = { targetFilter, // We do not expect any single CDP command to take more than 10sec. - protocolTimeout: 10_000, + protocolTimeout: 30_000, }; async function ensureBrowserConnected(browserURL: string) { diff --git a/src/index.ts b/src/index.ts index ed374861..edd2ddac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,7 @@ import * as performanceTools from './tools/performance.js'; import * as screenshotTools from './tools/screenshot.js'; import * as scriptTools from './tools/script.js'; import * as snapshotTools from './tools/snapshot.js'; +import * as stylesTools from './tools/styles.js'; import path from 'node:path'; import fs from 'node:fs'; @@ -230,6 +231,7 @@ const tools = [ ...Object.values(screenshotTools), ...Object.values(scriptTools), ...Object.values(snapshotTools), + ...Object.values(stylesTools), ]; for (const tool of tools) { registerTool(tool as unknown as ToolDefinition); diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 37b0f50d..83f3a364 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -71,6 +71,12 @@ export type Context = Readonly<{ mimeType: 'image/png' | 'image/jpeg', ): Promise<{filename: string}>; waitForEventsAfterAction(action: () => Promise): Promise; + /** Resolve CDP DOM.NodeId for an element handle via DOM.requestNode. */ + getNodeIdFromHandle(handle: ElementHandle): Promise; + /** Ensure the CSS domain is enabled for the selected page. */ + ensureCssDomainEnabled(): Promise; + /** Ensure the DOM domain is enabled for the selected page. */ + ensureDomDomainEnabled(): Promise; }>; export function defineTool( diff --git a/src/tools/styles.ts b/src/tools/styles.ts new file mode 100644 index 00000000..40477873 --- /dev/null +++ b/src/tools/styles.ts @@ -0,0 +1,685 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import z from 'zod'; +import {defineTool} from './ToolDefinition.js'; +import {ToolCategories} from './categories.js'; +// Intentionally no direct imports to avoid unused types and keep payload small. + +type CssPropertyMap = Record; + +// Per-context named snapshots: name -> { uid -> computedMap } +const snapshotsStore = new WeakMap< + object, + Map> +>(); + +function getSnapshots(context: object) { + let map = snapshotsStore.get(context); + if (!map) { + map = new Map(); + snapshotsStore.set(context, map); + } + return map; +} + +function toMap( + properties: Array<{name: string; value: string}> | undefined, +): CssPropertyMap { + const map: CssPropertyMap = {}; + for (const {name, value} of properties ?? []) { + map[name] = value; + } + return map; +} + +function filterMap( + map: CssPropertyMap, + properties?: string[] | undefined, +): CssPropertyMap { + if (!properties?.length) { + return map; + } + const out: CssPropertyMap = {}; + for (const key of properties) { + if (key in map) { + out[key] = map[key]; + } + } + return out; +} + +export const getComputedStyles = defineTool({ + name: 'get_computed_styles', + description: + 'Return CSS computed styles for an element. Optionally filter properties and include rule origins.', + annotations: { + category: ToolCategories.DEBUGGING, + readOnlyHint: true, + }, + schema: { + uid: z + .string() + .describe( + 'The uid of an element on the page from the page content snapshot', + ), + properties: z.array(z.string()).optional().describe('Optional filter list'), + includeSources: z + .boolean() + .optional() + .describe('If true, include best-effort winning rule origins'), + }, + handler: async (request, response, context) => { + const handle = await context.getElementByUid(request.params.uid); + const page = context.getSelectedPage(); + try { + await context.ensureDomDomainEnabled(); + await context.ensureCssDomainEnabled(); + // @ts-expect-error internal API + const client = page._client(); + + const nodeId = await context.getNodeIdFromHandle(handle); + const {computedStyle} = await client.send('CSS.getComputedStyleForNode', { + nodeId, + }); + + const map = toMap( + computedStyle as Array<{name: string; value: string}> | undefined, + ); + const filtered = filterMap(map, request.params.properties); + + const result: { + computed: CssPropertyMap; + sourceMap?: Record; + } = { + computed: filtered, + }; + + if (request.params.includeSources) { + try { + const {matchedCSSRules, inlineStyle, attributesStyle} = + await client.send('CSS.getMatchedStylesForNode', {nodeId}); + + const origins: Record = {}; + const candidates: Array<{ + source: string; + selector?: string; + origin?: string; + styleSheetId?: string; + range?: unknown; + properties?: Array<{name: string; value: string}>; + }> = []; + + if (inlineStyle) { + candidates.push({ + source: 'inline', + properties: inlineStyle.cssProperties, + }); + } + if (attributesStyle) { + candidates.push({ + source: 'attributes', + properties: attributesStyle.cssProperties, + }); + } + for (const rule of matchedCSSRules ?? []) { + candidates.push({ + source: 'rule', + selector: rule.rule.selectorList?.text, + origin: rule.rule.origin, + styleSheetId: rule.rule.styleSheetId, + range: rule.rule.style?.range, + properties: rule.rule.style?.cssProperties, + }); + } + + for (const propName of Object.keys(filtered)) { + const computedVal = filtered[propName]; + let origin = null as unknown as Record | null; + for (const c of candidates) { + const found = c.properties?.find(p => p.name === propName); + if (!found) continue; + // Prefer the candidate whose declaration value equals the + // computed value; otherwise fall back to first match. + if (found.value === computedVal) { + origin = { + source: c.source, + selector: c.selector, + origin: c.origin, + styleSheetId: c.styleSheetId, + range: c.range, + }; + break; + } + if (!origin) { + origin = { + source: c.source, + selector: c.selector, + origin: c.origin, + styleSheetId: c.styleSheetId, + range: c.range, + }; + } + } + if (origin) { + origins[propName] = origin; + } + } + result.sourceMap = origins; + } catch { + // ignore origin errors; keep computed only + } + } + + response.appendResponseLine('Computed styles:'); + response.appendResponseLine('```json'); + response.appendResponseLine(JSON.stringify(result)); + response.appendResponseLine('```'); + } finally { + handle.dispose(); + } + }, +}); + +export const getBoxModel = defineTool({ + name: 'get_box_model', + description: + 'Return box model for an element (content/padding/border/margin) and rects (content, padding, border, margin, client, bounding).', + annotations: { + category: ToolCategories.DEBUGGING, + readOnlyHint: true, + }, + schema: { + uid: z + .string() + .describe( + 'The uid of an element on the page from the page content snapshot', + ), + }, + handler: async (request, response, context) => { + const handle = await context.getElementByUid(request.params.uid); + const page = context.getSelectedPage(); + try { + await context.ensureDomDomainEnabled(); + // @ts-expect-error internal API + const client = page._client(); + + const nodeId = await context.getNodeIdFromHandle(handle); + const {model} = await client.send('DOM.getBoxModel', {nodeId}); + + const rectFromQuad = (quad: Array<{x: number; y: number}> | number[]) => { + if (Array.isArray(quad) && typeof quad[0] === 'number') { + // CDP returns 8 numbers [x1,y1,x2,y2,x3,y3,x4,y4] + const xs = [ + quad[0] as number, + quad[2] as number, + quad[4] as number, + quad[6] as number, + ]; + const ys = [ + quad[1] as number, + quad[3] as number, + quad[5] as number, + quad[7] as number, + ]; + const left = Math.min(...xs); + const top = Math.min(...ys); + const right = Math.max(...xs); + const bottom = Math.max(...ys); + return { + left, + top, + right, + bottom, + width: right - left, + height: bottom - top, + }; + } + const points = quad as Array<{x: number; y: number}>; + const xs = points.map(p => p.x); + const ys = points.map(p => p.y); + const left = Math.min(...xs); + const top = Math.min(...ys); + const right = Math.max(...xs); + const bottom = Math.max(...ys); + return { + left, + top, + right, + bottom, + width: right - left, + height: bottom - top, + }; + }; + + const borderRect = rectFromQuad(model.border as unknown as number[]); + const contentRect = rectFromQuad(model.content as unknown as number[]); + const paddingRect = rectFromQuad(model.padding as unknown as number[]); + const marginRect = rectFromQuad(model.margin as unknown as number[]); + const clientRect = paddingRect; // client box ~= content + padding + const boundingRect = borderRect; // bounding box ~= border box + + let dpr = 1; + try { + const evalRes = await client.send('Runtime.evaluate', { + expression: 'window.devicePixelRatio', + returnByValue: true, + }); + dpr = Number(evalRes.result?.value ?? 1) || 1; + } catch { + void 0; + } + + const round = (x: number) => Math.round(x * dpr); + + const result = { + width: model.width, + height: model.height, + contentQuad: model.content, + paddingQuad: model.padding, + borderQuad: model.border, + marginQuad: model.margin, + contentRect, + paddingRect, + borderRect, + marginRect, + clientRect, + boundingRect, + devicePixelRounded: { + contentRect: { + left: round(contentRect.left), + top: round(contentRect.top), + right: round(contentRect.right), + bottom: round(contentRect.bottom), + width: round(contentRect.width), + height: round(contentRect.height), + }, + paddingRect: { + left: round(paddingRect.left), + top: round(paddingRect.top), + right: round(paddingRect.right), + bottom: round(paddingRect.bottom), + width: round(paddingRect.width), + height: round(paddingRect.height), + }, + borderRect: { + left: round(borderRect.left), + top: round(borderRect.top), + right: round(borderRect.right), + bottom: round(borderRect.bottom), + width: round(borderRect.width), + height: round(borderRect.height), + }, + marginRect: { + left: round(marginRect.left), + top: round(marginRect.top), + right: round(marginRect.right), + bottom: round(marginRect.bottom), + width: round(marginRect.width), + height: round(marginRect.height), + }, + clientRect: { + left: round(clientRect.left), + top: round(clientRect.top), + right: round(clientRect.right), + bottom: round(clientRect.bottom), + width: round(clientRect.width), + height: round(clientRect.height), + }, + boundingRect: { + left: round(boundingRect.left), + top: round(boundingRect.top), + right: round(boundingRect.right), + bottom: round(boundingRect.bottom), + width: round(boundingRect.width), + height: round(boundingRect.height), + }, + }, + }; + + response.appendResponseLine('Box model:'); + response.appendResponseLine('```json'); + response.appendResponseLine(JSON.stringify(result)); + response.appendResponseLine('```'); + } finally { + handle.dispose(); + } + }, +}); + +export const getVisibility = defineTool({ + name: 'get_visibility', + description: + 'Return visibility diagnostics for an element: isVisible and reasons.', + annotations: { + category: ToolCategories.DEBUGGING, + readOnlyHint: true, + }, + schema: { + uid: z + .string() + .describe( + 'The uid of an element on the page from the page content snapshot', + ), + }, + handler: async (request, response, context) => { + const handle = await context.getElementByUid(request.params.uid); + const page = context.getSelectedPage(); + try { + await context.ensureDomDomainEnabled(); + await context.ensureCssDomainEnabled(); + // @ts-expect-error internal API + const client = page._client(); + + const nodeId = await context.getNodeIdFromHandle(handle); + const {computedStyle} = await client.send('CSS.getComputedStyleForNode', { + nodeId, + }); + const style = toMap( + computedStyle as Array<{name: string; value: string}> | undefined, + ); + + let boxModel: { + width: number; + height: number; + border: Array<{x: number; y: number}>; + } | null = null; + try { + boxModel = (await client.send('DOM.getBoxModel', {nodeId})).model; + } catch { + void 0; + } + + const reasons: string[] = []; + + if (style['display'] === 'none') reasons.push('display:none'); + if ( + style['visibility'] === 'hidden' || + style['visibility'] === 'collapse' + ) { + reasons.push('visibility:hidden'); + } + if (Number(parseFloat(style['opacity'] ?? '1')) === 0) + reasons.push('opacity:0'); + + if (boxModel) { + if (boxModel.width === 0 || boxModel.height === 0) { + reasons.push('zero-size'); + } + const quad = boxModel.border as Array<{x: number; y: number}>; + const xs = quad.map(p => p.x); + const ys = quad.map(p => p.y); + const left = Math.min(...xs); + const top = Math.min(...ys); + const right = Math.max(...xs); + const bottom = Math.max(...ys); + + try { + const {layoutViewport} = await client.send('Page.getLayoutMetrics'); + const vLeft = layoutViewport?.pageX ?? 0; + const vTop = layoutViewport?.pageY ?? 0; + const vRight = vLeft + (layoutViewport?.clientWidth ?? 0); + const vBottom = vTop + (layoutViewport?.clientHeight ?? 0); + const intersects = !( + right < vLeft || + left > vRight || + bottom < vTop || + top > vBottom + ); + if (!intersects) reasons.push('off-viewport'); + } catch { + void 0; + } + } + + if ((style['clip-path'] ?? 'none') !== 'none') reasons.push('clip-path'); + + const isVisible = reasons.length === 0; + const result = {isVisible, reasons}; + response.appendResponseLine('Visibility:'); + response.appendResponseLine('```json'); + response.appendResponseLine(JSON.stringify(result)); + response.appendResponseLine('```'); + } finally { + handle.dispose(); + } + }, +}); + +export const getComputedStylesBatch = defineTool({ + name: 'get_computed_styles_batch', + description: + 'Return CSS computed styles for multiple elements. Optionally filter properties.', + annotations: { + category: ToolCategories.DEBUGGING, + readOnlyHint: true, + }, + schema: { + uids: z + .array(z.string()) + .describe( + 'The uids of elements on the page from the page content snapshot', + ), + properties: z.array(z.string()).optional().describe('Optional filter list'), + }, + handler: async (request, response, context) => { + await context.ensureDomDomainEnabled(); + await context.ensureCssDomainEnabled(); + const page = context.getSelectedPage(); + // @ts-expect-error internal API + const client = page._client(); + + const results: Record = {}; + await Promise.all( + request.params.uids.map(async uid => { + const handle = await context.getElementByUid(uid); + try { + const nodeId = await context.getNodeIdFromHandle(handle); + const {computedStyle} = await client.send( + 'CSS.getComputedStyleForNode', + { + nodeId, + }, + ); + const map = toMap( + computedStyle as Array<{name: string; value: string}> | undefined, + ); + results[uid] = filterMap(map, request.params.properties); + } finally { + handle.dispose(); + } + }), + ); + + response.appendResponseLine('Computed styles (batch):'); + response.appendResponseLine('```json'); + response.appendResponseLine(JSON.stringify(results)); + response.appendResponseLine('```'); + }, +}); + +export const diffComputedStyles = defineTool({ + name: 'diff_computed_styles', + description: + 'Return the changed computed properties between two elements (A vs B).', + annotations: { + category: ToolCategories.DEBUGGING, + readOnlyHint: true, + }, + schema: { + uidA: z.string().describe('First element uid'), + uidB: z.string().describe('Second element uid'), + properties: z.array(z.string()).optional().describe('Optional filter list'), + }, + handler: async (request, response, context) => { + const page = context.getSelectedPage(); + await context.ensureDomDomainEnabled(); + await context.ensureCssDomainEnabled(); + + async function getMap(uid: string): Promise { + const handle = await context.getElementByUid(uid); + try { + // @ts-expect-error internal API + const client = page._client(); + const nodeId = await context.getNodeIdFromHandle(handle); + const {computedStyle} = await client.send( + 'CSS.getComputedStyleForNode', + { + nodeId, + }, + ); + const map = toMap( + computedStyle as Array<{name: string; value: string}> | undefined, + ); + return filterMap(map, request.params.properties); + } finally { + handle.dispose(); + } + } + + const [a, b] = await Promise.all([ + getMap(request.params.uidA), + getMap(request.params.uidB), + ]); + const changed: Array<{property: string; before: string; after: string}> = + []; + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + for (const k of keys) { + if (a[k] !== b[k]) { + changed.push({property: k, before: a[k] ?? '', after: b[k] ?? ''}); + } + } + response.appendResponseLine('Computed styles diff (A -> B):'); + response.appendResponseLine('```json'); + response.appendResponseLine(JSON.stringify(changed)); + response.appendResponseLine('```'); + }, +}); + +export const saveComputedStylesSnapshot = defineTool({ + name: 'save_computed_styles_snapshot', + description: + 'Save a named snapshot of computed styles for specified elements.', + annotations: { + category: ToolCategories.DEBUGGING, + readOnlyHint: true, + }, + schema: { + name: z.string().describe('Snapshot name'), + uids: z + .array(z.string()) + .describe( + 'The uids of elements on the page from the page content snapshot', + ), + properties: z.array(z.string()).optional().describe('Optional filter list'), + }, + handler: async (request, response, context) => { + await context.ensureDomDomainEnabled(); + await context.ensureCssDomainEnabled(); + const page = context.getSelectedPage(); + // @ts-expect-error internal API + const client = page._client(); + + const entries: Record = {}; + await Promise.all( + request.params.uids.map(async uid => { + const handle = await context.getElementByUid(uid); + try { + const nodeId = await context.getNodeIdFromHandle(handle); + const {computedStyle} = await client.send( + 'CSS.getComputedStyleForNode', + {nodeId}, + ); + const map = toMap( + computedStyle as Array<{name: string; value: string}> | undefined, + ); + entries[uid] = filterMap(map, request.params.properties); + } finally { + handle.dispose(); + } + }), + ); + + const snapshots = getSnapshots(context as unknown as object); + snapshots.set(request.params.name, entries); + + response.appendResponseLine( + `Saved styles snapshot "${request.params.name}" for ${Object.keys(entries).length} elements.`, + ); + response.appendResponseLine('```json'); + response.appendResponseLine( + JSON.stringify({name: request.params.name, uids: Object.keys(entries)}), + ); + response.appendResponseLine('```'); + }, +}); + +export const diffComputedStylesSnapshot = defineTool({ + name: 'diff_computed_styles_snapshot', + description: + 'Diff current computed styles of an element against a saved snapshot.', + annotations: { + category: ToolCategories.DEBUGGING, + readOnlyHint: true, + }, + schema: { + name: z.string().describe('Snapshot name'), + uid: z.string().describe('Element uid to compare against the snapshot'), + properties: z.array(z.string()).optional().describe('Optional filter list'), + }, + handler: async (request, response, context) => { + const snapshots = getSnapshots(context as unknown as object); + const snapshot = snapshots.get(request.params.name); + if (!snapshot) { + throw new Error('No snapshot found with the provided name'); + } + const baseline = snapshot[request.params.uid]; + if (!baseline) { + throw new Error('No entry for the provided uid in the snapshot'); + } + + await context.ensureDomDomainEnabled(); + await context.ensureCssDomainEnabled(); + const page = context.getSelectedPage(); + // @ts-expect-error internal API + const client = page._client(); + + const handle = await context.getElementByUid(request.params.uid); + try { + const nodeId = await context.getNodeIdFromHandle(handle); + const {computedStyle} = await client.send('CSS.getComputedStyleForNode', { + nodeId, + }); + const current = filterMap( + toMap( + computedStyle as Array<{name: string; value: string}> | undefined, + ), + request.params.properties, + ); + + const changed: Array<{property: string; before: string; after: string}> = + []; + const keys = new Set([...Object.keys(baseline), ...Object.keys(current)]); + for (const k of keys) { + if (baseline[k] !== current[k]) { + changed.push({ + property: k, + before: baseline[k] ?? '', + after: current[k] ?? '', + }); + } + } + response.appendResponseLine( + `Computed styles diff vs snapshot "${request.params.name}" (snapshot -> current):`, + ); + response.appendResponseLine('```json'); + response.appendResponseLine(JSON.stringify(changed)); + response.appendResponseLine('```'); + } finally { + handle.dispose(); + } + }, +}); diff --git a/tests/e2e.styles.test.ts b/tests/e2e.styles.test.ts new file mode 100644 index 00000000..19028159 --- /dev/null +++ b/tests/e2e.styles.test.ts @@ -0,0 +1,210 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Client} from '@modelcontextprotocol/sdk/client/index.js'; +import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js'; +import {describe, it} from 'node:test'; +import assert from 'node:assert'; +import {executablePath} from 'puppeteer'; + +function extractJson(text: string): unknown { + const match = text.match(/```json\s*([\s\S]*?)\s*```/); + if (!match) { + throw new Error('No JSON block found in tool response'); + } + return JSON.parse(match[1]); +} + +async function withClient(cb: (client: Client) => Promise) { + const transport = new StdioClientTransport({ + command: 'node', + args: [ + 'build/src/index.js', + '--headless', + '--isolated', + '--executable-path', + executablePath(), + ], + }); + const client = new Client( + { + name: 'e2e-styles', + version: '1.0.0', + }, + { + capabilities: {}, + }, + ); + try { + await client.connect(transport); + await cb(client); + } finally { + await client.close(); + } +} + +function findUidFromSnapshot(snapshotText: string, includes: string): string { + const lines = snapshotText.split('\n'); + for (const line of lines) { + if (!line.includes('uid=')) continue; + if (line.includes(includes)) { + const m = line.match(/uid=(\d+_\d+)/); + if (m) return m[1]; + } + } + throw new Error('UID not found in snapshot for: ' + includes); +} + +describe('e2e styles', () => { + it('computed/box/visibility/batch/diff/snapshot flow', async () => { + await withClient(async client => { + const html = encodeURIComponent(` +
box
+icon`); + + // Navigate via MCP + await client.callTool({ + name: 'navigate_page', + arguments: {url: `data:text/html,${html}`}, + }); + + // Snapshot and resolve UIDs + const snapRes = await client.callTool({ + name: 'take_snapshot', + arguments: {}, + }); + const snapText = (snapRes as {content?: Array<{text?: string}>}) + .content?.[0]?.text as string; + const uidBox = findUidFromSnapshot(snapText, 'button "box"'); + const uidIcon = findUidFromSnapshot(snapText, 'img "icon"'); + + // get_computed_styles + const cs = await client.callTool({ + name: 'get_computed_styles', + arguments: {uid: uidBox, properties: ['display'], includeSources: true}, + }); + const csParsed = extractJson( + (cs as {content?: Array<{text?: string}>}).content?.[0]?.text || '', + ) as { + computed: Record; + sourceMap?: Record; + }; + assert.strictEqual(csParsed.computed.display, 'block'); + + // get_box_model + const bm = await client.callTool({ + name: 'get_box_model', + arguments: {uid: uidBox}, + }); + const bmParsed = extractJson( + (bm as {content?: Array<{text?: string}>}).content?.[0]?.text || '', + ) as { + borderRect: {width: number}; + contentRect: {width: number}; + }; + assert.ok(bmParsed.borderRect.width >= bmParsed.contentRect.width); + + // get_visibility (first visible) + const vis1 = await client.callTool({ + name: 'get_visibility', + arguments: {uid: uidBox}, + }); + const v1 = extractJson( + (vis1 as {content?: Array<{text?: string}>}).content?.[0]?.text || '', + ) as { + isVisible: boolean; + }; + assert.strictEqual(v1.isVisible, true); + + // Batch + const batch = await client.callTool({ + name: 'get_computed_styles_batch', + arguments: {uids: [uidBox, uidIcon], properties: ['display']}, + }); + const batchParsed = extractJson( + (batch as {content?: Array<{text?: string}>}).content?.[0]?.text || '', + ) as Record; + assert.strictEqual(batchParsed[uidBox].display, 'block'); + assert.strictEqual(batchParsed[uidIcon].display, 'inline'); + + // Diff between two nodes + const diff = await client.callTool({ + name: 'diff_computed_styles', + arguments: {uidA: uidBox, uidB: uidIcon, properties: ['display']}, + }); + const diffParsed = extractJson( + (diff as {content?: Array<{text?: string}>}).content?.[0]?.text || '', + ) as Array<{ + property: string; + before: string; + after: string; + }>; + const displayChange = diffParsed.find(d => d.property === 'display'); + assert.ok(displayChange); + assert.strictEqual(displayChange?.before, 'block'); + assert.strictEqual(displayChange?.after, 'inline'); + + // Save snapshot + await client.callTool({ + name: 'save_computed_styles_snapshot', + arguments: {name: 'snap1', uids: [uidBox], properties: ['display']}, + }); + + // Change display via evaluate_script + await client.callTool({ + name: 'evaluate_script', + arguments: { + function: String((el: Element) => { + (el as HTMLElement).style.display = 'inline'; + return true; + }), + args: [{uid: uidBox}], + }, + }); + + // Diff snapshot + const sdiff = await client.callTool({ + name: 'diff_computed_styles_snapshot', + arguments: {name: 'snap1', uid: uidBox, properties: ['display']}, + }); + const sdiffParsed = extractJson( + (sdiff as {content?: Array<{text?: string}>}).content?.[0]?.text || '', + ) as Array<{ + property: string; + before: string; + after: string; + }>; + const change = sdiffParsed.find(d => d.property === 'display'); + assert.ok(change); + assert.strictEqual(change?.before, 'block'); + assert.strictEqual(change?.after, 'inline'); + + // Hide and check visibility false + await client.callTool({ + name: 'evaluate_script', + arguments: { + function: String((el: Element) => { + (el as HTMLElement).style.display = 'none'; + return true; + }), + args: [{uid: uidBox}], + }, + }); + const vis2 = await client.callTool({ + name: 'get_visibility', + arguments: {uid: uidBox}, + }); + const v2 = extractJson( + (vis2 as {content?: Array<{text?: string}>}).content?.[0]?.text || '', + ) as { + isVisible: boolean; + reasons: string[]; + }; + assert.strictEqual(v2.isVisible, false); + assert.ok(v2.reasons.includes('display:none')); + }); + }); +}); diff --git a/tests/tools/styles.test.ts b/tests/tools/styles.test.ts new file mode 100644 index 00000000..fff4be01 --- /dev/null +++ b/tests/tools/styles.test.ts @@ -0,0 +1,260 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, it} from 'node:test'; +import assert from 'assert'; + +import { + getComputedStyles, + getBoxModel, + getVisibility, + getComputedStylesBatch, + diffComputedStyles, + saveComputedStylesSnapshot, + diffComputedStylesSnapshot, +} from '../../src/tools/styles.js'; + +import {html, withBrowser} from '../utils.js'; + +describe('styles', () => { + describe('get_computed_styles', () => { + it('returns filtered computed styles', async () => { + await withBrowser(async (response, context) => { + const page = context.getSelectedPage(); + await page.setContent( + html`
box
`, + ); + await context.createTextSnapshot(); + + await getComputedStyles.handler( + { + params: { + uid: '1_1', + properties: ['display'], + }, + }, + response, + context, + ); + + const json = response.responseLines.at(2)!; + const parsed = JSON.parse(json) as { + computed: Record; + }; + assert.strictEqual(parsed.computed.display, 'block'); + }); + }); + + it('can include best-effort rule origins', async () => { + await withBrowser(async (response, context) => { + const page = context.getSelectedPage(); + await page.setContent( + html`
box
`, + ); + await context.createTextSnapshot(); + + await getComputedStyles.handler( + { + params: { + uid: '1_1', + properties: ['display'], + includeSources: true, + }, + }, + response, + context, + ); + + const json = response.responseLines.at(2)!; + const parsed = JSON.parse(json) as { + computed: Record; + sourceMap?: Record; + }; + assert.strictEqual(parsed.computed.display, 'block'); + // Best-effort: inline styles should be detected. + assert.strictEqual(parsed.sourceMap?.display?.source, 'inline'); + }); + }); + }); + + describe('get_box_model', () => { + it('returns box quads and rects', async () => { + await withBrowser(async (response, context) => { + const page = context.getSelectedPage(); + await page.setContent( + html`
box
`, + ); + await context.createTextSnapshot(); + + await getBoxModel.handler({params: {uid: '1_1'}}, response, context); + + const json = response.responseLines.at(2)!; + const parsed = JSON.parse(json) as { + borderRect: {width: number}; + contentRect: {width: number}; + borderQuad: unknown; + }; + assert.ok(parsed.borderQuad); + assert.ok(parsed.borderRect.width >= parsed.contentRect.width); + }); + }); + }); + + describe('get_visibility', () => { + it('flags display:none as not visible', async () => { + await withBrowser(async (response, context) => { + const page = context.getSelectedPage(); + await page.setContent(html`
hidden
`); + await context.createTextSnapshot(); + // Hide the element after snapshot so we can still resolve the uid + await page.evaluate(() => { + const el = document.getElementById('box'); + if (el) { + el.style.display = 'none'; + } + }); + + await getVisibility.handler({params: {uid: '1_1'}}, response, context); + + const json = response.responseLines.at(2)!; + const parsed = JSON.parse(json) as { + isVisible: boolean; + reasons: string[]; + }; + assert.strictEqual(parsed.isVisible, false); + assert.ok(parsed.reasons.includes('display:none')); + }); + }); + }); + + describe('get_computed_styles_batch', () => { + it('returns styles for multiple elements', async () => { + await withBrowser(async (response, context) => { + const page = context.getSelectedPage(); + await page.setContent(html`
box
inline`); + await context.createTextSnapshot(); + + await getComputedStylesBatch.handler( + { + params: { + uids: ['1_1', '1_2'], + properties: ['display'], + }, + }, + response, + context, + ); + + const json = response.responseLines.at(2)!; + const parsed = JSON.parse(json) as Record; + assert.strictEqual(parsed['1_1'].display, 'block'); + assert.strictEqual(parsed['1_2'].display, 'inline'); + }); + }); + }); + + describe('diff_computed_styles', () => { + it('returns changed properties between two nodes', async () => { + await withBrowser(async (response, context) => { + const page = context.getSelectedPage(); + await page.setContent(html`
box
inline`); + await context.createTextSnapshot(); + + await diffComputedStyles.handler( + { + params: { + uidA: '1_1', + uidB: '1_2', + properties: ['display'], + }, + }, + response, + context, + ); + + const json = response.responseLines.at(2)!; + const parsed = JSON.parse(json) as Array<{ + property: string; + before: string; + after: string; + }>; + const display = parsed.find(p => p.property === 'display'); + assert.ok(display); + assert.strictEqual(display?.before, 'block'); + assert.strictEqual(display?.after, 'inline'); + }); + }); + }); + + describe('named snapshots', () => { + it('saves and diffs snapshot vs current', async () => { + await withBrowser(async (response, context) => { + const page = context.getSelectedPage(); + await page.setContent( + html`
box
`, + ); + await context.createTextSnapshot(); + + await saveComputedStylesSnapshot.handler( + { + params: { + name: 'snap1', + uids: ['1_1'], + properties: ['display'], + }, + }, + response, + context, + ); + + // Change the style + await page.evaluate(() => { + const el = document.getElementById('box'); + if (el) el.style.display = 'inline'; + }); + + response.resetResponseLineForTesting(); + await diffComputedStylesSnapshot.handler( + { + params: { + name: 'snap1', + uid: '1_1', + properties: ['display'], + }, + }, + response, + context, + ); + + const json = response.responseLines.at(2)!; + const parsed = JSON.parse(json) as Array<{ + property: string; + before: string; + after: string; + }>; + const display = parsed.find(p => p.property === 'display'); + assert.strictEqual(display?.before, 'block'); + assert.strictEqual(display?.after, 'inline'); + }); + }); + }); +});