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
13 changes: 8 additions & 5 deletions src/tools/ToolDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/

import type {Dialog, ElementHandle, Page} from 'puppeteer-core';
import type { Dialog, ElementHandle, Page } from 'puppeteer-core';
import type z from 'zod';

import type {TraceResult} from '../trace-processing/parse.js';
import type { TraceResult } from '../trace-processing/parse.js';

import type {ToolCategories} from './categories.js';
import type { ToolCategories } from './categories.js';

export interface ToolDefinition<Schema extends z.ZodRawShape = z.ZodRawShape> {
name: string;
Expand Down Expand Up @@ -44,7 +44,7 @@ export interface Response {
setIncludePages(value: boolean): void;
setIncludeNetworkRequests(
value: boolean,
options?: {pageSize?: number; pageIdx?: number; resourceTypes?: string[]},
options?: { pageSize?: number; pageIdx?: number; resourceTypes?: string[] },
): void;
setIncludeConsoleData(value: boolean): void;
setIncludeSnapshot(value: boolean): void;
Expand Down Expand Up @@ -73,8 +73,11 @@ export type Context = Readonly<{
saveTemporaryFile(
data: Uint8Array<ArrayBufferLike>,
mimeType: 'image/png' | 'image/jpeg',
): Promise<{filename: string}>;
): Promise<{ filename: string }>;
waitForEventsAfterAction(action: () => Promise<unknown>): Promise<void>;
// Added for multi-page device emulation support
createPagesSnapshot(): Promise<Page[]>;
getPages(): Page[];
}>;

export function defineTool<Schema extends z.ZodRawShape>(
Expand Down
155 changes: 150 additions & 5 deletions src/tools/emulation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,47 @@
* SPDX-License-Identifier: Apache-2.0
*/

import {PredefinedNetworkConditions} from 'puppeteer-core';
import { PredefinedNetworkConditions } from 'puppeteer-core';
import { KnownDevices } from 'puppeteer-core';
import z from 'zod';

import {ToolCategories} from './categories.js';
import {defineTool} from './ToolDefinition.js';
import { ToolCategories } from './categories.js';
import { defineTool } from './ToolDefinition.js';

const throttlingOptions: [string, ...string[]] = [
'No emulation',
...Object.keys(PredefinedNetworkConditions),
];

// common use device
const deviceOptions: [string, ...string[]] = [
'No emulation',
// iPhone series
'iPhone SE',
'iPhone 12',
'iPhone 12 Pro',
'iPhone 13',
'iPhone 13 Pro',
'iPhone 14',
'iPhone 14 Pro',
'iPhone 15',
'iPhone 15 Pro',
// Android series
'Galaxy S5',
'Galaxy S8',
'Galaxy S9+',
'Pixel 2',
'Pixel 3',
'Pixel 4',
'Pixel 5',
'Nexus 5',
'Nexus 6P',
// ipad
'iPad',
'iPad Pro',
'Galaxy Tab S4',
];

export const emulateNetwork = defineTool({
name: 'emulate_network',
description: `Emulates network conditions such as throttling on the selected page.`,
Expand Down Expand Up @@ -42,7 +72,7 @@ export const emulateNetwork = defineTool({
if (conditions in PredefinedNetworkConditions) {
const networkCondition =
PredefinedNetworkConditions[
conditions as keyof typeof PredefinedNetworkConditions
conditions as keyof typeof PredefinedNetworkConditions
];
await page.emulateNetworkConditions(networkCondition);
context.setNetworkConditions(conditions);
Expand All @@ -68,9 +98,124 @@ export const emulateCpu = defineTool({
},
handler: async (request, _response, context) => {
const page = context.getSelectedPage();
const {throttlingRate} = request.params;
const { throttlingRate } = request.params;

await page.emulateCPUThrottling(throttlingRate);
context.setCpuThrottlingRate(throttlingRate);
},
});

export const emulateDevice = defineTool({
name: 'emulate_device',
description: `IMPORTANT: Emulates a mobile device including viewport, user-agent, touch support, and device scale factor. This tool MUST be called BEFORE navigating to any website to ensure the correct mobile user-agent is used. Essential for testing mobile website performance and user experience.`,
annotations: {
category: ToolCategories.EMULATION,
readOnlyHint: false,
},
schema: {
device: z
.enum(deviceOptions)
.describe(
`The device to emulate. Available devices are: ${deviceOptions.join(', ')}. Set to "No emulation" to disable device emulation and use desktop mode.`,
),
customUserAgent: z
.string()
.optional()
.describe(
'Optional custom user agent string. If provided, it will override the device\'s default user agent.',
),
},
handler: async (request, response, context) => {
const { device, customUserAgent } = request.params;

// get all pages to support multi-page scene
await context.createPagesSnapshot();
const allPages = context.getPages();
const currentPage = context.getSelectedPage();

// check if multi pages and apply to all pages
let pagesToEmulate = [currentPage];
let multiPageMessage = '';

if (allPages.length > 1) {
// check if other pages have navigated content (maybe new tab page)
const navigatedPages = [];
for (const page of allPages) {
const url = page.url();
if (url !== 'about:blank' && url !== currentPage.url()) {
navigatedPages.push({ page, url });
}
}

if (navigatedPages.length > 0) {
// found other pages have navigated, apply device emulation to all pages
pagesToEmulate = [currentPage, ...navigatedPages.map(p => p.page)];
multiPageMessage = `🔄 SMART MULTI-PAGE MODE: Detected ${navigatedPages.length} additional page(s) with content. ` +
`Applying device emulation to current page and ${navigatedPages.length} other page(s): ` +
`${navigatedPages.map(p => p.url).join(', ')}. `;
}
}

// check if current page has navigated
const currentUrl = currentPage.url();
if (currentUrl !== 'about:blank') {
response.appendResponseLine(
`⚠️ WARNING: Device emulation is being applied AFTER page navigation (current URL: ${currentUrl}). ` +
`For best results, device emulation should be set BEFORE navigating to the target website.`
);
}

if (multiPageMessage) {
response.appendResponseLine(multiPageMessage);
}

if (device === 'No emulation') {
// apply desktop mode to all pages
for (const pageToEmulate of pagesToEmulate) {
await pageToEmulate.setViewport({
width: 1920,
height: 1080,
deviceScaleFactor: 1,
isMobile: false,
hasTouch: false,
isLandscape: true,
});

await pageToEmulate.setUserAgent(
customUserAgent ||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
);
}

response.appendResponseLine(
`Device emulation disabled. Desktop mode applied to ${pagesToEmulate.length} page(s).`
);
return;
}

// check if current device is in KnownDevices
if (device in KnownDevices) {
const deviceConfig = KnownDevices[device as keyof typeof KnownDevices];

// apply device config to all page
for (const pageToEmulate of pagesToEmulate) {
await pageToEmulate.emulate({
userAgent: customUserAgent || deviceConfig.userAgent,
viewport: deviceConfig.viewport,
});
}

response.appendResponseLine(
`Successfully emulated device: ${device} on ${pagesToEmulate.length} page(s). ` +
`Viewport: ${deviceConfig.viewport.width}x${deviceConfig.viewport.height}, ` +
`Scale: ${deviceConfig.viewport.deviceScaleFactor}x, ` +
`Mobile: ${deviceConfig.viewport.isMobile ? 'Yes' : 'No'}, ` +
`Touch: ${deviceConfig.viewport.hasTouch ? 'Yes' : 'No'}${customUserAgent ? ', Custom UA applied' : ''}.`
);
} else {
response.appendResponseLine(
`Device "${device}" not found in known devices. Available devices: ${deviceOptions.filter(d => d !== 'No emulation').join(', ')}`
);
}
},
});