Skip to content

Commit 9f7c4c9

Browse files
committed
feat: support device emulation
1 parent aa9a176 commit 9f7c4c9

File tree

11 files changed

+420
-4
lines changed

11 files changed

+420
-4
lines changed

docs/tool-reference.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,8 @@
200200
- **cpuThrottlingRate** (number) _(optional)_: Represents the CPU slowdown factor. Set the rate to 1 to disable throttling. If omitted, throttling remains unchanged.
201201
- **geolocation** (unknown) _(optional)_: Geolocation to [`emulate`](#emulate). Set to null to clear the geolocation override.
202202
- **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.
203+
- **userAgent** (unknown) _(optional)_: User agent to [`emulate`](#emulate). Set to null to clear the user agent override.
204+
- **viewport** (unknown) _(optional)_: Viewport to [`emulate`](#emulate). Set to null to reset to the default viewport.
203205

204206
---
205207

scripts/eval_scenarios/emulation_test.ts renamed to scripts/eval_scenarios/emulation_userAgent_test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,14 @@ import assert from 'node:assert';
99
import type {TestScenario} from '../eval_gemini.ts';
1010

1111
export const scenario: TestScenario = {
12-
prompt: 'Emulate offline network conditions.',
12+
prompt: 'Emulate iPhone 14 user agent',
1313
maxTurns: 2,
1414
expectations: calls => {
1515
assert.strictEqual(calls.length, 1);
1616
assert.strictEqual(calls[0].name, 'emulate');
17-
assert.strictEqual(calls[0].args.networkConditions, 'Offline');
17+
assert.deepStrictEqual(
18+
calls[0].args.userAgent,
19+
'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',
20+
);
1821
},
1922
};
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import assert from 'node:assert';
8+
9+
import {KnownDevices} from 'puppeteer';
10+
11+
import type {TestScenario} from '../eval_gemini.ts';
12+
13+
export const scenario: TestScenario = {
14+
prompt: 'Emulate iPhone 14 viewport',
15+
maxTurns: 2,
16+
expectations: calls => {
17+
assert.strictEqual(calls.length, 1);
18+
assert.strictEqual(calls[0].name, 'emulate');
19+
assert.deepStrictEqual(
20+
{
21+
...(calls[0].args.viewport as object),
22+
// models might not send defaults.
23+
isLandscape: KnownDevices['iPhone 14'].viewport.isLandscape ?? false,
24+
},
25+
{
26+
...KnownDevices['iPhone 14'].viewport,
27+
height: 844, // Puppeteer is wrong about the expected height.
28+
},
29+
);
30+
},
31+
};

src/McpContext.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import type {
2828
Page,
2929
SerializedAXNode,
3030
PredefinedNetworkConditions,
31+
Viewport,
3132
} from './third_party/index.js';
3233
import {listPages} from './tools/pages.js';
3334
import {takeSnapshot} from './tools/snapshot.js';
@@ -115,6 +116,8 @@ export class McpContext implements Context {
115116
#networkConditionsMap = new WeakMap<Page, string>();
116117
#cpuThrottlingRateMap = new WeakMap<Page, number>();
117118
#geolocationMap = new WeakMap<Page, GeolocationOptions>();
119+
#viewportMap = new WeakMap<Page, Viewport>();
120+
#userAgentMap = new WeakMap<Page, string>();
118121
#dialog?: Dialog;
119122

120123
#pageIdMap = new WeakMap<Page, number>();
@@ -314,6 +317,34 @@ export class McpContext implements Context {
314317
return this.#geolocationMap.get(page) ?? null;
315318
}
316319

320+
setViewport(viewport: Viewport | null): void {
321+
const page = this.getSelectedPage();
322+
if (viewport === null) {
323+
this.#viewportMap.delete(page);
324+
} else {
325+
this.#viewportMap.set(page, viewport);
326+
}
327+
}
328+
329+
getViewport(): Viewport | null {
330+
const page = this.getSelectedPage();
331+
return this.#viewportMap.get(page) ?? null;
332+
}
333+
334+
setUserAgent(userAgent: string | null): void {
335+
const page = this.getSelectedPage();
336+
if (userAgent === null) {
337+
this.#userAgentMap.delete(page);
338+
} else {
339+
this.#userAgentMap.set(page, userAgent);
340+
}
341+
}
342+
343+
getUserAgent(): string | null {
344+
const page = this.getSelectedPage();
345+
return this.#userAgentMap.get(page) ?? null;
346+
}
347+
317348
setIsRunningPerformanceTrace(x: boolean): void {
318349
this.#isRunningTrace = x;
319350
}

src/McpResponse.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,18 @@ export class McpResponse implements Response {
432432
);
433433
}
434434

435+
const viewport = context.getViewport();
436+
if (viewport) {
437+
response.push(`## Viewport emulation`);
438+
response.push(`Emulating viewport: ${JSON.stringify(viewport)}`);
439+
}
440+
441+
const userAgent = context.getUserAgent();
442+
if (userAgent) {
443+
response.push(`## UserAgent emulation`);
444+
response.push(`Emulating userAgent: ${userAgent}`);
445+
}
446+
435447
const cpuThrottlingRate = context.getCpuThrottlingRate();
436448
if (cpuThrottlingRate > 1) {
437449
response.push(`## CPU emulation`);

src/third_party/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export {z as zod} from 'zod';
2525
export {
2626
Locator,
2727
PredefinedNetworkConditions,
28+
KnownDevices,
2829
CDPSessionEvent,
2930
} from 'puppeteer-core';
3031
export {default as puppeteer} from 'puppeteer-core';

src/tools/ToolDefinition.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@
66

77
import type {TextSnapshotNode, GeolocationOptions} from '../McpContext.js';
88
import {zod} from '../third_party/index.js';
9-
import type {Dialog, ElementHandle, Page} from '../third_party/index.js';
9+
import type {
10+
Dialog,
11+
ElementHandle,
12+
Page,
13+
Viewport,
14+
} from '../third_party/index.js';
1015
import type {TraceResult} from '../trace-processing/parse.js';
1116
import type {PaginationOptions} from '../utils/types.js';
1217

@@ -105,6 +110,10 @@ export type Context = Readonly<{
105110
setNetworkConditions(conditions: string | null): void;
106111
setCpuThrottlingRate(rate: number): void;
107112
setGeolocation(geolocation: GeolocationOptions | null): void;
113+
setViewport(viewport: Viewport | null): void;
114+
getViewport(): Viewport | null;
115+
setUserAgent(userAgent: string | null): void;
116+
getUserAgent(): string | null;
108117
saveTemporaryFile(
109118
data: Uint8Array<ArrayBufferLike>,
110119
mimeType: 'image/png' | 'image/jpeg' | 'image/webp',

src/tools/emulation.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* @license
33
* Copyright 2025 Google LLC
44
* SPDX-License-Identifier: Apache-2.0
5+
*
56
*/
67

78
import {zod, PredefinedNetworkConditions} from '../third_party/index.js';
@@ -55,10 +56,56 @@ export const emulate = defineTool({
5556
.describe(
5657
'Geolocation to emulate. Set to null to clear the geolocation override.',
5758
),
59+
userAgent: zod
60+
.string()
61+
.nullable()
62+
.optional()
63+
.describe(
64+
'User agent to emulate. Set to null to clear the user agent override.',
65+
),
66+
viewport: zod
67+
.object({
68+
width: zod.number().int().min(0).describe('Page width in pixels.'),
69+
height: zod.number().int().min(0).describe('Page height in pixels.'),
70+
deviceScaleFactor: zod
71+
.number()
72+
.min(0)
73+
.optional()
74+
.describe('Specify device scale factor (can be thought of as dpr).'),
75+
isMobile: zod
76+
.boolean()
77+
.optional()
78+
.describe(
79+
'Whether the meta viewport tag is taken into account. Defaults to false.',
80+
),
81+
hasTouch: zod
82+
.boolean()
83+
.optional()
84+
.describe(
85+
'Specifies if viewport supports touch events. This should be set to true for mobile devices.',
86+
),
87+
isLandscape: zod
88+
.boolean()
89+
.optional()
90+
.describe(
91+
'Specifies if viewport is in landscape mode. Defaults to false.',
92+
),
93+
})
94+
.nullable()
95+
.optional()
96+
.describe(
97+
'Viewport to emulate. Set to null to reset to the default viewport.',
98+
),
5899
},
59100
handler: async (request, _response, context) => {
60101
const page = context.getSelectedPage();
61-
const {networkConditions, cpuThrottlingRate, geolocation} = request.params;
102+
const {
103+
networkConditions,
104+
cpuThrottlingRate,
105+
geolocation,
106+
userAgent,
107+
viewport,
108+
} = request.params;
62109

63110
if (networkConditions) {
64111
if (networkConditions === 'No emulation') {
@@ -96,5 +143,35 @@ export const emulate = defineTool({
96143
context.setGeolocation(geolocation);
97144
}
98145
}
146+
147+
if (userAgent !== undefined) {
148+
if (userAgent === null) {
149+
await page.setUserAgent({
150+
userAgent: undefined,
151+
});
152+
context.setUserAgent(null);
153+
} else {
154+
await page.setUserAgent({
155+
userAgent,
156+
});
157+
context.setUserAgent(userAgent);
158+
}
159+
}
160+
161+
if (viewport !== undefined) {
162+
if (viewport === null) {
163+
await page.setViewport(null);
164+
context.setViewport(null);
165+
} else {
166+
const defaults = {
167+
deviceScaleFactor: 1,
168+
isMobile: false,
169+
hasTouch: false,
170+
isLandscape: false,
171+
};
172+
await page.setViewport({...defaults, ...viewport});
173+
context.setViewport({...defaults, ...viewport});
174+
}
175+
}
99176
},
100177
});

tests/McpResponse.test.js.snapshot

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,18 @@ Emulating: Slow 3G
7474
Default navigation timeout set to 100000 ms
7575
`;
7676

77+
exports[`McpResponse > adds userAgent emulation setting when it is set 1`] = `
78+
# test response
79+
## UserAgent emulation
80+
Emulating userAgent: MyUA
81+
`;
82+
83+
exports[`McpResponse > adds viewport emulation setting when it is set 1`] = `
84+
# test response
85+
## Viewport emulation
86+
Emulating viewport: {"width":400,"height":400,"deviceScaleFactor":1}
87+
`;
88+
7789
exports[`McpResponse > allows response text lines to be added 1`] = `
7890
# test response
7991
Testing 1

tests/McpResponse.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,22 @@ describe('McpResponse', () => {
182182
});
183183
});
184184

185+
it('adds viewport emulation setting when it is set', async t => {
186+
await withMcpContext(async (response, context) => {
187+
context.setViewport({width: 400, height: 400, deviceScaleFactor: 1});
188+
const {content} = await response.handle('test', context);
189+
t.assert.snapshot?.(getTextContent(content[0]));
190+
});
191+
});
192+
193+
it('adds userAgent emulation setting when it is set', async t => {
194+
await withMcpContext(async (response, context) => {
195+
context.setUserAgent('MyUA');
196+
const {content} = await response.handle('test', context);
197+
t.assert.snapshot?.(getTextContent(content[0]));
198+
});
199+
});
200+
185201
it('adds a prompt dialog', async t => {
186202
await withMcpContext(async (response, context) => {
187203
const page = context.getSelectedPage();

0 commit comments

Comments
 (0)