diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 9dccd3e7..aae061da 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -200,6 +200,8 @@ - **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. --- diff --git a/scripts/eval_scenarios/emulation_userAgent_test.ts b/scripts/eval_scenarios/emulation_userAgent_test.ts new file mode 100644 index 00000000..ee0cfc7e --- /dev/null +++ b/scripts/eval_scenarios/emulation_userAgent_test.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; + +import type {TestScenario} from '../eval_gemini.ts'; + +export const scenario: TestScenario = { + prompt: 'Emulate iPhone 14 user agent', + maxTurns: 2, + expectations: calls => { + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0].name, 'emulate'); + assert.deepStrictEqual( + calls[0].args.userAgent, + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1', + ); + }, +}; diff --git a/scripts/eval_scenarios/emulation_viewport_test.ts b/scripts/eval_scenarios/emulation_viewport_test.ts new file mode 100644 index 00000000..0aed9f77 --- /dev/null +++ b/scripts/eval_scenarios/emulation_viewport_test.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; + +import {KnownDevices} from 'puppeteer'; + +import type {TestScenario} from '../eval_gemini.ts'; + +export const scenario: TestScenario = { + prompt: 'Emulate iPhone 14 viewport', + maxTurns: 2, + expectations: calls => { + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0].name, 'emulate'); + assert.deepStrictEqual( + { + ...(calls[0].args.viewport as object), + // models might not send defaults. + isLandscape: KnownDevices['iPhone 14'].viewport.isLandscape ?? false, + }, + { + ...KnownDevices['iPhone 14'].viewport, + height: 844, // Puppeteer is wrong about the expected height. + }, + ); + }, +}; diff --git a/src/McpContext.ts b/src/McpContext.ts index f1c9019e..5c5f510c 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -28,6 +28,7 @@ import type { Page, SerializedAXNode, PredefinedNetworkConditions, + Viewport, } from './third_party/index.js'; import {listPages} from './tools/pages.js'; import {takeSnapshot} from './tools/snapshot.js'; @@ -115,6 +116,8 @@ export class McpContext implements Context { #networkConditionsMap = new WeakMap(); #cpuThrottlingRateMap = new WeakMap(); #geolocationMap = new WeakMap(); + #viewportMap = new WeakMap(); + #userAgentMap = new WeakMap(); #dialog?: Dialog; #pageIdMap = new WeakMap(); @@ -314,6 +317,34 @@ export class McpContext implements Context { return this.#geolocationMap.get(page) ?? null; } + setViewport(viewport: Viewport | null): void { + const page = this.getSelectedPage(); + if (viewport === null) { + this.#viewportMap.delete(page); + } else { + this.#viewportMap.set(page, viewport); + } + } + + getViewport(): Viewport | null { + const page = this.getSelectedPage(); + return this.#viewportMap.get(page) ?? null; + } + + setUserAgent(userAgent: string | null): void { + const page = this.getSelectedPage(); + if (userAgent === null) { + this.#userAgentMap.delete(page); + } else { + this.#userAgentMap.set(page, userAgent); + } + } + + getUserAgent(): string | null { + const page = this.getSelectedPage(); + return this.#userAgentMap.get(page) ?? null; + } + setIsRunningPerformanceTrace(x: boolean): void { this.#isRunningTrace = x; } diff --git a/src/McpResponse.ts b/src/McpResponse.ts index eddfde07..4f628c7a 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -432,6 +432,18 @@ export class McpResponse implements Response { ); } + const viewport = context.getViewport(); + if (viewport) { + response.push(`## Viewport emulation`); + response.push(`Emulating viewport: ${JSON.stringify(viewport)}`); + } + + const userAgent = context.getUserAgent(); + if (userAgent) { + response.push(`## UserAgent emulation`); + response.push(`Emulating userAgent: ${userAgent}`); + } + const cpuThrottlingRate = context.getCpuThrottlingRate(); if (cpuThrottlingRate > 1) { response.push(`## CPU emulation`); diff --git a/src/third_party/index.ts b/src/third_party/index.ts index f7587cf8..55c09495 100644 --- a/src/third_party/index.ts +++ b/src/third_party/index.ts @@ -25,6 +25,7 @@ export {z as zod} from 'zod'; export { Locator, PredefinedNetworkConditions, + KnownDevices, CDPSessionEvent, } from 'puppeteer-core'; export {default as puppeteer} from 'puppeteer-core'; diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 73bc18d1..78d0432a 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -6,7 +6,12 @@ import type {TextSnapshotNode, GeolocationOptions} from '../McpContext.js'; import {zod} from '../third_party/index.js'; -import type {Dialog, ElementHandle, Page} from '../third_party/index.js'; +import type { + Dialog, + ElementHandle, + Page, + Viewport, +} from '../third_party/index.js'; import type {TraceResult} from '../trace-processing/parse.js'; import type {PaginationOptions} from '../utils/types.js'; @@ -105,6 +110,10 @@ export type Context = Readonly<{ setNetworkConditions(conditions: string | null): void; setCpuThrottlingRate(rate: number): void; setGeolocation(geolocation: GeolocationOptions | null): void; + setViewport(viewport: Viewport | null): void; + getViewport(): Viewport | null; + setUserAgent(userAgent: string | null): void; + getUserAgent(): string | null; saveTemporaryFile( data: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp', diff --git a/src/tools/emulation.ts b/src/tools/emulation.ts index 13119250..325e05f0 100644 --- a/src/tools/emulation.ts +++ b/src/tools/emulation.ts @@ -2,6 +2,7 @@ * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 + * */ import {zod, PredefinedNetworkConditions} from '../third_party/index.js'; @@ -55,10 +56,56 @@ export const emulate = defineTool({ .describe( 'Geolocation to emulate. Set to null to clear the geolocation override.', ), + userAgent: zod + .string() + .nullable() + .optional() + .describe( + 'User agent to emulate. Set to null to clear the user agent override.', + ), + 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() + .optional() + .describe( + 'Viewport to emulate. Set to null to reset to the default viewport.', + ), }, handler: async (request, _response, context) => { const page = context.getSelectedPage(); - const {networkConditions, cpuThrottlingRate, geolocation} = request.params; + const { + networkConditions, + cpuThrottlingRate, + geolocation, + userAgent, + viewport, + } = request.params; if (networkConditions) { if (networkConditions === 'No emulation') { @@ -96,5 +143,35 @@ export const emulate = defineTool({ context.setGeolocation(geolocation); } } + + if (userAgent !== undefined) { + if (userAgent === null) { + await page.setUserAgent({ + userAgent: undefined, + }); + context.setUserAgent(null); + } else { + await page.setUserAgent({ + userAgent, + }); + context.setUserAgent(userAgent); + } + } + + if (viewport !== undefined) { + if (viewport === null) { + await page.setViewport(null); + context.setViewport(null); + } else { + const defaults = { + deviceScaleFactor: 1, + isMobile: false, + hasTouch: false, + isLandscape: false, + }; + await page.setViewport({...defaults, ...viewport}); + context.setViewport({...defaults, ...viewport}); + } + } }, }); diff --git a/tests/McpResponse.test.js.snapshot b/tests/McpResponse.test.js.snapshot index 461c98d2..678305fc 100644 --- a/tests/McpResponse.test.js.snapshot +++ b/tests/McpResponse.test.js.snapshot @@ -74,6 +74,18 @@ Emulating: Slow 3G Default navigation timeout set to 100000 ms `; +exports[`McpResponse > adds userAgent emulation setting when it is set 1`] = ` +# test response +## UserAgent emulation +Emulating userAgent: MyUA +`; + +exports[`McpResponse > adds viewport emulation setting when it is set 1`] = ` +# test response +## Viewport emulation +Emulating viewport: {"width":400,"height":400,"deviceScaleFactor":1} +`; + exports[`McpResponse > allows response text lines to be added 1`] = ` # test response Testing 1 diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts index f6c68b37..41e50fd4 100644 --- a/tests/McpResponse.test.ts +++ b/tests/McpResponse.test.ts @@ -182,6 +182,22 @@ describe('McpResponse', () => { }); }); + it('adds viewport emulation setting when it is set', async t => { + await withMcpContext(async (response, context) => { + context.setViewport({width: 400, height: 400, deviceScaleFactor: 1}); + const {content} = await response.handle('test', context); + t.assert.snapshot?.(getTextContent(content[0])); + }); + }); + + it('adds userAgent emulation setting when it is set', async t => { + await withMcpContext(async (response, context) => { + context.setUserAgent('MyUA'); + const {content} = await response.handle('test', context); + t.assert.snapshot?.(getTextContent(content[0])); + }); + }); + it('adds a prompt dialog', async t => { await withMcpContext(async (response, context) => { const page = context.getSelectedPage(); diff --git a/tests/tools/emulation.test.ts b/tests/tools/emulation.test.ts index 5275f564..b27b111e 100644 --- a/tests/tools/emulation.test.ts +++ b/tests/tools/emulation.test.ts @@ -8,9 +8,12 @@ import assert from 'node:assert'; import {describe, it} from 'node:test'; import {emulate} from '../../src/tools/emulation.js'; +import {serverHooks} from '../server.js'; import {withMcpContext} from '../utils.js'; describe('emulation', () => { + const server = serverHooks(); + describe('network', () => { it('emulates offline network conditions', async () => { await withMcpContext(async (response, context) => { @@ -234,4 +237,223 @@ describe('emulation', () => { }); }); }); + describe('viewport', () => { + it('emulates viewport', async () => { + server.addHtmlRoute( + '/viewport', + '', + ); + await withMcpContext(async (response, context) => { + const page = context.getSelectedPage(); + await page.goto(server.baseUrl + '/viewport'); + await emulate.handler( + { + params: { + viewport: { + width: 400, + height: 400, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + }, + response, + context, + ); + + const viewportData = await page.evaluate(() => { + return { + width: window.innerWidth, + height: window.innerHeight, + deviceScaleFactor: window.devicePixelRatio, + hasTouch: navigator.maxTouchPoints > 0, + }; + }); + + assert.deepStrictEqual(viewportData, { + width: 400, + height: 400, + deviceScaleFactor: 2, + hasTouch: true, + }); + }); + }); + + it('clears viewport override when viewport is set to null', async () => { + await withMcpContext(async (response, context) => { + // First set a viewport + await emulate.handler( + { + params: { + viewport: { + width: 400, + height: 400, + }, + }, + }, + response, + context, + ); + + assert.ok(context.getViewport()); + + // Then clear it by setting viewport to null + await emulate.handler( + { + params: { + viewport: null, + }, + }, + response, + context, + ); + + assert.strictEqual(context.getViewport(), null); + + const viewportData = await context.getSelectedPage().evaluate(() => { + return { + width: window.innerWidth, + height: window.innerHeight, + deviceScaleFactor: window.devicePixelRatio, + hasTouch: navigator.maxTouchPoints > 0, + }; + }); + + assert.notStrictEqual(viewportData.width, 400); + assert.notStrictEqual(viewportData.height, 400); + }); + }); + + it('reports correctly for the currently selected page', async () => { + await withMcpContext(async (response, context) => { + await emulate.handler( + { + params: { + viewport: { + width: 400, + height: 400, + }, + }, + }, + response, + context, + ); + + assert.ok(context.getViewport()); + + const page = await context.newPage(); + context.selectPage(page); + + assert.strictEqual(context.getViewport(), null); + }); + }); + }); + + describe('userAgent', () => { + it('emulates userAgent', async () => { + await withMcpContext(async (response, context) => { + await emulate.handler( + { + params: { + userAgent: 'MyUA', + }, + }, + response, + context, + ); + + assert.strictEqual(context.getUserAgent(), 'MyUA'); + const page = context.getSelectedPage(); + const ua = await page.evaluate(() => navigator.userAgent); + assert.strictEqual(ua, 'MyUA'); + }); + }); + + it('updates userAgent', async () => { + await withMcpContext(async (response, context) => { + await emulate.handler( + { + params: { + userAgent: 'UA1', + }, + }, + response, + context, + ); + assert.strictEqual(context.getUserAgent(), 'UA1'); + + await emulate.handler( + { + params: { + userAgent: 'UA2', + }, + }, + response, + context, + ); + assert.strictEqual(context.getUserAgent(), 'UA2'); + const page = context.getSelectedPage(); + const ua = await page.evaluate(() => navigator.userAgent); + assert.strictEqual(ua, 'UA2'); + }); + }); + + it('clears userAgent override when userAgent is set to null', async () => { + await withMcpContext(async (response, context) => { + // First set a userAgent + await emulate.handler( + { + params: { + userAgent: 'MyUA', + }, + }, + response, + context, + ); + + assert.strictEqual(context.getUserAgent(), 'MyUA'); + + // Then clear it by setting userAgent to null + await emulate.handler( + { + params: { + userAgent: null, + }, + }, + response, + context, + ); + + assert.strictEqual(context.getUserAgent(), null); + const page = context.getSelectedPage(); + const ua = await page.evaluate(() => navigator.userAgent); + assert.notStrictEqual(ua, 'MyUA'); + // It should be something like "HeadlessChrome" or similar default + assert.ok(ua.length > 0); + }); + }); + + it('reports correctly for the currently selected page', async () => { + await withMcpContext(async (response, context) => { + await emulate.handler( + { + params: { + userAgent: 'MyUA', + }, + }, + response, + context, + ); + + assert.strictEqual(context.getUserAgent(), 'MyUA'); + + const page = await context.newPage(); + context.selectPage(page); + + assert.strictEqual(context.getUserAgent(), null); + }); + }); + }); });