diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3a04e2ad2..75810741a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -118,6 +118,8 @@ You can use the `DEBUG` environment variable as usual to control categories that When adding a new tool or updating a tool name or description, make sure to run `npm run gen` to generate the tool reference documentation. +If the change affects when or why to use a tool, update `docs/mcp-tools-user-guide.md` as well. + ### Contributing to Evals We use Gemini to evaluate the MCP server tools in `scripts/eval_scenarios`. diff --git a/README.md b/README.md index ebb3a4b09..c6917d779 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ control and inspect a live Chrome browser. It acts as a Model-Context-Protocol Chrome DevTools for reliable automation, in-depth debugging, and performance analysis. A [CLI](docs/cli.md) is also provided for use without MCP. -## [Tool reference](./docs/tool-reference.md) | [Changelog](./CHANGELOG.md) | [Contributing](./CONTRIBUTING.md) | [Troubleshooting](./docs/troubleshooting.md) | [Design Principles](./docs/design-principles.md) +## [Tool reference](./docs/tool-reference.md) | [MCP tools guide](./docs/mcp-tools-user-guide.md) | [Changelog](./CHANGELOG.md) | [Contributing](./CONTRIBUTING.md) | [Troubleshooting](./docs/troubleshooting.md) | [Design Principles](./docs/design-principles.md) ## Key features @@ -475,6 +475,8 @@ Your MCP client should open the browser and record a performance trace. If you run into any issues, checkout our [troubleshooting guide](./docs/troubleshooting.md). +For scenario-based guidance on each tool, see the [MCP tools guide](./docs/mcp-tools-user-guide.md). + - **Input automation** (9 tools) @@ -505,11 +507,19 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles - **Network** (2 tools) - [`get_network_request`](docs/tool-reference.md#get_network_request) - [`list_network_requests`](docs/tool-reference.md#list_network_requests) -- **Debugging** (6 tools) +- **Debugging** (14 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_console_message`](docs/tool-reference.md#get_console_message) + - [`get_visibility`](docs/tool-reference.md#get_visibility) + - [`highlight_elements_for_styles`](docs/tool-reference.md#highlight_elements_for_styles) - [`lighthouse_audit`](docs/tool-reference.md#lighthouse_audit) - [`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/mcp-tools-user-guide.md b/docs/mcp-tools-user-guide.md new file mode 100644 index 000000000..7ab99f2dd --- /dev/null +++ b/docs/mcp-tools-user-guide.md @@ -0,0 +1,282 @@ +# Chrome DevTools MCP — tools user guide + +This guide explains **when and how** to use each MCP tool. For parameter details, see [tool-reference.md](./tool-reference.md). + +## Core workflow (recommended) + +1. **`list_pages`** — See open tabs; note `pageId` for the tab you care about. +2. **`select_page`** — Target that tab for subsequent tools (unless you only + have one page). +3. **`take_snapshot`** — Get the accessibility tree with **`uid`** values + for elements. **Prefer this over screenshots** for structure and automation. +4. Act (navigate, click, fill, etc.), then **`take_snapshot`** again when the + DOM may have changed. + +**uids expire** when the page updates. Always use the **latest** snapshot. + +--- + +## Navigation automation + +### `list_pages` + +- **Use when:** You need to know which tabs exist, their URLs, or which `pageId` to pass to `select_page` / `close_page`. +- **Typical scenario:** Multi-tab debugging; picking the tab that shows the bug. + +### `select_page` + +- **Use when:** More than one tab is open, or you need to switch context. +- **Pairs with:** Every page-scoped tool after switching tabs. + +### `new_page` + +- **Use when:** You need a fresh tab, a specific URL in isolation, or an + **`isolatedContext`** (separate cookies/storage from the default profile). +- **Typical scenario:** Testing logged-out vs logged-in side by side. + +### `navigate_page` + +- **Use when:** Loading a URL, going **back/forward**, **reload** (optionally + **ignore cache**), or injecting an **init script** before the next document. +- **Prefer over** asking the user to open links manually. + +### `close_page` + +- **Use when:** Cleaning up extra tabs. **Cannot** close the last remaining page. + +### `wait_for` + +- **Use when:** The UI updates asynchronously; wait until **any** of the given + strings appears before continuing. +- **Pairs with:** `take_snapshot` after the wait. + +### `get_tab_id` _(experimental)_ + +- **Use when:** Integrating with external tooling that needs the Chrome **tab + ID** for the selected page. + +--- + +## Input automation + +All of these need **`uid`** values from **`take_snapshot`** (except +`type_text`, which uses the focused element). + +### `click` + +- **Use when:** Activating buttons, links, controls, or opening menus. +- **Tip:** Use **`dblClick`** when a double-click is required. + +### `click_at` _(experimental vision)_ + +- **Use when:** You must hit **pixel coordinates** (e.g. canvas, non-a11y + overlay) and **`uid`-based `click`** is not enough. + +### `hover` + +- **Use when:** Revealing tooltips, mega-menus, or hover-only controls before + another action. + +### `fill` + +- **Use when:** Setting **inputs**, **text areas**, **` element.`, + description: + 'Set value on inputs, textareas, or select/combobox options from ' + + 'snapshot uid. Prefer over type_text when filling whole fields.', annotations: { category: ToolCategory.INPUT, readOnlyHint: false, @@ -252,7 +261,9 @@ export const fill = definePageTool({ export const typeText = definePageTool({ name: 'type_text', - description: `Type text using keyboard into a previously focused input`, + description: + 'Send keystrokes to the focused element; optional submitKey (e.g. ' + + 'Enter). Use after click/fill when key-by-key input matters.', annotations: { category: ToolCategory.INPUT, readOnlyHint: false, @@ -279,7 +290,9 @@ export const typeText = definePageTool({ export const drag = definePageTool({ name: 'drag', - description: `Drag an element onto another element`, + description: + 'Drag from_uid onto to_uid for drop targets, reorder lists, or ' + + 'file-like interactions modeled as drag-and-drop.', annotations: { category: ToolCategory.INPUT, readOnlyHint: false, @@ -313,7 +326,8 @@ export const drag = definePageTool({ export const fillForm = definePageTool({ name: 'fill_form', - description: `Fill out multiple form elements at once`, + description: + 'Batch-fill many {uid, value} pairs in one call for multi-field forms.', annotations: { category: ToolCategory.INPUT, readOnlyHint: false, @@ -351,7 +365,9 @@ export const fillForm = definePageTool({ export const uploadFile = definePageTool({ name: 'upload_file', - description: 'Upload a file through a provided element.', + description: + 'Attach a local file path to a file input or an element that opens ' + + 'a file chooser.', annotations: { category: ToolCategory.INPUT, readOnlyHint: false, @@ -401,7 +417,9 @@ export const uploadFile = definePageTool({ export const pressKey = definePageTool({ name: 'press_key', - description: `Press a key or key combination. Use this when other input methods like fill() cannot be used (e.g., keyboard shortcuts, navigation keys, or special key combinations).`, + description: + 'Press a key chord (e.g. Control+R, Escape). Use for shortcuts or ' + + 'when fill/type_text cannot model the interaction.', annotations: { category: ToolCategory.INPUT, readOnlyHint: false, diff --git a/src/tools/lighthouse.ts b/src/tools/lighthouse.ts index 606abf1ad..d1a4ab8e2 100644 --- a/src/tools/lighthouse.ts +++ b/src/tools/lighthouse.ts @@ -22,7 +22,9 @@ import {definePageTool} from './ToolDefinition.js'; export const lighthouseAudit = definePageTool({ name: 'lighthouse_audit', - description: `Get Lighthouse score and reports for accessibility, SEO and best practices. This excludes performance. For performance audits, run ${startTrace.name}`, + description: + 'Lighthouse a11y/SEO/best-practices only (HTML+JSON reports). For ' + + `load/runtime timelines use ${startTrace.name}.`, annotations: { category: ToolCategory.DEBUGGING, readOnlyHint: false, diff --git a/src/tools/memory.ts b/src/tools/memory.ts index b1f302ae1..33f64fd56 100644 --- a/src/tools/memory.ts +++ b/src/tools/memory.ts @@ -11,7 +11,9 @@ import {definePageTool} from './ToolDefinition.js'; export const takeMemorySnapshot = definePageTool({ name: 'take_memory_snapshot', - description: `Capture a heap snapshot of the currently selected page. Use to analyze the memory distribution of JavaScript objects and debug memory leaks.`, + description: + 'Write a .heapsnapshot for the current target; open in Memory panel ' + + 'to find leaks and retainers.', annotations: { category: ToolCategory.PERFORMANCE, readOnlyHint: false, diff --git a/src/tools/network.ts b/src/tools/network.ts index 2df445cc3..575c2304c 100644 --- a/src/tools/network.ts +++ b/src/tools/network.ts @@ -34,7 +34,9 @@ const FILTERABLE_RESOURCE_TYPES: readonly [ResourceType, ...ResourceType[]] = [ export const listNetworkRequests = definePageTool({ name: 'list_network_requests', - description: `List all requests for the currently selected page since the last navigation.`, + description: + 'HTTP/S requests since navigation: URL, status, timing, size. Filter ' + + 'by resource type; paginate large logs.', annotations: { category: ToolCategory.NETWORK, readOnlyHint: true, @@ -88,7 +90,9 @@ export const listNetworkRequests = definePageTool({ export const getNetworkRequest = definePageTool({ name: 'get_network_request', - description: `Gets a network request by an optional reqid, if omitted returns the currently selected request in the DevTools Network panel.`, + description: + 'Full request/response for a reqid from list_network_requests; omit ' + + 'reqid to use the row selected in the Network panel.', annotations: { category: ToolCategory.NETWORK, readOnlyHint: false, diff --git a/src/tools/pages.ts b/src/tools/pages.ts index 184a51350..ab87d8a5a 100644 --- a/src/tools/pages.ts +++ b/src/tools/pages.ts @@ -19,7 +19,11 @@ import { export const listPages = defineTool(args => { return { name: 'list_pages', - description: `Get a list of pages ${args?.categoryExtensions ? 'including extension service workers' : ''} open in the browser.`, + description: + 'List browser tabs with ids and URLs for select_page / close_page.' + + (args?.categoryExtensions + ? ' Includes extension service worker targets when enabled.' + : ''), annotations: { category: ToolCategory.NAVIGATION, readOnlyHint: true, @@ -34,7 +38,9 @@ export const listPages = defineTool(args => { export const selectPage = defineTool({ name: 'select_page', - description: `Select a page as a context for future tool calls.`, + description: + 'Make pageId the active tab for following tools (required when multiple ' + + 'tabs are open).', annotations: { category: ToolCategory.NAVIGATION, readOnlyHint: true, @@ -63,7 +69,8 @@ export const selectPage = defineTool({ export const closePage = defineTool({ name: 'close_page', - description: `Closes the page by its index. The last open page cannot be closed.`, + description: + 'Close a tab by pageId from list_pages. The last tab cannot be closed.', annotations: { category: ToolCategory.NAVIGATION, readOnlyHint: false, @@ -90,7 +97,9 @@ export const closePage = defineTool({ export const newPage = defineTool({ name: 'new_page', - description: `Open a new tab and load a URL. Use project URL if not specified otherwise.`, + description: + 'Open a tab and goto url; optional background or isolatedContext ' + + '(separate storage). Use for parallel sessions.', annotations: { category: ToolCategory.NAVIGATION, readOnlyHint: false, @@ -135,7 +144,9 @@ export const newPage = defineTool({ export const navigatePage = definePageTool({ name: 'navigate_page', - description: `Go to a URL, or back, forward, or reload. Use project URL if not specified otherwise.`, + description: + 'Navigate: url, back, forward, or reload; optional cache bypass, ' + + 'per-navigation init script, beforeunload handling.', annotations: { category: ToolCategory.NAVIGATION, readOnlyHint: false, @@ -285,7 +296,9 @@ export const navigatePage = definePageTool({ export const resizePage = definePageTool({ name: 'resize_page', - description: `Resizes the selected page's window so that the page has specified dimension`, + description: + 'Resize the window so the page content matches width x height ' + + '(responsive layout debugging).', annotations: { category: ToolCategory.EMULATION, readOnlyHint: false, @@ -324,7 +337,9 @@ export const resizePage = definePageTool({ export const handleDialog = definePageTool({ name: 'handle_dialog', - description: `If a browser dialog was opened, use this command to handle it`, + description: + 'Accept or dismiss the current alert/confirm/prompt; optional ' + + 'promptText for prompt().', annotations: { category: ToolCategory.INPUT, readOnlyHint: false, @@ -375,7 +390,8 @@ export const handleDialog = definePageTool({ export const getTabId = definePageTool({ name: 'get_tab_id', - description: `Get the tab ID of the page`, + description: + 'Return Chrome tab id for interop with external DevTools clients.', annotations: { category: ToolCategory.NAVIGATION, readOnlyHint: true, diff --git a/src/tools/performance.ts b/src/tools/performance.ts index c02b627b9..f2366f488 100644 --- a/src/tools/performance.ts +++ b/src/tools/performance.ts @@ -28,7 +28,9 @@ const filePathSchema = zod export const startTrace = definePageTool({ name: 'performance_start_trace', - description: `Start a performance trace on the selected webpage. Use to find frontend performance issues, Core Web Vitals (LCP, INP, CLS), and improve page load speed.`, + description: + 'Record a DevTools performance trace (reload optional). Use for load ' + + 'speed, main-thread jank, and Core Web Vitals—not Lighthouse scores.', annotations: { category: ToolCategory.PERFORMANCE, readOnlyHint: false, @@ -117,7 +119,8 @@ export const startTrace = definePageTool({ export const stopTrace = definePageTool({ name: 'performance_stop_trace', description: - 'Stop the active performance trace recording on the selected webpage.', + 'Stop tracing and return trace summary; optional filePath for raw ' + + 'trace JSON (.json or .gz).', annotations: { category: ToolCategory.PERFORMANCE, readOnlyHint: false, @@ -142,7 +145,8 @@ export const stopTrace = definePageTool({ export const analyzeInsight = definePageTool({ name: 'performance_analyze_insight', description: - 'Provides more detailed information on a specific Performance Insight of an insight set that was highlighted in the results of a trace recording.', + 'Expand one insight from the last trace (insightSetId + insightName ' + + 'from trace output).', annotations: { category: ToolCategory.PERFORMANCE, readOnlyHint: true, diff --git a/src/tools/screencast.ts b/src/tools/screencast.ts index a5ab1c38e..27904a6ef 100644 --- a/src/tools/screencast.ts +++ b/src/tools/screencast.ts @@ -22,7 +22,8 @@ async function generateTempFilePath(): Promise { export const startScreencast = definePageTool({ name: 'screencast_start', description: - 'Starts recording a screencast (video) of the selected page in mp4 format.', + 'Record the tab to MP4 (ffmpeg required). For visual repros; pair with ' + + 'screencast_stop.', annotations: { category: ToolCategory.DEBUGGING, readOnlyHint: false, @@ -77,7 +78,8 @@ export const startScreencast = definePageTool({ export const stopScreencast = definePageTool({ name: 'screencast_stop', - description: 'Stops the active screencast recording on the selected page.', + description: + 'Finish MP4 recording started by screencast_start; flushes file path.', annotations: { category: ToolCategory.DEBUGGING, readOnlyHint: false, diff --git a/src/tools/screenshot.ts b/src/tools/screenshot.ts index 2e648531c..9a6f7e45f 100644 --- a/src/tools/screenshot.ts +++ b/src/tools/screenshot.ts @@ -12,7 +12,9 @@ import {definePageTool} from './ToolDefinition.js'; export const screenshot = definePageTool({ name: 'take_screenshot', - description: `Take a screenshot of the page or element.`, + description: + 'Capture PNG/JPEG/WebP of viewport, full page, or a uid element; use ' + + 'when pixels matter (layout, regressions), not for DOM structure.', annotations: { category: ToolCategory.DEBUGGING, // Not read-only due to filePath param. diff --git a/src/tools/script.ts b/src/tools/script.ts index 725628bcd..076c88ea2 100644 --- a/src/tools/script.ts +++ b/src/tools/script.ts @@ -17,8 +17,10 @@ export type Evaluatable = Page | Frame | WebWorker; export const evaluateScript = defineTool(cliArgs => { return { name: 'evaluate_script', - description: `Evaluate a JavaScript function inside the currently selected page. Returns the response as JSON, -so returned values have to be JSON-serializable.`, + description: + 'Run an async/sync function body in the page; result JSON-serializable. ' + + 'Pass snapshot element uids as args to receive DOM handles. For ' + + 'extensions, optional serviceWorkerId targets the worker.', annotations: { category: ToolCategory.DEBUGGING, readOnlyHint: false, diff --git a/src/tools/slim/tools.ts b/src/tools/slim/tools.ts index 712dcff63..b5c6c44c3 100644 --- a/src/tools/slim/tools.ts +++ b/src/tools/slim/tools.ts @@ -11,7 +11,8 @@ import {definePageTool} from '../ToolDefinition.js'; export const screenshot = definePageTool({ name: 'screenshot', - description: `Takes a screenshot`, + description: + 'Viewport PNG saved to a temp path (--slim mode; minimal footprint).', annotations: { category: ToolCategory.DEBUGGING, // Not read-only due to filePath param. @@ -34,7 +35,8 @@ export const screenshot = definePageTool({ export const navigate = definePageTool({ name: 'navigate', - description: `Loads a URL`, + description: + 'Navigate the selected tab to url (--slim; accepts beforeunload).', annotations: { category: ToolCategory.NAVIGATION, readOnlyHint: false, @@ -71,7 +73,8 @@ export const navigate = definePageTool({ export const evaluate = definePageTool({ name: 'evaluate', - description: `Evaluates a JavaScript script`, + description: + 'Evaluate a script string in page context; returns text/JSON (--slim).', annotations: { category: ToolCategory.DEBUGGING, readOnlyHint: false, diff --git a/src/tools/snapshot.ts b/src/tools/snapshot.ts index 338bd6794..9bd61aac2 100644 --- a/src/tools/snapshot.ts +++ b/src/tools/snapshot.ts @@ -11,9 +11,10 @@ import {definePageTool, timeoutSchema} from './ToolDefinition.js'; export const takeSnapshot = definePageTool({ name: 'take_snapshot', - description: `Take a text snapshot of the currently selected page based on the a11y tree. The snapshot lists page elements along with a unique -identifier (uid). Always use the latest snapshot. Prefer taking a snapshot over taking a screenshot. The snapshot indicates the element selected -in the DevTools Elements panel (if any).`, + description: + 'Accessibility tree with stable uids for automation. Always use the ' + + 'latest snapshot after DOM changes. Prefer over screenshot for ' + + 'structure; reflects Elements panel selection when set.', annotations: { category: ToolCategory.DEBUGGING, // Not read-only due to filePath param. @@ -43,7 +44,9 @@ in the DevTools Elements panel (if any).`, export const waitFor = definePageTool({ name: 'wait_for', - description: `Wait for the specified text to appear on the selected page.`, + description: + 'Wait until any of the given strings appears (async rendering, ' + + 'SPA transitions).', annotations: { category: ToolCategory.NAVIGATION, readOnlyHint: true, diff --git a/src/tools/styles.ts b/src/tools/styles.ts new file mode 100644 index 000000000..60ba19455 --- /dev/null +++ b/src/tools/styles.ts @@ -0,0 +1,1124 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {zod as z} from '../third_party/index.js'; +import type {ElementHandle} from '../third_party/index.js'; + +import {ToolCategory} from './categories.js'; +import {definePageTool} from './ToolDefinition.js'; +// Intentionally no direct imports to avoid unused types and keep payload small. + +type CssPropertyMap = Record; + +interface BorderRect { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; +} + +interface StyleSnapshotMeta { + capturedAt: string; + url: string; + viewportWidth: number; + viewportHeight: number; + dpr: number; +} + +interface StyleSnapshotElement { + computed: CssPropertyMap; + borderRect?: BorderRect; + domPath?: string; + backendNodeId?: number; +} + +interface StyleSnapshotData { + meta: StyleSnapshotMeta; + elements: Record; +} + +/** Legacy: flat uid -> computed map (pre v1). */ +type LegacySnapshotMap = Record; + +const GEOMETRY_EPS_PX = 0.5; + +// Per-context named snapshots (v1 or legacy flat map). +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 isV1Snapshot( + s: StyleSnapshotData | LegacySnapshotMap, +): s is StyleSnapshotData { + return typeof s === 'object' && s !== null && 'meta' in s && 'elements' in s; +} + +function snapshotElements( + raw: StyleSnapshotData | LegacySnapshotMap, +): Record { + if (isV1Snapshot(raw)) { + return raw.elements; + } + const out: Record = {}; + for (const [uid, computed] of Object.entries(raw)) { + out[uid] = {computed}; + } + return out; +} + +function snapshotMeta( + raw: StyleSnapshotData | LegacySnapshotMap, +): StyleSnapshotMeta | undefined { + return isV1Snapshot(raw) ? raw.meta : undefined; +} + +function rectFromQuad( + quad: Array<{x: number; y: number}> | number[], +): BorderRect { + if (Array.isArray(quad) && typeof quad[0] === 'number') { + 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, + }; +} + +function borderRectsMatch( + a?: BorderRect, + b?: BorderRect, + eps = GEOMETRY_EPS_PX, +) { + if (!a || !b) { + return !a && !b; + } + return ( + Math.abs(a.left - b.left) <= eps && + Math.abs(a.top - b.top) <= eps && + Math.abs(a.width - b.width) <= eps && + Math.abs(a.height - b.height) <= eps + ); +} + +function isLayoutProperty(p: string): boolean { + const x = p.toLowerCase(); + return ( + x.includes('width') || + x.includes('height') || + x.includes('margin') || + x.includes('padding') || + x.includes('border') || + x === 'display' || + x === 'position' || + x.includes('flex') || + x.includes('grid') || + x.includes('gap') || + x === 'transform' || + x === 'top' || + x === 'left' || + x === 'right' || + x === 'bottom' || + x.includes('inset') + ); +} + +function classifyStyleDiff( + styleChanges: Array<{property: string}>, + geometryEqual: boolean | undefined, +): { + changeClass: 'none' | 'cascadeOnly' | 'layoutEffective' | 'paintLikely'; + effectiveLayoutChange: boolean; +} { + if (styleChanges.length === 0) { + if (geometryEqual === undefined) { + return {changeClass: 'none', effectiveLayoutChange: false}; + } + const layoutShift = geometryEqual === false; + return { + changeClass: layoutShift ? 'layoutEffective' : 'none', + effectiveLayoutChange: layoutShift, + }; + } + if (geometryEqual === false) { + return {changeClass: 'layoutEffective', effectiveLayoutChange: true}; + } + const touchedLayout = styleChanges.some(c => isLayoutProperty(c.property)); + if (geometryEqual === true && touchedLayout) { + return {changeClass: 'cascadeOnly', effectiveLayoutChange: false}; + } + if (geometryEqual === undefined && touchedLayout) { + return {changeClass: 'layoutEffective', effectiveLayoutChange: false}; + } + if (touchedLayout) { + return {changeClass: 'layoutEffective', effectiveLayoutChange: true}; + } + return {changeClass: 'paintLikely', effectiveLayoutChange: false}; +} + +async function domPathForHandle(handle: ElementHandle) { + return handle.evaluate((el: Element) => { + const parts: string[] = []; + let current: Element | null = el; + const stopAt = document.documentElement.parentElement; + while (current && current !== stopAt) { + const tag = current.tagName.toLowerCase(); + const par: Element | null = current.parentElement; + let idx = 1; + if (par) { + for (const c of par.children) { + if (c.tagName === current.tagName) { + if (c === current) { + break; + } + idx++; + } + } + } + parts.unshift(`${tag}:nth-of-type(${idx})`); + current = par; + } + return parts.join(' > '); + }); +} + +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; +} + +function resolveSnapshotElement( + elements: Record, + uid: string, + domPath?: string, +): StyleSnapshotElement | undefined { + const direct = elements[uid]; + if (direct) { + return direct; + } + if (!domPath?.length) { + return undefined; + } + for (const el of Object.values(elements)) { + if (el.domPath === domPath) { + return el; + } + } + return undefined; +} + +function borderQuadToNumbers( + border: unknown, +): [number, number, number, number, number, number, number, number] | null { + if (!Array.isArray(border) || border.length < 8) { + return null; + } + if (typeof border[0] === 'number') { + return border as [ + number, + number, + number, + number, + number, + number, + number, + number, + ]; + } + const pts = border as Array<{x: number; y: number}>; + const out: number[] = []; + for (const p of pts) { + out.push(p.x, p.y); + } + return out.length >= 8 + ? (out.slice(0, 8) as [ + number, + number, + number, + number, + number, + number, + number, + number, + ]) + : null; +} + +export const getComputedStyles = definePageTool({ + name: 'get_computed_styles', + description: + 'Resolved computed styles for one uid; optional property filter and ' + + 'winning-rule hints (includeSources). Prefer over scraping styles in ' + + 'evaluate_script.', + annotations: { + category: ToolCategory.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 pptr = request.page.pptrPage; + const handle = await request.page.getElementByUid(request.params.uid); + try { + await context.ensureDomDomainEnabledForPage(pptr); + await context.ensureCssDomainEnabledForPage(pptr); + // @ts-expect-error internal API + const client = pptr._client(); + + const nodeId = await context.getNodeIdFromHandle(handle, pptr); + 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, + }); + } + + interface OriginEntry { + source: string; + selector?: string; + origin?: string; + styleSheetId?: string; + range?: unknown; + value: string; + } + const propIndex = new Map(); + for (const c of candidates) { + for (const p of c.properties ?? []) { + let arr = propIndex.get(p.name); + if (!arr) { + arr = []; + propIndex.set(p.name, arr); + } + arr.push({ + source: c.source, + selector: c.selector, + origin: c.origin, + styleSheetId: c.styleSheetId, + range: c.range, + value: p.value, + }); + } + } + for (const propName of Object.keys(filtered)) { + const entries = propIndex.get(propName); + if (!entries) { + continue; + } + const computedVal = filtered[propName]; + let origin: Record | null = null; + for (const e of entries) { + if (e.value === computedVal) { + origin = { + source: e.source, + selector: e.selector, + origin: e.origin, + styleSheetId: e.styleSheetId, + range: e.range, + }; + break; + } + if (!origin) { + origin = { + source: e.source, + selector: e.selector, + origin: e.origin, + styleSheetId: e.styleSheetId, + range: e.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 { + await handle.dispose(); + } + }, +}); + +export const getBoxModel = definePageTool({ + name: 'get_box_model', + description: + 'CDP box model quads and rects for layout misalignment, overflow, and ' + + 'offset debugging.', + annotations: { + category: ToolCategory.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 pptr = request.page.pptrPage; + const handle = await request.page.getElementByUid(request.params.uid); + try { + await context.ensureDomDomainEnabledForPage(pptr); + // @ts-expect-error internal API + const client = pptr._client(); + + const nodeId = await context.getNodeIdFromHandle(handle, pptr); + const {model} = await client.send('DOM.getBoxModel', {nodeId}); + + 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 { + await handle.dispose(); + } + }, +}); + +export const getVisibility = definePageTool({ + name: 'get_visibility', + description: + 'Explain why an element is invisible (display, opacity, zero size, ' + + 'off-viewport, clip-path).', + annotations: { + category: ToolCategory.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 pptr = request.page.pptrPage; + const handle = await request.page.getElementByUid(request.params.uid); + try { + await context.ensureDomDomainEnabledForPage(pptr); + await context.ensureCssDomainEnabledForPage(pptr); + // @ts-expect-error internal API + const client = pptr._client(); + + const nodeId = await context.getNodeIdFromHandle(handle, pptr); + const [computedRes, boxRes] = await Promise.all([ + client.send('CSS.getComputedStyleForNode', {nodeId}), + client.send('DOM.getBoxModel', {nodeId}).catch(() => null), + ]); + const style = toMap( + computedRes.computedStyle as + | Array<{name: string; value: string}> + | undefined, + ); + + const boxModel: { + width: number; + height: number; + border: Array<{x: number; y: number}>; + } | null = boxRes?.model ?? null; + + 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 { + await handle.dispose(); + } + }, +}); + +export const getComputedStylesBatch = definePageTool({ + name: 'get_computed_styles_batch', + description: + 'Batch computed styles map keyed by uid—use for design tokens or ' + + 'multi-node parity checks.', + annotations: { + category: ToolCategory.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) => { + const pptr = request.page.pptrPage; + await context.ensureDomDomainEnabledForPage(pptr); + await context.ensureCssDomainEnabledForPage(pptr); + // @ts-expect-error internal API + const client = pptr._client(); + + const results: Record = {}; + await Promise.all( + request.params.uids.map(async uid => { + const handle = await request.page.getElementByUid(uid); + try { + const nodeId = await context.getNodeIdFromHandle(handle, pptr); + 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 { + await handle.dispose(); + } + }), + ); + + response.appendResponseLine('Computed styles (batch):'); + response.appendResponseLine('```json'); + response.appendResponseLine(JSON.stringify(results)); + response.appendResponseLine('```'); + }, +}); + +export const diffComputedStyles = definePageTool({ + name: 'diff_computed_styles', + description: + 'Side-by-side style diff for two uids on the same page; optional ' + + 'geometry compare for layout-affecting changes.', + annotations: { + category: ToolCategory.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'), + compareGeometry: z + .boolean() + .optional() + .describe( + 'If true, compare border-box geometry and classify effective layout change.', + ), + }, + handler: async (request, response, context) => { + const pptr = request.page.pptrPage; + await context.ensureDomDomainEnabledForPage(pptr); + await context.ensureCssDomainEnabledForPage(pptr); + // @ts-expect-error internal API + const client = pptr._client(); + + async function getMapAndRect(uid: string): Promise<{ + map: CssPropertyMap; + rect?: BorderRect; + }> { + const handle = await request.page.getElementByUid(uid); + try { + const nodeId = await context.getNodeIdFromHandle(handle, pptr); + const {computedStyle} = await client.send( + 'CSS.getComputedStyleForNode', + { + nodeId, + }, + ); + const map = filterMap( + toMap( + computedStyle as Array<{name: string; value: string}> | undefined, + ), + request.params.properties, + ); + let rect: BorderRect | undefined; + if (request.params.compareGeometry) { + try { + const bm = await client.send('DOM.getBoxModel', {nodeId}); + if (bm.model?.border) { + rect = rectFromQuad(bm.model.border as number[]); + } + } catch { + void 0; + } + } + return {map, rect}; + } finally { + await handle.dispose(); + } + } + + const [ra, rb] = await Promise.all([ + getMapAndRect(request.params.uidA), + getMapAndRect(request.params.uidB), + ]); + const a = ra.map; + const b = rb.map; + 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] ?? ''}); + } + } + let geometryEqual: boolean | undefined; + if (request.params.compareGeometry) { + geometryEqual = borderRectsMatch(ra.rect, rb.rect); + } + const classification = classifyStyleDiff(changed, geometryEqual); + const out: Record = { + styleChanges: changed, + ...classification, + }; + if (request.params.compareGeometry) { + out.geometry = { + borderRectA: ra.rect, + borderRectB: rb.rect, + approximatelyEqual: geometryEqual, + }; + } + response.appendResponseLine('Computed styles diff (A -> B):'); + response.appendResponseLine('```json'); + response.appendResponseLine(JSON.stringify(out)); + response.appendResponseLine('```'); + }, +}); + +export const saveComputedStylesSnapshot = definePageTool({ + name: 'save_computed_styles_snapshot', + description: + 'Store baseline computed styles + domPath/meta under a name for ' + + 'cross-navigation regression checks.', + annotations: { + category: ToolCategory.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) => { + const pptr = request.page.pptrPage; + await context.ensureDomDomainEnabledForPage(pptr); + await context.ensureCssDomainEnabledForPage(pptr); + // @ts-expect-error internal API + const client = pptr._client(); + + const vpRes = await client.send('Runtime.evaluate', { + expression: + '({w:window.innerWidth,h:window.innerHeight,' + + 'dpr:window.devicePixelRatio})', + returnByValue: true, + }); + const vp = vpRes.result?.value as {w?: number; h?: number; dpr?: number}; + const meta: StyleSnapshotMeta = { + capturedAt: new Date().toISOString(), + url: pptr.url(), + viewportWidth: Number(vp?.w ?? 0), + viewportHeight: Number(vp?.h ?? 0), + dpr: Number(vp?.dpr ?? 1) || 1, + }; + + const elements: Record = {}; + await Promise.all( + request.params.uids.map(async uid => { + const handle = await request.page.getElementByUid(uid); + try { + const nodeId = await context.getNodeIdFromHandle(handle, pptr); + const [computedRes, bmRes, domPathRes, descRes] = await Promise.all([ + client.send('CSS.getComputedStyleForNode', {nodeId}), + client.send('DOM.getBoxModel', {nodeId}).catch(() => null), + domPathForHandle(handle).catch(() => undefined), + client.send('DOM.describeNode', {nodeId}).catch(() => null), + ]); + const map = toMap( + computedRes.computedStyle as + | Array<{name: string; value: string}> + | undefined, + ); + let borderRect: BorderRect | undefined; + if (bmRes?.model?.border) { + borderRect = rectFromQuad(bmRes.model.border as number[]); + } + const domPath = domPathRes || undefined; + const backendNodeId = + (descRes?.node?.backendNodeId as number | undefined) ?? undefined; + elements[uid] = { + computed: filterMap(map, request.params.properties), + borderRect, + domPath, + backendNodeId, + }; + } finally { + await handle.dispose(); + } + }), + ); + + const data: StyleSnapshotData = {meta, elements}; + const snapshots = getSnapshots(context as unknown as object); + snapshots.set(request.params.name, data); + + response.appendResponseLine( + `Saved styles snapshot "${request.params.name}" for ` + + `${Object.keys(elements).length} elements (schema v1).`, + ); + response.appendResponseLine('```json'); + response.appendResponseLine( + JSON.stringify({ + name: request.params.name, + schemaVersion: 1, + meta, + uids: Object.keys(elements), + }), + ); + response.appendResponseLine('```'); + }, +}); + +export const diffComputedStylesSnapshot = definePageTool({ + name: 'diff_computed_styles_snapshot', + description: + 'Compare live uid to save_computed_styles_snapshot baseline; domPath ' + + 'when uids differ between loads.', + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: true, + }, + schema: { + name: z.string().describe('Snapshot name'), + uid: z + .string() + .describe('Element uid for the live node (from current snapshot)'), + domPath: z + .string() + .optional() + .describe( + 'If baseline uid differs, match saved element by domPath from v1 snapshot.', + ), + properties: z.array(z.string()).optional().describe('Optional filter list'), + compareGeometry: z + .boolean() + .optional() + .describe('Compare border-box rects to detect effective layout change.'), + }, + 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 elems = snapshotElements(snapshot); + const baseline = resolveSnapshotElement( + elems, + request.params.uid, + request.params.domPath, + ); + if (!baseline) { + throw new Error('No entry for the provided uid/domPath in the snapshot'); + } + const baseMap = baseline.computed; + + const pptr = request.page.pptrPage; + await context.ensureDomDomainEnabledForPage(pptr); + await context.ensureCssDomainEnabledForPage(pptr); + // @ts-expect-error internal API + const client = pptr._client(); + + const handle = await request.page.getElementByUid(request.params.uid); + try { + const nodeId = await context.getNodeIdFromHandle(handle, pptr); + 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(baseMap), ...Object.keys(current)]); + for (const k of keys) { + if (baseMap[k] !== current[k]) { + changed.push({ + property: k, + before: baseMap[k] ?? '', + after: current[k] ?? '', + }); + } + } + + let geometryEqual: boolean | undefined; + let currentRect: BorderRect | undefined; + let liveQuad: number[] | null = null; + try { + const bm = await client.send('DOM.getBoxModel', {nodeId}); + const flat = borderQuadToNumbers(bm.model?.border); + liveQuad = flat ? [...flat] : null; + if (bm.model?.border) { + currentRect = rectFromQuad(bm.model.border as number[]); + } + } catch { + void 0; + } + if (request.params.compareGeometry) { + geometryEqual = borderRectsMatch(baseline.borderRect, currentRect); + } + + const classification = classifyStyleDiff(changed, geometryEqual); + const meta = snapshotMeta(snapshot); + const out: Record = { + snapshotMeta: meta, + domPathBaseline: baseline.domPath, + styleChanges: changed, + overlay: {borderQuad: liveQuad}, + ...classification, + }; + if (request.params.compareGeometry) { + out.geometry = { + baselineBorderRect: baseline.borderRect, + currentBorderRect: currentRect, + approximatelyEqual: geometryEqual, + }; + } + response.appendResponseLine( + `Computed styles diff vs snapshot "${request.params.name}" ` + + `(snapshot -> current):`, + ); + response.appendResponseLine('```json'); + response.appendResponseLine(JSON.stringify(out)); + response.appendResponseLine('```'); + } finally { + await handle.dispose(); + } + }, +}); + +export const highlightElementsForStyles = definePageTool({ + name: 'highlight_elements_for_styles', + description: + 'Highlight border quads in DevTools and return coordinates for ' + + 'screenshot overlays or docs.', + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: true, + }, + schema: { + uids: z + .array(z.string()) + .min(1) + .describe('Element uids from the current page snapshot'), + }, + handler: async (request, response, context) => { + const pptr = request.page.pptrPage; + await context.ensureDomDomainEnabledForPage(pptr); + // @ts-expect-error internal API + const client = pptr._client(); + try { + await client.send('Overlay.enable'); + } catch { + void 0; + } + const regions = await Promise.all( + request.params.uids.map(async uid => { + const handle = await request.page.getElementByUid(uid); + try { + const nodeId = await context.getNodeIdFromHandle(handle, pptr); + const flat = borderQuadToNumbers( + (await client.send('DOM.getBoxModel', {nodeId})).model?.border, + ); + const quad = flat ? [...flat] : null; + if (quad) { + await client + .send('Overlay.highlightQuad', {quad}) + .catch(() => undefined); + } + return {uid, borderQuad: quad}; + } finally { + await handle.dispose(); + } + }), + ); + response.appendResponseLine('Highlight regions (border quads, layout px):'); + response.appendResponseLine('```json'); + response.appendResponseLine(JSON.stringify({regions})); + response.appendResponseLine('```'); + }, +}); diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 3c74115c3..7c9bf6cd1 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -21,6 +21,7 @@ import * as screenshotTools from './screenshot.js'; import * as scriptTools from './script.js'; import * as slimTools from './slim/tools.js'; import * as snapshotTools from './snapshot.js'; +import * as stylesTools from './styles.js'; import type {ToolDefinition} from './ToolDefinition.js'; export const createTools = (args: ParsedArguments) => { @@ -41,6 +42,7 @@ export const createTools = (args: ParsedArguments) => { ...Object.values(screenshotTools), ...Object.values(scriptTools), ...Object.values(snapshotTools), + ...Object.values(stylesTools), ]; const tools = []; diff --git a/tests/browser.test.ts b/tests/browser.test.ts index b0835bf96..6bf81e936 100644 --- a/tests/browser.test.ts +++ b/tests/browser.test.ts @@ -27,6 +27,7 @@ describe('browser', () => { userDataDir: folderPath, executablePath: executablePath(), devtools: false, + chromeArgs: ['--no-sandbox', '--disable-setuid-sandbox'], }); try { try { @@ -36,6 +37,7 @@ describe('browser', () => { userDataDir: folderPath, executablePath: executablePath(), devtools: false, + chromeArgs: ['--no-sandbox', '--disable-setuid-sandbox'], }); await browser2.close(); assert.fail('not reached'); @@ -63,6 +65,7 @@ describe('browser', () => { height: 801, }, devtools: false, + chromeArgs: ['--no-sandbox', '--disable-setuid-sandbox'], }); try { const [page] = await browser.pages(); @@ -86,7 +89,11 @@ describe('browser', () => { userDataDir: folderPath, executablePath: executablePath(), devtools: false, - chromeArgs: ['--remote-debugging-port=0'], + chromeArgs: [ + '--remote-debugging-port=0', + '--no-sandbox', + '--disable-setuid-sandbox', + ], }); try { const connectedBrowser = await ensureBrowserConnected({ diff --git a/tests/e2e.styles.test.ts b/tests/e2e.styles.test.ts new file mode 100644 index 000000000..ac87168eb --- /dev/null +++ b/tests/e2e.styles.test.ts @@ -0,0 +1,226 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import {describe, it} from 'node:test'; + +import {Client} from '@modelcontextprotocol/sdk/client/index.js'; +import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js'; +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/bin/chrome-devtools-mcp.js', + '--headless', + '--isolated', + '--executable-path', + executablePath(), + '--no-usage-statistics', + '--chrome-arg=--no-sandbox', + '--chrome-arg=--disable-setuid-sandbox', + ], + }); + 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, 'image "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 { + styleChanges: Array<{ + property: string; + before: string; + after: string; + }>; + }; + const displayChange = diffParsed.styleChanges.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: [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 { + styleChanges: Array<{ + property: string; + before: string; + after: string; + }>; + }; + const change = sdiffParsed.styleChanges.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: [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/e2e/comparison/comparison-scenarios.test.ts b/tests/e2e/comparison/comparison-scenarios.test.ts new file mode 100644 index 000000000..6756fc92b --- /dev/null +++ b/tests/e2e/comparison/comparison-scenarios.test.ts @@ -0,0 +1,195 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import path from 'node:path'; +import {describe, it, before} from 'node:test'; +import {fileURLToPath} from 'node:url'; + +import {generateAll} from './generate-fixtures.js'; +import { + extractJson, + findUid, + htmlFileAsDataUrl, + toolText, + withClient, +} from './harness.js'; +import {SCENARIOS} from './scenarios-data.js'; + +const dir = path.dirname(fileURLToPath(import.meta.url)); + +/** Per-test: global runner uses --test-timeout=60000; each case spins up MCP. */ +const perTestMs = 180_000; + +describe('e2e comparison generated A/B pages', {timeout: 600_000}, () => { + before( + async () => { + await generateAll(); + }, + {timeout: 600_000}, + ); + + for (const s of SCENARIOS) { + it(`diff combined ${s.id}`, {timeout: perTestMs}, async () => { + await withClient(async client => { + const url = await htmlFileAsDataUrl( + path.join(dir, 'generated/pairs', s.id, 'combined.html'), + ); + await client.callTool({ + name: 'navigate_page', + arguments: {url}, + }); + const snapRes = await client.callTool({ + name: 'take_snapshot', + arguments: {}, + }); + const snap = toolText(snapRes); + const uidA = findUid(snap, 'variant-a'); + const uidB = findUid(snap, 'variant-b'); + + const diffArgs: Record = { + uidA, + uidB, + properties: [s.property], + }; + if (s.compareGeometry) { + diffArgs.compareGeometry = true; + } + const diffRes = await client.callTool({ + name: 'diff_computed_styles', + arguments: diffArgs, + }); + const diff = extractJson(toolText(diffRes)) as { + styleChanges: Array<{ + property: string; + before: string; + after: string; + }>; + }; + const row = diff.styleChanges.find(x => x.property === s.property); + assert.ok(row, `missing ${s.property} in ${s.id}`); + assert.notStrictEqual(row?.before, row?.after); + + const aRes = await client.callTool({ + name: 'get_computed_styles', + arguments: {uid: uidA, properties: [s.property]}, + }); + const bRes = await client.callTool({ + name: 'get_computed_styles', + arguments: {uid: uidB, properties: [s.property]}, + }); + const aj = extractJson(toolText(aRes)) as { + computed: Record; + }; + const bj = extractJson(toolText(bRes)) as { + computed: Record; + }; + assert.strictEqual(row?.before, aj.computed[s.property]); + assert.strictEqual(row?.after, bj.computed[s.property]); + }); + }); + } + + it('sequential standalone A and B pages', {timeout: perTestMs}, async () => { + const sid = 'css-color'; + const base = path.join(dir, 'generated/pairs', sid); + const ua = await htmlFileAsDataUrl(path.join(base, 'a.html')); + const ub = await htmlFileAsDataUrl(path.join(base, 'b.html')); + + let ca: {computed: Record}; + await withClient(async client => { + await client.callTool({name: 'navigate_page', arguments: {url: ua}}); + const snap = toolText( + await client.callTool({name: 'take_snapshot', arguments: {}}), + ); + const uidA = findUid(snap, 'variant-a'); + ca = extractJson( + toolText( + await client.callTool({ + name: 'get_computed_styles', + arguments: {uid: uidA, properties: ['color']}, + }), + ), + ) as {computed: Record}; + }); + + let cb: {computed: Record}; + await withClient(async client => { + await client.callTool({name: 'navigate_page', arguments: {url: ub}}); + const snap = toolText( + await client.callTool({name: 'take_snapshot', arguments: {}}), + ); + const uidB = findUid(snap, 'variant-b'); + cb = extractJson( + toolText( + await client.callTool({ + name: 'get_computed_styles', + arguments: {uid: uidB, properties: ['color']}, + }), + ), + ) as {computed: Record}; + }); + + assert.notStrictEqual(ca!.computed.color, cb!.computed.color); + }); + + it( + 'save and diff_computed_styles_snapshot on generated page', + {timeout: perTestMs}, + async () => { + await withClient(async client => { + const url = await htmlFileAsDataUrl( + path.join(dir, 'generated/pairs', 'css-width', 'combined.html'), + ); + await client.callTool({name: 'navigate_page', arguments: {url}}); + const snap = toolText( + await client.callTool({name: 'take_snapshot', arguments: {}}), + ); + const uidA = findUid(snap, 'variant-a'); + await client.callTool({ + name: 'save_computed_styles_snapshot', + arguments: { + name: 'fsnap-width', + uids: [uidA], + properties: ['width'], + }, + }); + await client.callTool({ + name: 'evaluate_script', + arguments: { + function: String((el: Element) => { + (el as HTMLElement).style.setProperty( + 'width', + '200px', + 'important', + ); + return true; + }), + args: [uidA], + }, + }); + const diffRes = await client.callTool({ + name: 'diff_computed_styles_snapshot', + arguments: { + name: 'fsnap-width', + uid: uidA, + properties: ['width'], + }, + }); + const dj = extractJson(toolText(diffRes)) as { + styleChanges: Array<{ + property: string; + before: string; + after: string; + }>; + }; + const ch = dj.styleChanges.find(x => x.property === 'width'); + assert.ok(ch); + assert.notStrictEqual(ch?.before, ch?.after); + }); + }, + ); +}); diff --git a/tests/e2e/comparison/fixtures/tool-smoke-asset.txt b/tests/e2e/comparison/fixtures/tool-smoke-asset.txt new file mode 100644 index 000000000..9766475a4 --- /dev/null +++ b/tests/e2e/comparison/fixtures/tool-smoke-asset.txt @@ -0,0 +1 @@ +ok diff --git a/tests/e2e/comparison/fixtures/tool-smoke.html b/tests/e2e/comparison/fixtures/tool-smoke.html new file mode 100644 index 000000000..fdd6888ef --- /dev/null +++ b/tests/e2e/comparison/fixtures/tool-smoke.html @@ -0,0 +1,27 @@ + + + + + tool-smoke + + + + + load + + + diff --git a/tests/e2e/comparison/generate-fixtures.ts b/tests/e2e/comparison/generate-fixtures.ts new file mode 100644 index 000000000..6ff11aa43 --- /dev/null +++ b/tests/e2e/comparison/generate-fixtures.ts @@ -0,0 +1,227 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {mkdir, rm, writeFile} from 'node:fs/promises'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +import {SCENARIOS, type Scenario} from './scenarios-data.js'; + +const IMG_SRC = + 'data:image/gif;base64,' + + 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + +const BASE_BTN = ` +.variant-a, .variant-b { + display: inline-block; + box-sizing: border-box; + padding: 2px 8px; + margin: 0; + font: 14px system-ui, sans-serif; + border: 1px solid #777; + vertical-align: top; +} +`; + +function deepWrap(inner: string, depth: number): string { + let out = inner; + for (let i = 0; i < depth; i++) { + out = `
${out}
`; + } + return out; +} + +function noiseBlock(n: number): string { + if (n <= 0) { + return ''; + } + const parts: string[] = []; + for (let i = 0; i < n; i++) { + parts.push(''); + } + return parts.join(''); +} + +function buttonPair(): string { + return ` + +`; +} + +function imgPair(): string { + return ` + +`; +} + +function flexHostPair(s: Scenario): string { + return ` +
+ ab +
+
+ ab +
`; +} + +const BASE_IMG = ` +img.variant-a, img.variant-b { + display: inline-block; + width: 48px; + height: 48px; + vertical-align: top; +} +`; + +function styleBlock(s: Scenario): string { + const head = (s.extraHead ?? '').trim(); + if (s.body === 'flex-host') { + return ` +`; + } + if (s.body === 'img') { + return ` +`; + } + return ` +`; +} + +function bodyInner(s: Scenario): string { + const noise = noiseBlock(s.noiseSiblings ?? 0); + let core: string; + switch (s.body) { + case 'img': + core = imgPair(); + break; + case 'flex-host': + core = flexHostPair(s); + break; + case 'wrap': + core = `
${buttonPair()}
`; + break; + case 'grid-host': + core = `
${buttonPair()}
`; + break; + default: + core = buttonPair(); + } + return noise + deepWrap(core, s.bodyDepth ?? 0); +} + +function renderCombined(s: Scenario): string { + return ` + + + + +${s.id} +${styleBlock(s)} + + +${bodyInner(s)} + + +`; +} + +function renderStandalone(s: Scenario, variant: 'a' | 'b'): string { + const css = variant === 'a' ? s.cssA : s.cssB; + const label = variant === 'a' ? 'variant-a' : 'variant-b'; + const cls = variant === 'a' ? 'variant-a' : 'variant-b'; + if (s.body === 'img') { + return ` + +${s.id}-${variant} + + +`; + } + if (s.body === 'flex-host') { + return ` + +${s.id}-${variant} + +
+x
+`; + } + const innerBtn = ` +`; + const wrapped = + s.body === 'grid-host' + ? `
${innerBtn}
` + : s.body === 'wrap' + ? `
${innerBtn}
` + : innerBtn; + return ` + +${s.id}-${variant} + +${wrapped} +`; +} + +export async function generateAll(): Promise { + const dir = path.dirname(fileURLToPath(import.meta.url)); + const root = path.join(dir, 'generated'); + await rm(root, {recursive: true, force: true}); + const pairs = path.join(root, 'pairs'); + + const BATCH = 20; + for (let i = 0; i < SCENARIOS.length; i += BATCH) { + const batch = SCENARIOS.slice(i, i + BATCH); + await Promise.all( + batch.map(async s => { + const sub = path.join(pairs, s.id); + await mkdir(sub, {recursive: true}); + await Promise.all([ + writeFile(path.join(sub, 'combined.html'), renderCombined(s)), + writeFile(path.join(sub, 'a.html'), renderStandalone(s, 'a')), + writeFile(path.join(sub, 'b.html'), renderStandalone(s, 'b')), + ]); + }), + ); + } +} + +async function main(): Promise { + await generateAll(); + console.error( + `Wrote ${SCENARIOS.length} fixture triples under generated/pairs`, + ); +} + +const selfPath = fileURLToPath(import.meta.url); +if (process.argv[1] === selfPath) { + main().catch(e => { + console.error(e); + process.exit(1); + }); +} diff --git a/tests/e2e/comparison/harness.ts b/tests/e2e/comparison/harness.ts new file mode 100644 index 000000000..ff8e898c3 --- /dev/null +++ b/tests/e2e/comparison/harness.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {readFile} from 'node:fs/promises'; + +import {Client} from '@modelcontextprotocol/sdk/client/index.js'; +import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js'; +import {executablePath} from 'puppeteer'; + +export async function connectComparisonClient( + extraArgs: string[] = [], +): Promise<{client: Client; transport: StdioClientTransport}> { + const transport = new StdioClientTransport({ + command: 'node', + args: [ + 'build/src/bin/chrome-devtools-mcp.js', + '--headless', + '--isolated', + '--executable-path', + executablePath(), + '--no-usage-statistics', + '--no-performance-crux', + '--chrome-arg=--no-sandbox', + '--chrome-arg=--disable-setuid-sandbox', + ...extraArgs, + ], + }); + const client = new Client( + {name: 'e2e-comparison', version: '1.0.0'}, + {capabilities: {}}, + ); + await client.connect(transport); + return {client, transport}; +} + +export function extractJson(text: string): unknown { + const match = text.match(/```json\s*([\s\S]*?)\s*```/); + if (!match) { + throw new Error('No JSON block in tool response'); + } + return JSON.parse(match[1]); +} + +export function toolText(res: unknown): string { + const r = res as {content?: Array<{text?: string}>}; + return (r.content?.[0]?.text as string) ?? ''; +} + +export function findUid(snapshotText: string, needle: string): string { + const lines = snapshotText.split('\n'); + for (const line of lines) { + if (!line.includes('uid=') || !line.includes(needle)) { + continue; + } + if (line.includes('RootWebArea')) { + continue; + } + const m = line.match(/uid=(\d+_\d+)/); + if (m) { + return m[1]; + } + } + throw new Error('UID not found for: ' + needle); +} + +const dataUrlCache = new Map(); + +export async function htmlFileAsDataUrl(absolutePath: string): Promise { + const cached = dataUrlCache.get(absolutePath); + if (cached) { + return cached; + } + const raw = await readFile(absolutePath, 'utf8'); + const url = `data:text/html;charset=utf-8,${encodeURIComponent(raw)}`; + dataUrlCache.set(absolutePath, url); + return url; +} + +export async function withClient( + cb: (client: Client) => Promise, + extraArgs: string[] = [], +): Promise { + const {client} = await connectComparisonClient(extraArgs); + try { + await cb(client); + } finally { + await client.close(); + } +} diff --git a/tests/e2e/comparison/mcp-tool-coverage.test.ts b/tests/e2e/comparison/mcp-tool-coverage.test.ts new file mode 100644 index 000000000..2842c5377 --- /dev/null +++ b/tests/e2e/comparison/mcp-tool-coverage.test.ts @@ -0,0 +1,203 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import path from 'node:path'; +import {describe, it} from 'node:test'; + +import { + extractJson, + findUid, + htmlFileAsDataUrl, + toolText, + withClient, +} from './harness.js'; + +const smokeHtml = path.join( + process.cwd(), + 'tests/e2e/comparison/fixtures/tool-smoke.html', +); + +describe('e2e MCP tool coverage (default server)', {timeout: 120_000}, () => { + it('pages, navigation, snapshot, console, network', async () => { + await withClient(async client => { + const url = await htmlFileAsDataUrl(smokeHtml); + await client.callTool({name: 'list_pages', arguments: {}}); + await client.callTool({name: 'navigate_page', arguments: {url}}); + await client.callTool({ + name: 'resize_page', + arguments: {width: 900, height: 700}, + }); + const snap = toolText( + await client.callTool({name: 'take_snapshot', arguments: {}}), + ); + const uidGo = findUid(snap, 'go-smoke'); + await client.callTool({ + name: 'wait_for', + arguments: {text: ['go-smoke'], timeout: 5000}, + }); + await client.callTool({ + name: 'list_console_messages', + arguments: {}, + }); + await client.callTool({ + name: 'list_network_requests', + arguments: {}, + }); + await client.callTool({ + name: 'take_screenshot', + arguments: {format: 'png'}, + }); + await client.callTool({ + name: 'emulate', + arguments: {viewport: {width: 800, height: 600}}, + }); + await client.callTool({ + name: 'click', + arguments: {uid: uidGo}, + }); + await client.callTool({ + name: 'hover', + arguments: {uid: uidGo}, + }); + const fieldUid = findUid( + toolText(await client.callTool({name: 'take_snapshot', arguments: {}})), + 'field-smoke', + ); + await client.callTool({ + name: 'fill', + arguments: {uid: fieldUid, value: 'x'}, + }); + await client.callTool({ + name: 'type_text', + arguments: {uid: fieldUid, text: 'y'}, + }); + await client.callTool({ + name: 'press_key', + arguments: {key: 'Enter'}, + }); + }); + }); + + it( + 'styles, geometry, performance, memory, lighthouse', + {timeout: 120_000}, + async () => { + await withClient(async client => { + const url = await htmlFileAsDataUrl(smokeHtml); + await client.callTool({name: 'navigate_page', arguments: {url}}); + const snap = toolText( + await client.callTool({name: 'take_snapshot', arguments: {}}), + ); + const uid = findUid(snap, 'go-smoke'); + const cs = extractJson( + toolText( + await client.callTool({ + name: 'get_computed_styles', + arguments: {uid, properties: ['display'], includeSources: true}, + }), + ), + ) as {computed: Record}; + assert.ok(cs.computed.display); + + const batch = extractJson( + toolText( + await client.callTool({ + name: 'get_computed_styles_batch', + arguments: {uids: [uid], properties: ['display']}, + }), + ), + ) as Record; + assert.ok(Object.keys(batch).length >= 1); + + extractJson( + toolText( + await client.callTool({name: 'get_box_model', arguments: {uid}}), + ), + ); + + extractJson( + toolText( + await client.callTool({name: 'get_visibility', arguments: {uid}}), + ), + ); + + const snap2 = toolText( + await client.callTool({name: 'take_snapshot', arguments: {}}), + ); + const uidField = findUid(snap2, 'field-smoke'); + extractJson( + toolText( + await client.callTool({ + name: 'diff_computed_styles', + arguments: {uidA: uid, uidB: uidField, properties: ['display']}, + }), + ), + ); + + await client.callTool({ + name: 'save_computed_styles_snapshot', + arguments: {name: 'cov-snap', uids: [uid], properties: ['display']}, + }); + extractJson( + toolText( + await client.callTool({ + name: 'diff_computed_styles_snapshot', + arguments: {name: 'cov-snap', uid, properties: ['display']}, + }), + ), + ); + + await client.callTool({ + name: 'highlight_elements_for_styles', + arguments: {uids: [uid]}, + }); + + await client.callTool({ + name: 'performance_start_trace', + arguments: {reload: false}, + }); + await client.callTool({ + name: 'performance_stop_trace', + arguments: {}, + }); + + await client.callTool({ + name: 'lighthouse_audit', + arguments: {mode: 'snapshot', device: 'desktop'}, + }); + + await client.callTool({name: 'take_memory_snapshot', arguments: {}}); + }); + }, + ); + + it('new_page lists additional tab', async () => { + await withClient(async client => { + const url = await htmlFileAsDataUrl(smokeHtml); + await client.callTool({name: 'navigate_page', arguments: {url}}); + const np = await client.callTool({ + name: 'new_page', + arguments: {url: 'about:blank'}, + }); + assert.ok(toolText(np).length > 0); + const pages = await client.callTool({name: 'list_pages', arguments: {}}); + assert.ok(toolText(pages).length > 0); + }); + }); +}); + +describe('e2e MCP tool coverage (interop flags)', () => { + it('get_tab_id', async () => { + await withClient( + async client => { + const r = await client.callTool({name: 'get_tab_id', arguments: {}}); + assert.ok(toolText(r).length > 0); + }, + ['--experimental-interop-tools'], + ); + }); +}); diff --git a/tests/e2e/comparison/scenarios-data.ts b/tests/e2e/comparison/scenarios-data.ts new file mode 100644 index 000000000..66a456384 --- /dev/null +++ b/tests/e2e/comparison/scenarios-data.ts @@ -0,0 +1,406 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export type ScenarioBody = + | 'buttons' + | 'img' + | 'flex-host' + | 'wrap' + | 'grid-host'; + +export interface Scenario { + id: string; + property: string; + cssA: string; + cssB: string; + extraHead?: string; + bodyDepth?: number; + noiseSiblings?: number; + body?: ScenarioBody; + compareGeometry?: boolean; +} + +function pushSimple( + out: Scenario[], + id: string, + property: string, + valA: string, + valB: string, + extra?: Partial, +): void { + out.push({ + id, + property, + cssA: `${property}: ${valA}`, + cssB: `${property}: ${valB}`, + ...extra, + }); +} + +function buildSpacingSizeBorder(out: Scenario[]): void { + const marginSides = [ + ['margin-top', '2px', '12px'], + ['margin-right', '2px', '12px'], + ['margin-bottom', '2px', '12px'], + ['margin-left', '2px', '12px'], + ] as const; + for (const [p, a, b] of marginSides) { + pushSimple(out, `css-${p}`, p, a, b); + } + pushSimple(out, 'css-margin-shorthand', 'margin-top', '4px', '20px', { + cssA: 'margin: 4px', + cssB: 'margin: 20px', + }); + + const padSides = [ + ['padding-top', '2px', '14px'], + ['padding-right', '2px', '14px'], + ['padding-bottom', '2px', '14px'], + ['padding-left', '2px', '14px'], + ] as const; + for (const [p, a, b] of padSides) { + pushSimple(out, `css-${p}`, p, a, b); + } + pushSimple(out, 'css-padding-shorthand', 'padding-top', '3px', '18px', { + cssA: 'padding: 3px', + cssB: 'padding: 18px', + }); + + pushSimple(out, 'css-width', 'width', '80px', '120px'); + pushSimple(out, 'css-height', 'height', '24px', '48px'); + pushSimple(out, 'css-min-width', 'min-width', '0px', '60px'); + pushSimple(out, 'css-max-width', 'max-width', '200px', '80px'); + pushSimple(out, 'css-min-height', 'min-height', '0px', '40px'); + pushSimple(out, 'css-max-height', 'max-height', '200px', '50px'); + + pushSimple(out, 'css-border-width', 'border-top-width', '1px', '6px'); + pushSimple(out, 'css-border-style', 'border-top-style', 'solid', 'dashed'); + pushSimple( + out, + 'css-border-color', + 'border-top-color', + 'rgb(0, 128, 0)', + 'rgb(128, 0, 128)', + ); + pushSimple(out, 'css-border-radius', 'border-top-left-radius', '0px', '16px'); + pushSimple( + out, + 'css-border-top-left-radius', + 'border-top-left-radius', + '0px', + '12px', + ); + pushSimple(out, 'css-outline-width', 'outline-width', '0px', '3px', { + cssA: 'outline-style: solid; outline-width: 0px; outline-color: rgb(0, 0, 0)', + cssB: 'outline-style: solid; outline-width: 3px; outline-color: rgb(0, 0, 0)', + }); + pushSimple(out, 'css-outline-style', 'outline-style', 'none', 'solid'); + pushSimple( + out, + 'css-outline-color', + 'outline-color', + 'rgb(255, 0, 0)', + 'rgb(0, 0, 255)', + ); +} + +function buildTypographyColors(out: Scenario[]): void { + pushSimple(out, 'css-font-size', 'font-size', '12px', '22px'); + pushSimple(out, 'css-line-height', 'line-height', '16px', '28px'); + pushSimple(out, 'css-letter-spacing', 'letter-spacing', 'normal', '4px'); + pushSimple(out, 'css-word-spacing', 'word-spacing', 'normal', '8px'); + pushSimple(out, 'css-font-weight', 'font-weight', '400', '700'); + pushSimple(out, 'css-font-style', 'font-style', 'normal', 'italic'); + pushSimple(out, 'css-font-family', 'font-family', 'serif', 'monospace'); + pushSimple( + out, + 'css-text-decoration-line', + 'text-decoration-line', + 'none', + 'underline', + ); + pushSimple(out, 'css-text-transform', 'text-transform', 'none', 'uppercase'); + pushSimple(out, 'css-overflow-wrap', 'overflow-wrap', 'normal', 'anywhere'); + pushSimple(out, 'css-color', 'color', 'rgb(10, 10, 10)', 'rgb(200, 50, 50)'); + pushSimple( + out, + 'css-background-color', + 'background-color', + 'rgb(240, 240, 240)', + 'rgb(20, 40, 200)', + ); + pushSimple(out, 'css-opacity', 'opacity', '1', '0.35'); + pushSimple(out, 'css-outline-offset', 'outline-offset', '0px', '6px'); +} + +function buildLayoutPosition(out: Scenario[]): void { + pushSimple(out, 'css-display', 'display', 'inline-block', 'block'); + out.push({ + id: 'css-position-offset', + property: 'top', + cssA: 'position: relative; top: 0px; left: 0px', + cssB: 'position: relative; top: 8px; left: 6px', + }); + pushSimple(out, 'css-float', 'float', 'none', 'left'); + pushSimple(out, 'css-clear', 'clear', 'none', 'both'); + pushSimple(out, 'css-z-index', 'z-index', '1', '50'); + pushSimple(out, 'css-overflow', 'overflow-x', 'visible', 'hidden', { + cssA: 'overflow-x: visible; overflow-y: visible', + cssB: 'overflow-x: hidden; overflow-y: visible', + }); + pushSimple(out, 'css-box-sizing', 'box-sizing', 'content-box', 'border-box'); + pushSimple(out, 'css-aspect-ratio', 'aspect-ratio', 'auto', '2 / 1'); + pushSimple( + out, + 'css-writing-mode', + 'writing-mode', + 'horizontal-tb', + 'vertical-rl', + ); +} + +function buildFlexGrid(out: Scenario[]): void { + pushSimple(out, 'css-flex-grow', 'flex-grow', '0', '2'); + pushSimple(out, 'css-flex-shrink', 'flex-shrink', '1', '0'); + pushSimple(out, 'css-flex-basis', 'flex-basis', 'auto', '40px'); + pushSimple(out, 'css-align-self', 'align-self', 'auto', 'flex-end'); + pushSimple(out, 'css-order', 'order', '0', '3'); + out.push({ + id: 'css-grid-column-start', + property: 'grid-column-start', + cssA: 'grid-column-start: auto', + cssB: 'grid-column-start: 1', + extraHead: ` + .grid-host { + display: grid; + grid-template-columns: 1fr 1fr; + } + `, + body: 'grid-host', + }); +} + +function buildTransformsEffects(out: Scenario[]): void { + pushSimple(out, 'css-transform', 'transform', 'none', 'rotate(8deg)'); + pushSimple(out, 'css-transform-scale', 'transform', 'none', 'scale(1.15)'); + pushSimple( + out, + 'css-transform-translate', + 'transform', + 'none', + 'translate(4px, 3px)', + ); + pushSimple( + out, + 'css-box-shadow', + 'box-shadow', + 'none', + '4px 4px 8px rgba(0,0,0,0.35)', + ); + pushSimple( + out, + 'css-text-shadow', + 'text-shadow', + 'none', + '2px 2px 4px rgba(0,0,0,0.5)', + ); + pushSimple(out, 'css-filter', 'filter', 'none', 'brightness(0.85)'); +} + +function buildLogicalUnits(out: Scenario[]): void { + pushSimple(out, 'css-inline-size', 'inline-size', '70px', '110px'); + pushSimple(out, 'css-block-size', 'block-size', '22px', '44px'); + pushSimple( + out, + 'css-margin-inline-start', + 'margin-inline-start', + '2px', + '18px', + ); + out.push({ + id: 'css-inset', + property: 'top', + cssA: 'position: relative; inset: 0px', + cssB: 'position: relative; inset: 4px 6px 2px 8px', + }); + pushSimple(out, 'css-rem-vs-px', 'width', '5rem', '120px'); +} + +function buildLayersAndVars(out: Scenario[]): void { + out.push({ + id: 'css-layer-order', + property: 'color', + cssA: '', + cssB: 'color: rgb(0, 0, 255)', + extraHead: ` + @layer base, theme; + @layer theme { .variant-a { color: rgb(200, 0, 0); } } + @layer base { .variant-a { color: rgb(0, 180, 0); } } + `, + }); + out.push({ + id: 'css-custom-prop-diff', + property: 'color', + cssA: '--x: rgb(50, 50, 50); color: var(--x)', + cssB: '--x: rgb(200, 100, 100); color: var(--x)', + }); +} + +function buildDomScale(out: Scenario[]): void { + pushSimple(out, 'dom-depth-12', 'border-top-width', '1px', '5px', { + bodyDepth: 12, + }); + pushSimple(out, 'dom-depth-28', 'border-top-width', '1px', '5px', { + bodyDepth: 28, + }); + pushSimple(out, 'dom-noise-siblings-40', 'font-size', '13px', '19px', { + noiseSiblings: 40, + }); + pushSimple(out, 'dom-noise-siblings-200', 'font-size', '13px', '19px', { + noiseSiblings: 200, + }); +} + +function buildImgBody(out: Scenario[]): void { + pushSimple(out, 'img-object-fit', 'object-fit', 'fill', 'cover', { + body: 'img', + property: 'object-fit', + }); + pushSimple(out, 'img-opacity', 'opacity', '1', '0.4', { + body: 'img', + }); + pushSimple(out, 'img-transform', 'transform', 'none', 'rotate(12deg)', { + body: 'img', + }); +} + +function buildFlexHost(out: Scenario[]): void { + out.push({ + id: 'flex-host-gap-row', + property: 'row-gap', + cssA: 'display: flex; flex-wrap: wrap; row-gap: 4px; width: 120px', + cssB: 'display: flex; flex-wrap: wrap; row-gap: 28px; width: 120px', + body: 'flex-host', + }); + out.push({ + id: 'flex-host-column-gap', + property: 'column-gap', + cssA: 'display: flex; column-gap: 2px', + cssB: 'display: flex; column-gap: 24px', + body: 'flex-host', + }); +} + +function buildGeometryFlag(out: Scenario[]): void { + pushSimple(out, 'geom-compare-width', 'width', '60px', '100px', { + compareGeometry: true, + }); + pushSimple(out, 'geom-compare-padding', 'padding-top', '4px', '20px', { + compareGeometry: true, + cssA: 'padding: 4px', + cssB: 'padding: 20px', + }); +} + +function buildScssCompiledComment(out: Scenario[]): void { + out.push({ + id: 'scss-compiled-nested', + property: 'color', + cssA: 'color: rgb(90, 90, 200)', + cssB: 'color: rgb(200, 90, 90)', + extraHead: ` + /* expanded from SCSS nesting */ + `, + body: 'wrap', + }); +} + +function buildEquivalentUnits(out: Scenario[]): void { + pushSimple(out, 'unit-em-font-size', 'font-size', '1em', '1.5em'); + pushSimple(out, 'unit-pct-width', 'width', '40%', '65%'); + pushSimple(out, 'unit-ch-width', 'width', '12ch', '20ch'); +} + +function buildMoreSurface(out: Scenario[]): void { + pushSimple(out, 'css-text-align', 'text-align', 'left', 'right'); + pushSimple(out, 'css-vertical-align', 'vertical-align', 'baseline', 'super'); + pushSimple(out, 'css-border-right-width', 'border-right-width', '1px', '9px'); + pushSimple( + out, + 'css-border-bottom-width', + 'border-bottom-width', + '1px', + '9px', + ); + pushSimple(out, 'css-overflow-x', 'overflow-x', 'visible', 'scroll'); + pushSimple(out, 'css-overflow-y', 'overflow-y', 'visible', 'auto'); + pushSimple(out, 'css-user-select', 'user-select', 'auto', 'none'); + pushSimple(out, 'css-pointer-events', 'pointer-events', 'auto', 'none'); + pushSimple(out, 'css-cursor', 'cursor', 'default', 'pointer'); + pushSimple(out, 'css-isolation', 'isolation', 'auto', 'isolate'); + pushSimple(out, 'css-mix-blend-mode', 'mix-blend-mode', 'normal', 'multiply'); + pushSimple(out, 'css-scroll-margin-top', 'scroll-margin-top', '0px', '12px'); + pushSimple(out, 'css-column-count', 'column-count', 'auto', '2'); + pushSimple(out, 'css-flex-direction', 'flex-direction', 'row', 'column', { + extraHead: ` + .variant-a, .variant-b { display: flex; } + `, + }); + pushSimple( + out, + 'css-justify-content', + 'justify-content', + 'flex-start', + 'flex-end', + { + extraHead: ` + .variant-a, .variant-b { display: flex; width: 140px; } + `, + }, + ); + pushSimple(out, 'css-align-items', 'align-items', 'stretch', 'center', { + extraHead: ` + .variant-a, .variant-b { display: flex; height: 48px; } + `, + }); + pushSimple(out, 'css-row-gap-flex', 'row-gap', '2px', '18px', { + extraHead: ` + .variant-a, .variant-b { display: flex; flex-wrap: wrap; width: 100px; } + `, + }); + pushSimple(out, 'css-border-top-width', 'border-top-width', '1px', '7px'); + pushSimple(out, 'css-border-left-width', 'border-left-width', '1px', '7px'); + pushSimple(out, 'css-text-indent', 'text-indent', '0px', '12px'); +} + +export function buildScenarios(): Scenario[] { + const out: Scenario[] = []; + buildSpacingSizeBorder(out); + buildTypographyColors(out); + buildLayoutPosition(out); + buildFlexGrid(out); + buildTransformsEffects(out); + buildLogicalUnits(out); + buildLayersAndVars(out); + buildDomScale(out); + buildImgBody(out); + buildFlexHost(out); + buildGeometryFlag(out); + buildScssCompiledComment(out); + buildEquivalentUnits(out); + buildMoreSurface(out); + + const ids = new Set(out.map(s => s.id)); + if (ids.size !== out.length) { + throw new Error('duplicate scenario id'); + } + if (out.length < 100) { + throw new Error(`need at least 100 scenarios, got ${out.length}`); + } + return out; +} + +export const SCENARIOS: Scenario[] = buildScenarios(); diff --git a/tests/index.test.ts b/tests/index.test.ts index f08350c08..65550bacc 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -27,6 +27,10 @@ describe('e2e', () => { '--isolated', '--executable-path', executablePath(), + '--no-usage-statistics', + '--no-performance-crux', + '--chrome-arg=--no-sandbox', + '--chrome-arg=--disable-setuid-sandbox', ...extraArgs, ], }); diff --git a/tests/tools/pages.test.ts b/tests/tools/pages.test.ts index 3fb5892b6..a2a2cc4c8 100644 --- a/tests/tools/pages.test.ts +++ b/tests/tools/pages.test.ts @@ -37,6 +37,34 @@ const EXTENSION_SIDE_PANEL_PATH = path.join( '../../../tests/tools/fixtures/extension-side-panel', ); +/** + * Shared Puppeteer browsers can retain other extensions' service workers. + * Keep snapshots stable by keeping only SW lines for this test's extension + * and renumbering sw-N. + */ +function normalizeListPagesSnapshotText( + raw: string, + extensionId: string, +): string { + const marked = raw.replaceAll(extensionId, ''); + const lines = marked.split('\n'); + const out: string[] = []; + let swCounter = 0; + for (const line of lines) { + const swMatch = /^sw-\d+:(.*)$/.exec(line); + if (swMatch) { + if (!line.includes('')) { + continue; + } + swCounter++; + out.push(`sw-${swCounter}:${swMatch[1]}`); + continue; + } + out.push(line); + } + return out.join('\n'); +} + describe('pages', () => { afterEach(() => { sinon.restore(); @@ -89,9 +117,9 @@ describe('pages', () => { }; assert.ok(textContent); - const text = textContent.text.replaceAll( + const text = normalizeListPagesSnapshotText( + textContent.text, extensionId, - '', ); t.assert.snapshot?.(text); }, @@ -115,7 +143,7 @@ describe('pages', () => { const swTarget = await context.browser.waitForTarget( target => target.type() === 'service_worker' && - target.url().includes('chrome-extension://'), + target.url().includes(`chrome-extension://${extensionId}/`), ); const swUrl = swTarget.url(); @@ -135,15 +163,18 @@ describe('pages', () => { const structured = result.structuredContent as { extensionServiceWorkers: Array<{url: string}>; }; + const ours = structured.extensionServiceWorkers.filter(sw => + sw.url.includes(extensionId), + ); assert.deepStrictEqual( - structured.extensionServiceWorkers.map(sw => sw.url), + ours.map(sw => sw.url), [swUrl], ); } - const text = textContent.text.replaceAll( + const text = normalizeListPagesSnapshotText( + textContent.text, extensionId, - '', ); t.assert.snapshot?.(text); }, @@ -188,9 +219,9 @@ describe('pages', () => { }; assert.ok(textContent); - const text = textContent.text.replaceAll( + const text = normalizeListPagesSnapshotText( + textContent.text, extensionId, - '', ); t.assert.snapshot?.(text); }, diff --git a/tests/tools/styles.test.ts b/tests/tools/styles.test.ts new file mode 100644 index 000000000..ed460209b --- /dev/null +++ b/tests/tools/styles.test.ts @@ -0,0 +1,276 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import {describe, it} from 'node:test'; + +import { + diffComputedStyles, + diffComputedStylesSnapshot, + getBoxModel, + getComputedStyles, + getComputedStylesBatch, + getVisibility, + saveComputedStylesSnapshot, +} from '../../src/tools/styles.js'; +import {html, withMcpContext} from '../utils.js'; + +describe('styles', () => { + describe('get_computed_styles', () => { + it('returns filtered computed styles', async () => { + await withMcpContext(async (response, context) => { + const page = context.getSelectedPptrPage(); + await page.setContent( + html`
box
`, + ); + await context.createTextSnapshot(context.getSelectedMcpPage()); + + await getComputedStyles.handler( + { + params: { + uid: '1_1', + properties: ['display'], + }, + page: context.getSelectedMcpPage(), + }, + 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 withMcpContext(async (response, context) => { + const page = context.getSelectedPptrPage(); + await page.setContent( + html`
box
`, + ); + await context.createTextSnapshot(context.getSelectedMcpPage()); + + await getComputedStyles.handler( + { + params: { + uid: '1_1', + properties: ['display'], + includeSources: true, + }, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + + const json = response.responseLines.at(2)!; + const parsed = JSON.parse(json) as { + computed: Record; + sourceMap?: Record; + }; + assert.strictEqual(parsed.computed.display, 'block'); + assert.strictEqual(parsed.sourceMap?.display?.source, 'inline'); + }); + }); + }); + + describe('get_box_model', () => { + it('returns box quads and rects', async () => { + await withMcpContext(async (response, context) => { + const page = context.getSelectedPptrPage(); + await page.setContent( + html`
box
`, + ); + await context.createTextSnapshot(context.getSelectedMcpPage()); + + await getBoxModel.handler( + {params: {uid: '1_1'}, page: context.getSelectedMcpPage()}, + 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 withMcpContext(async (response, context) => { + const page = context.getSelectedPptrPage(); + await page.setContent(html`
hidden
`); + await context.createTextSnapshot(context.getSelectedMcpPage()); + await page.evaluate(() => { + const el = document.getElementById('box'); + if (el) { + el.style.display = 'none'; + } + }); + + await getVisibility.handler( + {params: {uid: '1_1'}, page: context.getSelectedMcpPage()}, + 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 withMcpContext(async (response, context) => { + const page = context.getSelectedPptrPage(); + await page.setContent(html`
box
inline`); + await context.createTextSnapshot(context.getSelectedMcpPage()); + + await getComputedStylesBatch.handler( + { + params: { + uids: ['1_1', '1_2'], + properties: ['display'], + }, + page: context.getSelectedMcpPage(), + }, + 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 withMcpContext(async (response, context) => { + const page = context.getSelectedPptrPage(); + await page.setContent(html`
box
inline`); + await context.createTextSnapshot(context.getSelectedMcpPage()); + + await diffComputedStyles.handler( + { + params: { + uidA: '1_1', + uidB: '1_2', + properties: ['display'], + }, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + + const json = response.responseLines.at(2)!; + const parsed = JSON.parse(json) as { + styleChanges: Array<{ + property: string; + before: string; + after: string; + }>; + }; + const display = parsed.styleChanges.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 withMcpContext(async (response, context) => { + const page = context.getSelectedPptrPage(); + await page.setContent( + html`
box
`, + ); + await context.createTextSnapshot(context.getSelectedMcpPage()); + + await saveComputedStylesSnapshot.handler( + { + params: { + name: 'snap1', + uids: ['1_1'], + properties: ['display'], + }, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + + 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'], + }, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + + const json = response.responseLines.at(2)!; + const parsed = JSON.parse(json) as { + styleChanges: Array<{ + property: string; + before: string; + after: string; + }>; + }; + const display = parsed.styleChanges.find(p => p.property === 'display'); + assert.strictEqual(display?.before, 'block'); + assert.strictEqual(display?.after, 'inline'); + }); + }); + }); +}); diff --git a/tests/utils.ts b/tests/utils.ts index f764fa9ce..78f2b9cd7 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -74,7 +74,11 @@ export async function withBrowser( devtools: options.autoOpenDevTools ?? false, pipe: true, handleDevToolsAsPage: true, - args: ['--screen-info={3840x2160}'], + args: [ + '--screen-info={3840x2160}', + '--no-sandbox', + '--disable-setuid-sandbox', + ], enableExtensions: true, }; const key = JSON.stringify(launchOptions);