Skip to content

Commit db46f4d

Browse files
committed
feat: support device emulation
1 parent ee35f20 commit db46f4d

File tree

8 files changed

+162
-2
lines changed

8 files changed

+162
-2
lines changed

docs/tool-reference.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@
197197

198198
**Parameters:**
199199

200+
- **device** (unknown) **(required)**: Mobile device to [`emulate`](#emulate). Set to null to disable device emulation.
200201
- **cpuThrottlingRate** (number) _(optional)_: Represents the CPU slowdown factor. Set the rate to 1 to disable throttling. If omitted, throttling remains unchanged.
201202
- **geolocation** (unknown) _(optional)_: Geolocation to [`emulate`](#emulate). Set to null to clear the geolocation override.
202203
- **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.

src/McpContext.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ export class McpContext implements Context {
109109
#networkConditionsMap = new WeakMap<Page, string>();
110110
#cpuThrottlingRateMap = new WeakMap<Page, number>();
111111
#geolocationMap = new WeakMap<Page, GeolocationOptions>();
112+
#deviceMap = new WeakMap<Page, string>();
112113
#dialog?: Dialog;
113114

114115
#pageIdMap = new WeakMap<Page, number>();
@@ -301,6 +302,20 @@ export class McpContext implements Context {
301302
return this.#geolocationMap.get(page) ?? null;
302303
}
303304

305+
setDevice(device: string | null): void {
306+
const page = this.getSelectedPage();
307+
if (device === null) {
308+
this.#deviceMap.delete(page);
309+
} else {
310+
this.#deviceMap.set(page, device);
311+
}
312+
}
313+
314+
getDevice(): string | null {
315+
const page = this.getSelectedPage();
316+
return this.#deviceMap.get(page) ?? null;
317+
}
318+
304319
setIsRunningPerformanceTrace(x: boolean): void {
305320
this.#isRunningTrace = x;
306321
}

src/McpResponse.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,12 @@ export class McpResponse implements Response {
413413
);
414414
}
415415

416+
const device = context.getDevice();
417+
if (device) {
418+
response.push(`## Device emulation`);
419+
response.push(`Emulating: ${device}`);
420+
}
421+
416422
const cpuThrottlingRate = context.getCpuThrottlingRate();
417423
if (cpuThrottlingRate > 1) {
418424
response.push(`## CPU emulation`);

src/third_party/index.ts

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

src/tools/ToolDefinition.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ export type Context = Readonly<{
105105
setNetworkConditions(conditions: string | null): void;
106106
setCpuThrottlingRate(rate: number): void;
107107
setGeolocation(geolocation: GeolocationOptions | null): void;
108+
setDevice(device: string | null): void;
109+
getDevice(): string | null;
108110
saveTemporaryFile(
109111
data: Uint8Array<ArrayBufferLike>,
110112
mimeType: 'image/png' | 'image/jpeg' | 'image/webp',

src/tools/emulation.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import {zod, PredefinedNetworkConditions} from '../third_party/index.js';
7+
import {
8+
zod,
9+
PredefinedNetworkConditions,
10+
KnownDevices,
11+
} from '../third_party/index.js';
812

913
import {ToolCategory} from './categories.js';
1014
import {defineTool} from './ToolDefinition.js';
@@ -15,6 +19,10 @@ const throttlingOptions: [string, ...string[]] = [
1519
...Object.keys(PredefinedNetworkConditions),
1620
];
1721

22+
const deviceOptions: [string, ...string[]] = [
23+
...(Object.keys(KnownDevices) as [string, ...string[]]),
24+
];
25+
1826
export const emulate = defineTool({
1927
name: 'emulate',
2028
description: `Emulates various features on the selected page.`,
@@ -55,10 +63,18 @@ export const emulate = defineTool({
5563
.describe(
5664
'Geolocation to emulate. Set to null to clear the geolocation override.',
5765
),
66+
device: zod
67+
.enum(deviceOptions)
68+
.optional()
69+
.nullable()
70+
.describe(
71+
'Mobile device to emulate. Set to null to disable device emulation.',
72+
),
5873
},
5974
handler: async (request, _response, context) => {
6075
const page = context.getSelectedPage();
61-
const {networkConditions, cpuThrottlingRate, geolocation} = request.params;
76+
const {networkConditions, cpuThrottlingRate, geolocation, device} =
77+
request.params;
6278

6379
if (networkConditions) {
6480
if (networkConditions === 'No emulation') {
@@ -96,5 +112,18 @@ export const emulate = defineTool({
96112
context.setGeolocation(geolocation);
97113
}
98114
}
115+
116+
if (device !== undefined) {
117+
if (device === null) {
118+
await page.setViewport(null);
119+
await page.setUserAgent('');
120+
context.setDevice(null);
121+
} else if (device in KnownDevices) {
122+
const deviceDefinition =
123+
KnownDevices[device as keyof typeof KnownDevices];
124+
await page.emulate(deviceDefinition);
125+
context.setDevice(device);
126+
}
127+
}
99128
},
100129
});

tests/McpResponse.test.ts

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

185+
it('adds device emulation setting when it is set', async t => {
186+
await withMcpContext(async (response, context) => {
187+
context.setDevice('iPhone 13');
188+
const {content} = await response.handle('test', context);
189+
t.assert.snapshot?.(getTextContent(content[0]));
190+
});
191+
});
192+
185193
it('adds a prompt dialog', async t => {
186194
await withMcpContext(async (response, context) => {
187195
const page = context.getSelectedPage();

tests/tools/emulation.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@ import assert from 'node:assert';
88
import {describe, it} from 'node:test';
99

1010
import {emulate} from '../../src/tools/emulation.js';
11+
import {serverHooks} from '../server.js';
1112
import {withMcpContext} from '../utils.js';
1213

1314
describe('emulation', () => {
15+
const server = serverHooks();
16+
1417
describe('network', () => {
1518
it('emulates offline network conditions', async () => {
1619
await withMcpContext(async (response, context) => {
@@ -234,4 +237,99 @@ describe('emulation', () => {
234237
});
235238
});
236239
});
240+
describe('device', () => {
241+
it('emulates device', async () => {
242+
await withMcpContext(async (response, context) => {
243+
await emulate.handler(
244+
{
245+
params: {
246+
device: 'iPhone 13',
247+
},
248+
},
249+
response,
250+
context,
251+
);
252+
253+
assert.strictEqual(context.getDevice(), 'iPhone 13');
254+
});
255+
});
256+
257+
it('clears device override when device is set to null', async () => {
258+
server.addHtmlRoute(
259+
'/viewport',
260+
'<!DOCTYPE html><meta name = "viewport" content = "initial-scale = 1, user-scalable = no">',
261+
);
262+
await withMcpContext(async (response, context) => {
263+
const page = context.getSelectedPage();
264+
await page.goto(server.getRoute('/viewport'));
265+
266+
// First set a device
267+
await emulate.handler(
268+
{
269+
params: {
270+
device: 'iPhone 13',
271+
},
272+
},
273+
response,
274+
context,
275+
);
276+
277+
assert.strictEqual(context.getDevice(), 'iPhone 13');
278+
const viewport = await page.evaluate(() => ({
279+
width: window.innerWidth,
280+
height: window.innerHeight,
281+
}));
282+
assert.strictEqual(viewport.width, 390);
283+
assert.strictEqual(viewport.height, 844);
284+
285+
const userAgent = await page.evaluate(() => navigator.userAgent);
286+
assert.match(userAgent, /iPhone/);
287+
288+
// Then clear it by setting device to null
289+
await emulate.handler(
290+
{
291+
params: {
292+
device: null,
293+
},
294+
},
295+
response,
296+
context,
297+
);
298+
299+
assert.strictEqual(context.getDevice(), null);
300+
301+
const resetViewport = await page.evaluate(() => ({
302+
width: window.innerWidth,
303+
height: window.innerHeight,
304+
}));
305+
306+
assert.notStrictEqual(resetViewport.width, 390);
307+
assert.notStrictEqual(resetViewport.height, 844);
308+
309+
const resetUserAgent = await page.evaluate(() => navigator.userAgent);
310+
assert.doesNotMatch(resetUserAgent, /iPhone/);
311+
});
312+
});
313+
314+
it('reports correctly for the currently selected page', async () => {
315+
await withMcpContext(async (response, context) => {
316+
await emulate.handler(
317+
{
318+
params: {
319+
device: 'iPhone 13',
320+
},
321+
},
322+
response,
323+
context,
324+
);
325+
326+
assert.strictEqual(context.getDevice(), 'iPhone 13');
327+
328+
const page = await context.newPage();
329+
context.selectPage(page);
330+
331+
assert.strictEqual(context.getDevice(), null);
332+
});
333+
});
334+
});
237335
});

0 commit comments

Comments
 (0)