From 89060017b5d33806e15275e8efece37a8de95cd7 Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Mon, 2 Mar 2026 14:27:45 +0100 Subject: [PATCH] fix: simplify emulation tools --- CONTRIBUTING.md | 7 ++ docs/tool-reference.md | 12 +-- src/McpContext.ts | 156 +++++++++++++++------------------- src/tools/ToolDefinition.ts | 55 ++++++++++-- src/tools/emulation.ts | 65 +++----------- src/tools/script.ts | 16 ++-- src/types.ts | 12 +-- tests/McpResponse.test.ts | 2 +- tests/tools/emulation.test.ts | 85 +++++++++++++++--- tests/tools/script.test.ts | 8 +- 10 files changed, 235 insertions(+), 183 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f1fe46408..9f64034ca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,3 +119,10 @@ export const scenario: TestScenario = { }, }; ``` + +## Restrictions on JSON schema + +- no .nullable(), no .object() types. +- represent complex object as a short formatted string. + +TODO: implement eslint for schema https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/1076 diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 8c33310db..d11829c5e 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -1,6 +1,6 @@ -# Chrome DevTools MCP Tool Reference (~7324 cl100k_base tokens) +# Chrome DevTools MCP Tool Reference (~6919 cl100k_base tokens) - **[Input automation](#input-automation)** (9 tools) - [`click`](#click) @@ -222,11 +222,11 @@ **Parameters:** - **colorScheme** (enum: "dark", "light", "auto") _(optional)_: [`Emulate`](#emulate) the dark or the light mode. Set to "auto" to reset to the default. -- **cpuThrottlingRate** (number) _(optional)_: Represents the CPU slowdown factor. Set the rate to 1 to disable throttling. If omitted, throttling remains unchanged. -- **geolocation** (unknown) _(optional)_: Geolocation to [`emulate`](#emulate). Set to null to clear the geolocation override. -- **networkConditions** (enum: "No emulation", "Offline", "Slow 3G", "Fast 3G", "Slow 4G", "Fast 4G") _(optional)_: Throttle network. Set to "No emulation" to disable. If omitted, conditions remain unchanged. -- **userAgent** (unknown) _(optional)_: User agent to [`emulate`](#emulate). Set to null to clear the user agent override. -- **viewport** (unknown) _(optional)_: Viewport to [`emulate`](#emulate). Set to null to reset to the default viewport. +- **cpuThrottlingRate** (number) _(optional)_: Represents the CPU slowdown factor. Omit or set the rate to 1 to disable throttling +- **geolocation** (string) _(optional)_: Geolocation (`<latitude>x<longitude>`) to [`emulate`](#emulate). Latitude between -90 and 90. Longitude between -180 and 180. Omit clear the geolocation override. +- **networkConditions** (enum: "Offline", "Slow 3G", "Fast 3G", "Slow 4G", "Fast 4G") _(optional)_: Throttle network. Omit to disable throttling. +- **userAgent** (string) _(optional)_: User agent to [`emulate`](#emulate). Set to empty string to clear the user agent override. +- **viewport** (string) _(optional)_: [`Emulate`](#emulate) device viewports '<width>x<height>x<devicePixelRatio>[,mobile][,touch][,landscape]'. 'touch' and 'mobile' to [`emulate`](#emulate) mobile devices. 'landscape' to [`emulate`](#emulate) landscape mode. --- diff --git a/src/McpContext.ts b/src/McpContext.ts index 0d8b6d76f..8cb44f64b 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -306,115 +306,95 @@ export class McpContext implements Context { async emulate( options: { - networkConditions?: string | null; - cpuThrottlingRate?: number | null; - geolocation?: GeolocationOptions | null; - userAgent?: string | null; - colorScheme?: 'dark' | 'light' | 'auto' | null; - viewport?: Viewport | null; + networkConditions?: string; + cpuThrottlingRate?: number; + geolocation?: GeolocationOptions; + userAgent?: string; + colorScheme?: 'dark' | 'light' | 'auto'; + viewport?: Viewport; }, targetPage?: Page, ): Promise { const page = targetPage ?? this.getSelectedPptrPage(); const mcpPage = this.#getMcpPage(page); const newSettings: EmulationSettings = {...mcpPage.emulationSettings}; - let timeoutsNeedUpdate = false; - - if (options.networkConditions !== undefined) { - timeoutsNeedUpdate = true; - if ( - options.networkConditions === null || - options.networkConditions === 'No emulation' - ) { - await page.emulateNetworkConditions(null); - delete newSettings.networkConditions; - } else if (options.networkConditions === 'Offline') { - await page.emulateNetworkConditions({ - offline: true, - download: 0, - upload: 0, - latency: 0, - }); - newSettings.networkConditions = 'Offline'; - } else if (options.networkConditions in PredefinedNetworkConditions) { - const networkCondition = - PredefinedNetworkConditions[ - options.networkConditions as keyof typeof PredefinedNetworkConditions - ]; - await page.emulateNetworkConditions(networkCondition); - newSettings.networkConditions = options.networkConditions; - } - } - if (options.cpuThrottlingRate !== undefined) { - timeoutsNeedUpdate = true; - if (options.cpuThrottlingRate === null) { - await page.emulateCPUThrottling(1); - delete newSettings.cpuThrottlingRate; - } else { - await page.emulateCPUThrottling(options.cpuThrottlingRate); - newSettings.cpuThrottlingRate = options.cpuThrottlingRate; - } + if (!options.networkConditions) { + await page.emulateNetworkConditions(null); + delete newSettings.networkConditions; + } else if (options.networkConditions === 'Offline') { + await page.emulateNetworkConditions({ + offline: true, + download: 0, + upload: 0, + latency: 0, + }); + newSettings.networkConditions = 'Offline'; + } else if (options.networkConditions in PredefinedNetworkConditions) { + const networkCondition = + PredefinedNetworkConditions[ + options.networkConditions as keyof typeof PredefinedNetworkConditions + ]; + await page.emulateNetworkConditions(networkCondition); + newSettings.networkConditions = options.networkConditions; + } + + if (!options.cpuThrottlingRate) { + await page.emulateCPUThrottling(1); + delete newSettings.cpuThrottlingRate; + } else { + await page.emulateCPUThrottling(options.cpuThrottlingRate); + newSettings.cpuThrottlingRate = options.cpuThrottlingRate; } - if (options.geolocation !== undefined) { - if (options.geolocation === null) { - await page.setGeolocation({latitude: 0, longitude: 0}); - delete newSettings.geolocation; - } else { - await page.setGeolocation(options.geolocation); - newSettings.geolocation = options.geolocation; - } + if (!options.geolocation) { + await page.setGeolocation({latitude: 0, longitude: 0}); + delete newSettings.geolocation; + } else { + await page.setGeolocation(options.geolocation); + newSettings.geolocation = options.geolocation; } - if (options.userAgent !== undefined) { - if (options.userAgent === null) { - await page.setUserAgent({userAgent: undefined}); - delete newSettings.userAgent; - } else { - await page.setUserAgent({userAgent: options.userAgent}); - newSettings.userAgent = options.userAgent; - } + if (!options.userAgent) { + await page.setUserAgent({userAgent: undefined}); + delete newSettings.userAgent; + } else { + await page.setUserAgent({userAgent: options.userAgent}); + newSettings.userAgent = options.userAgent; } - if (options.colorScheme !== undefined) { - if (options.colorScheme === null || options.colorScheme === 'auto') { - await page.emulateMediaFeatures([ - {name: 'prefers-color-scheme', value: ''}, - ]); - delete newSettings.colorScheme; - } else { - await page.emulateMediaFeatures([ - {name: 'prefers-color-scheme', value: options.colorScheme}, - ]); - newSettings.colorScheme = options.colorScheme; - } + if (!options.colorScheme || options.colorScheme === 'auto') { + await page.emulateMediaFeatures([ + {name: 'prefers-color-scheme', value: ''}, + ]); + delete newSettings.colorScheme; + } else { + await page.emulateMediaFeatures([ + {name: 'prefers-color-scheme', value: options.colorScheme}, + ]); + newSettings.colorScheme = options.colorScheme; } - if (options.viewport !== undefined) { - if (options.viewport === null) { - await page.setViewport(null); - delete newSettings.viewport; - } else { - const defaults = { - deviceScaleFactor: 1, - isMobile: false, - hasTouch: false, - isLandscape: false, - }; - const viewport = {...defaults, ...options.viewport}; - await page.setViewport(viewport); - newSettings.viewport = viewport; - } + if (!options.viewport) { + await page.setViewport(null); + delete newSettings.viewport; + } else { + const defaults = { + deviceScaleFactor: 1, + isMobile: false, + hasTouch: false, + isLandscape: false, + }; + const viewport = {...defaults, ...options.viewport}; + await page.setViewport(viewport); + newSettings.viewport = viewport; } mcpPage.emulationSettings = Object.keys(newSettings).length ? newSettings : {}; - if (timeoutsNeedUpdate) { - this.#updateSelectedPageTimeouts(); - } + this.#updateSelectedPageTimeouts(); } setIsRunningPerformanceTrace(x: boolean): void { diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 543b202db..5230a4db3 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -150,12 +150,12 @@ export type Context = Readonly<{ restoreEmulation(page: ContextPage): Promise; emulate( options: { - networkConditions?: string | null; - cpuThrottlingRate?: number | null; - geolocation?: GeolocationOptions | null; - userAgent?: string | null; - colorScheme?: 'dark' | 'light' | 'auto' | null; - viewport?: Viewport | null; + networkConditions?: string; + cpuThrottlingRate?: number; + geolocation?: GeolocationOptions; + userAgent?: string; + colorScheme?: 'dark' | 'light' | 'auto'; + viewport?: Viewport; }, targetPage?: Page, ): Promise; @@ -310,3 +310,46 @@ export const timeoutSchema = { return value && value <= 0 ? undefined : value; }), }; + +export function viewportTransform(arg: string | undefined): + | { + width: number; + height: number; + deviceScaleFactor?: number; + isMobile?: boolean; + isLandscape?: boolean; + hasTouch?: boolean; + } + | undefined { + if (!arg) { + return undefined; + } + const [dimensions, ...tags] = arg.split(','); + const isMobile = tags.includes('mobile'); + const hasTouch = tags.includes('touch'); + const isLandscape = tags.includes('landscape'); + const [width, height, dpr] = dimensions.split('x').map(Number) as [ + number, + number, + number | undefined, + ]; + return { + width, + height, + deviceScaleFactor: dpr, + isMobile: isMobile, + isLandscape: isLandscape, + hasTouch: hasTouch, + }; +} + +export function geolocationTransform(arg: string | undefined) { + if (!arg) { + return undefined; + } + const [latitude, longitude] = arg.split('x').map(Number) as [number, number]; + return { + latitude, + longitude, + }; +} diff --git a/src/tools/emulation.ts b/src/tools/emulation.ts index ed6ded49c..66eaa849d 100644 --- a/src/tools/emulation.ts +++ b/src/tools/emulation.ts @@ -8,10 +8,13 @@ import {zod, PredefinedNetworkConditions} from '../third_party/index.js'; import {ToolCategory} from './categories.js'; -import {definePageTool} from './ToolDefinition.js'; +import { + definePageTool, + geolocationTransform, + viewportTransform, +} from './ToolDefinition.js'; const throttlingOptions: [string, ...string[]] = [ - 'No emulation', 'Offline', ...Object.keys(PredefinedNetworkConditions), ]; @@ -27,41 +30,27 @@ export const emulate = definePageTool({ networkConditions: zod .enum(throttlingOptions) .optional() - .describe( - `Throttle network. Set to "No emulation" to disable. If omitted, conditions remain unchanged.`, - ), + .describe(`Throttle network. Omit to disable throttling.`), cpuThrottlingRate: zod .number() .min(1) .max(20) .optional() .describe( - 'Represents the CPU slowdown factor. Set the rate to 1 to disable throttling. If omitted, throttling remains unchanged.', + 'Represents the CPU slowdown factor. Omit or set the rate to 1 to disable throttling', ), geolocation: zod - .object({ - latitude: zod - .number() - .min(-90) - .max(90) - .describe('Latitude between -90 and 90.'), - longitude: zod - .number() - .min(-180) - .max(180) - .describe('Longitude between -180 and 180.'), - }) - .nullable() + .string() .optional() + .transform(geolocationTransform) .describe( - 'Geolocation to emulate. Set to null to clear the geolocation override.', + 'Geolocation (`x`) to emulate. Latitude between -90 and 90. Longitude between -180 and 180. Omit clear the geolocation override.', ), userAgent: zod .string() - .nullable() .optional() .describe( - 'User agent to emulate. Set to null to clear the user agent override.', + 'User agent to emulate. Set to empty string to clear the user agent override.', ), colorScheme: zod .enum(['dark', 'light', 'auto']) @@ -70,37 +59,11 @@ export const emulate = definePageTool({ 'Emulate the dark or the light mode. Set to "auto" to reset to the default.', ), viewport: zod - .object({ - width: zod.number().int().min(0).describe('Page width in pixels.'), - height: zod.number().int().min(0).describe('Page height in pixels.'), - deviceScaleFactor: zod - .number() - .min(0) - .optional() - .describe('Specify device scale factor (can be thought of as dpr).'), - isMobile: zod - .boolean() - .optional() - .describe( - 'Whether the meta viewport tag is taken into account. Defaults to false.', - ), - hasTouch: zod - .boolean() - .optional() - .describe( - 'Specifies if viewport supports touch events. This should be set to true for mobile devices.', - ), - isLandscape: zod - .boolean() - .optional() - .describe( - 'Specifies if viewport is in landscape mode. Defaults to false.', - ), - }) - .nullable() + .string() .optional() + .transform(viewportTransform) .describe( - 'Viewport to emulate. Set to null to reset to the default viewport.', + `Emulate device viewports 'xx[,mobile][,touch][,landscape]'. 'touch' and 'mobile' to emulate mobile devices. 'landscape' to emulate landscape mode.`, ), }, handler: async (request, _response, context) => { diff --git a/src/tools/script.ts b/src/tools/script.ts index fa2d18c17..e3ff14d7d 100644 --- a/src/tools/script.ts +++ b/src/tools/script.ts @@ -38,13 +38,11 @@ Example with arguments: \`(el) => { ), args: zod .array( - zod.object({ - uid: zod - .string() - .describe( - 'The uid of an element on the page from the page content snapshot', - ), - }), + zod + .string() + .describe( + 'The uid of an element on the page from the page content snapshot', + ), ) .optional() .describe(`An optional list of arguments to pass to the function.`), @@ -91,8 +89,8 @@ Example with arguments: \`(el) => { const args: Array> = []; try { const frames = new Set(); - for (const el of uidArgs ?? []) { - const handle = await mcpPage.getElementByUid(el.uid); + for (const uid of uidArgs ?? []) { + const handle = await mcpPage.getElementByUid(uid); frames.add(handle.frame); args.push(handle); } diff --git a/src/types.ts b/src/types.ts index a85796edf..4efd8dc93 100644 --- a/src/types.ts +++ b/src/types.ts @@ -36,10 +36,10 @@ export interface TextSnapshot { } export interface EmulationSettings { - networkConditions?: string | null; - cpuThrottlingRate?: number | null; - geolocation?: GeolocationOptions | null; - userAgent?: string | null; - colorScheme?: 'dark' | 'light' | null; - viewport?: Viewport | null; + networkConditions?: string; + cpuThrottlingRate?: number; + geolocation?: GeolocationOptions; + userAgent?: string; + colorScheme?: 'dark' | 'light'; + viewport?: Viewport; } diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts index 141f4f0bb..0936a1e2b 100644 --- a/tests/McpResponse.test.ts +++ b/tests/McpResponse.test.ts @@ -302,7 +302,7 @@ describe('McpResponse', () => { 'test', context, ); - await context.emulate({networkConditions: null}); + await context.emulate({}); assert.equal(content[0].type, 'text'); assert.strictEqual(getTextContent(content[0]), `# test response`); t.assert.snapshot?.( diff --git a/tests/tools/emulation.test.ts b/tests/tools/emulation.test.ts index 36e45aa2c..721505fc2 100644 --- a/tests/tools/emulation.test.ts +++ b/tests/tools/emulation.test.ts @@ -8,12 +8,81 @@ import assert from 'node:assert'; import {beforeEach, describe, it} from 'node:test'; import {emulate} from '../../src/tools/emulation.js'; +import { + geolocationTransform, + viewportTransform, +} from '../../src/tools/ToolDefinition.js'; import {serverHooks} from '../server.js'; import {html, withMcpContext} from '../utils.js'; describe('emulation', () => { const server = serverHooks(); + describe('transforms', () => { + describe('viewportTransform', () => { + it('returns undefined for undefined input', () => { + assert.strictEqual(viewportTransform(undefined), undefined); + }); + + it('parses basic dimensions', () => { + assert.deepStrictEqual(viewportTransform('800x600'), { + width: 800, + height: 600, + deviceScaleFactor: undefined, + isMobile: false, + isLandscape: false, + hasTouch: false, + }); + }); + + it('parses dimensions with devicePixelRatio', () => { + assert.deepStrictEqual(viewportTransform('1024x768x2'), { + width: 1024, + height: 768, + deviceScaleFactor: 2, + isMobile: false, + isLandscape: false, + hasTouch: false, + }); + }); + + it('parses mobile and touch tags', () => { + assert.deepStrictEqual(viewportTransform('375x667x2,mobile,touch'), { + width: 375, + height: 667, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }); + }); + + it('parses landscape tag', () => { + assert.deepStrictEqual(viewportTransform('1024x768x1,landscape'), { + width: 1024, + height: 768, + deviceScaleFactor: 1, + isMobile: false, + hasTouch: false, + isLandscape: true, + }); + }); + }); + + describe('geolocationTransform', () => { + it('returns undefined for undefined input', () => { + assert.strictEqual(geolocationTransform(undefined), undefined); + }); + + it('parses latitude and longitude', () => { + assert.deepStrictEqual(geolocationTransform('48.137154x11.576124'), { + latitude: 48.137154, + longitude: 11.576124, + }); + }); + }); + }); + describe('network', () => { it('emulates offline network conditions', async () => { await withMcpContext(async (response, context) => { @@ -58,9 +127,7 @@ describe('emulation', () => { await withMcpContext(async (response, context) => { await emulate.handler( { - params: { - networkConditions: 'No emulation', - }, + params: {}, page: context.getSelectedMcpPage(), }, response, @@ -229,9 +296,7 @@ describe('emulation', () => { // Then clear it by setting geolocation to null await emulate.handler( { - params: { - geolocation: null, - }, + params: {}, page: context.getSelectedMcpPage(), }, response, @@ -347,9 +412,7 @@ describe('emulation', () => { // Then clear it by setting viewport to null await emulate.handler( { - params: { - viewport: null, - }, + params: {}, page: context.getSelectedMcpPage(), }, response, @@ -465,9 +528,7 @@ describe('emulation', () => { await emulate.handler( { - params: { - userAgent: null, - }, + params: {}, page: context.getSelectedMcpPage(), }, response, diff --git a/tests/tools/script.test.ts b/tests/tools/script.test.ts index 787367269..90c1b34b3 100644 --- a/tests/tools/script.test.ts +++ b/tests/tools/script.test.ts @@ -137,7 +137,7 @@ describe('script', () => { function: String(async (el: Element) => { return el.id; }), - args: [{uid: '1_1'}], + args: ['1_1'], }, }, response, @@ -162,7 +162,7 @@ describe('script', () => { function: String((container: Element, child: Element) => { return container.contains(child); }), - args: [{uid: '1_0'}, {uid: '1_1'}], + args: ['1_0', '1_1'], }, }, response, @@ -190,7 +190,7 @@ describe('script', () => { function: String((element: Element) => { return element.textContent; }), - args: [{uid: '1_3'}], + args: ['1_3'], }, }, response, @@ -286,7 +286,7 @@ describe('script', () => { params: { function: String(() => 'test'), serviceWorkerId: 'example_service_worker', - args: [{uid: '1_1'}], + args: ['1_1'], }, }, response,