Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/tool-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@

**Parameters:**

- **device** (unknown) **(required)**: Mobile device to [`emulate`](#emulate). Set to null to disable device emulation.
- **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.
Expand Down
15 changes: 15 additions & 0 deletions src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export class McpContext implements Context {
#networkConditionsMap = new WeakMap<Page, string>();
#cpuThrottlingRateMap = new WeakMap<Page, number>();
#geolocationMap = new WeakMap<Page, GeolocationOptions>();
#deviceMap = new WeakMap<Page, string>();
#dialog?: Dialog;

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

setDevice(device: string | null): void {
const page = this.getSelectedPage();
if (device === null) {
this.#deviceMap.delete(page);
} else {
this.#deviceMap.set(page, device);
}
}

getDevice(): string | null {
const page = this.getSelectedPage();
return this.#deviceMap.get(page) ?? null;
}

setIsRunningPerformanceTrace(x: boolean): void {
this.#isRunningTrace = x;
}
Expand Down
6 changes: 6 additions & 0 deletions src/McpResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,12 @@ export class McpResponse implements Response {
);
}

const device = context.getDevice();
if (device) {
response.push(`## Device emulation`);
response.push(`Emulating: ${device}`);
}

const cpuThrottlingRate = context.getCpuThrottlingRate();
if (cpuThrottlingRate > 1) {
response.push(`## CPU emulation`);
Expand Down
1 change: 1 addition & 0 deletions src/third_party/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export {z as zod} from 'zod';
export {
Locator,
PredefinedNetworkConditions,
KnownDevices,
CDPSessionEvent,
} from 'puppeteer-core';
export {default as puppeteer} from 'puppeteer-core';
Expand Down
2 changes: 2 additions & 0 deletions src/tools/ToolDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ export type Context = Readonly<{
setNetworkConditions(conditions: string | null): void;
setCpuThrottlingRate(rate: number): void;
setGeolocation(geolocation: GeolocationOptions | null): void;
setDevice(device: string | null): void;
getDevice(): string | null;
saveTemporaryFile(
data: Uint8Array<ArrayBufferLike>,
mimeType: 'image/png' | 'image/jpeg' | 'image/webp',
Expand Down
33 changes: 31 additions & 2 deletions src/tools/emulation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/

import {zod, PredefinedNetworkConditions} from '../third_party/index.js';
import {
zod,
PredefinedNetworkConditions,
KnownDevices,
} from '../third_party/index.js';

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

const deviceOptions: [string, ...string[]] = [
...(Object.keys(KnownDevices) as [string, ...string[]]),
];

export const emulate = defineTool({
name: 'emulate',
description: `Emulates various features on the selected page.`,
Expand Down Expand Up @@ -55,10 +63,18 @@ export const emulate = defineTool({
.describe(
'Geolocation to emulate. Set to null to clear the geolocation override.',
),
device: zod
.enum(deviceOptions)
.optional()
.nullable()
.describe(
'Mobile device to emulate. Set to null to disable device emulation.',
),
},
handler: async (request, _response, context) => {
const page = context.getSelectedPage();
const {networkConditions, cpuThrottlingRate, geolocation} = request.params;
const {networkConditions, cpuThrottlingRate, geolocation, device} =
request.params;

if (networkConditions) {
if (networkConditions === 'No emulation') {
Expand Down Expand Up @@ -96,5 +112,18 @@ export const emulate = defineTool({
context.setGeolocation(geolocation);
}
}

if (device !== undefined) {
if (device === null) {
await page.setViewport(null);
await page.setUserAgent('');
context.setDevice(null);
} else if (device in KnownDevices) {
const deviceDefinition =
KnownDevices[device as keyof typeof KnownDevices];
await page.emulate(deviceDefinition);
context.setDevice(device);
}
}
},
});
6 changes: 6 additions & 0 deletions tests/McpResponse.test.js.snapshot
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ exports[`McpResponse > adds cpu throttling setting when it is over 1 1`] = `
Emulating: 4x slowdown
`;

exports[`McpResponse > adds device emulation setting when it is set 1`] = `
# test response
## Device emulation
Emulating: iPhone 13
`;

exports[`McpResponse > adds throttling setting when it is not null 1`] = `
# test response
## Network emulation
Expand Down
8 changes: 8 additions & 0 deletions tests/McpResponse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,14 @@ describe('McpResponse', () => {
});
});

it('adds device emulation setting when it is set', async t => {
await withMcpContext(async (response, context) => {
context.setDevice('iPhone 13');
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();
Expand Down
98 changes: 98 additions & 0 deletions tests/tools/emulation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -234,4 +237,99 @@ describe('emulation', () => {
});
});
});
describe('device', () => {
it('emulates device', async () => {
await withMcpContext(async (response, context) => {
await emulate.handler(
{
params: {
device: 'iPhone 13',
},
},
response,
context,
);

assert.strictEqual(context.getDevice(), 'iPhone 13');
});
});

it('clears device override when device is set to null', async () => {
server.addHtmlRoute(
'/viewport',
'<!DOCTYPE html><meta name = "viewport" content = "initial-scale = 1, user-scalable = no">',
);
await withMcpContext(async (response, context) => {
const page = context.getSelectedPage();
await page.goto(server.getRoute('/viewport'));

// First set a device
await emulate.handler(
{
params: {
device: 'iPhone 13',
},
},
response,
context,
);

assert.strictEqual(context.getDevice(), 'iPhone 13');
const viewport = await page.evaluate(() => ({
width: window.innerWidth,
height: window.innerHeight,
}));
assert.strictEqual(viewport.width, 390);
assert.strictEqual(viewport.height, 844);

const userAgent = await page.evaluate(() => navigator.userAgent);
assert.match(userAgent, /iPhone/);

// Then clear it by setting device to null
await emulate.handler(
{
params: {
device: null,
},
},
response,
context,
);

assert.strictEqual(context.getDevice(), null);

const resetViewport = await page.evaluate(() => ({
width: window.innerWidth,
height: window.innerHeight,
}));

assert.notStrictEqual(resetViewport.width, 390);
assert.notStrictEqual(resetViewport.height, 844);

const resetUserAgent = await page.evaluate(() => navigator.userAgent);
assert.doesNotMatch(resetUserAgent, /iPhone/);
});
});

it('reports correctly for the currently selected page', async () => {
await withMcpContext(async (response, context) => {
await emulate.handler(
{
params: {
device: 'iPhone 13',
},
},
response,
context,
);

assert.strictEqual(context.getDevice(), 'iPhone 13');

const page = await context.newPage();
context.selectPage(page);

assert.strictEqual(context.getDevice(), null);
});
});
});
});