Skip to content
Closed
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,7 @@ The Chrome DevTools MCP server supports the following configuration option:
- **Type:** string

- **`--viewport`**
Initial viewport size for the Chrome instances started by the server. For example, `1280x720`. In headless mode, max size is 3840x2160px.
Initial viewport size for the Chrome instances started by the server. For example, `1280x720` or `1280x720x2` to set the device scale factor. In headless mode, max size is 3840x2160px.
- **Type:** string

- **`--proxyServer`/ `--proxy-server`**
Expand Down
25 changes: 18 additions & 7 deletions src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ interface McpContextOptions {
experimentalIncludeAllPages?: boolean;
// Whether CrUX data should be fetched.
performanceCrux: boolean;
// Viewport emulation applied before the context was created.
initialViewport?: Viewport;
}

const DEFAULT_TIMEOUT = 5_000;
Expand All @@ -85,6 +87,16 @@ function getNetworkMultiplierFromString(condition: string | null): number {
return 1;
}

function normalizeViewportSettings(viewport: Viewport): Viewport {
return {
deviceScaleFactor: 1,
Comment thread
Lightning00Blade marked this conversation as resolved.
isMobile: false,
hasTouch: false,
isLandscape: false,
...viewport,
};
}

export class McpContext implements Context {
browser: Browser;
logger: Debugger;
Expand Down Expand Up @@ -151,6 +163,11 @@ export class McpContext implements Context {

async #init() {
const pages = await this.createPagesSnapshot();
if (this.#selectedPage && this.#options.initialViewport) {
this.#selectedPage.emulationSettings.viewport = normalizeViewportSettings(
this.#options.initialViewport,
);
}
await this.createExtensionServiceWorkersSnapshot();
await this.#networkCollector.init(pages);
await this.#consoleCollector.init(pages);
Expand Down Expand Up @@ -380,13 +397,7 @@ export class McpContext implements Context {
await page.setViewport(null);
delete newSettings.viewport;
} else {
const defaults = {
deviceScaleFactor: 1,
isMobile: false,
hasTouch: false,
isLandscape: false,
};
const viewport = {...defaults, ...options.viewport};
const viewport = normalizeViewportSettings(options.viewport);
await page.setViewport(viewport);
newSettings.viewport = viewport;
}
Expand Down
53 changes: 39 additions & 14 deletions src/bin/chrome-devtools-mcp-cli-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,39 @@
import type {YargsOptions} from '../third_party/index.js';
import {yargs, hideBin} from '../third_party/index.js';

function parseViewportOption(arg: string | undefined) {
if (arg === undefined) {
return;
}

const dimensions = arg.split('x');
if (dimensions.length < 2 || dimensions.length > 3) {
throw new Error(
'Invalid viewport. Expected format is `1280x720` or `1280x720x2`.',
);
}

const [width, height, deviceScaleFactor] = dimensions.map(Number);
if (
!width ||
!height ||
Number.isNaN(width) ||
Number.isNaN(height) ||
(deviceScaleFactor !== undefined &&
(deviceScaleFactor <= 0 || Number.isNaN(deviceScaleFactor)))
) {
throw new Error(
'Invalid viewport. Expected format is `1280x720` or `1280x720x2`.',
);
}

return {
width,
height,
...(deviceScaleFactor === undefined ? {} : {deviceScaleFactor}),
};
}

export const cliOptions = {
autoConnect: {
type: 'boolean',
Expand Down Expand Up @@ -124,20 +157,8 @@ export const cliOptions = {
viewport: {
type: 'string',
describe:
'Initial viewport size for the Chrome instances started by the server. For example, `1280x720`. In headless mode, max size is 3840x2160px.',
coerce: (arg: string | undefined) => {
if (arg === undefined) {
return;
}
const [width, height] = arg.split('x').map(Number);
if (!width || !height || Number.isNaN(width) || Number.isNaN(height)) {
throw new Error('Invalid viewport. Expected format is `1280x720`.');
}
return {
width,
height,
};
},
'Initial viewport size for the Chrome instances started by the server. For example, `1280x720` or `1280x720x2` to set the device scale factor. In headless mode, max size is 3840x2160px.',
coerce: parseViewportOption,
},
proxyServer: {
type: 'string',
Expand Down Expand Up @@ -297,6 +318,10 @@ export function parseArguments(version: string, argv = process.argv) {
'$0 --viewport 1280x720',
'Launch Chrome with the initial viewport size of 1280x720px',
],
[
'$0 --viewport 1280x720x2',
'Launch Chrome with the initial viewport size of 1280x720px and device scale factor 2',
],
[
`$0 --chrome-arg='--no-sandbox' --chrome-arg='--disable-setuid-sandbox'`,
'Launch Chrome without sandboxes. Use with caution.',
Expand Down
9 changes: 9 additions & 0 deletions src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ interface McpLaunchOptions {
viewport?: {
width: number;
height: number;
deviceScaleFactor?: number;
};
chromeArgs?: string[];
ignoreDefaultChromeArgs?: string[];
Expand Down Expand Up @@ -242,6 +243,14 @@ export async function launch(options: McpLaunchOptions): Promise<Browser> {
contentWidth: options.viewport.width,
contentHeight: options.viewport.height,
});
if (options.viewport.deviceScaleFactor !== undefined) {
// page.resize() only affects the content size. Apply DPR separately.
await page?.setViewport({
width: options.viewport.width,
height: options.viewport.height,
deviceScaleFactor: options.viewport.deviceScaleFactor,
});
}
}
return browser;
} catch (error) {
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ export async function createMcpServer(
experimentalDevToolsDebugging: devtools,
experimentalIncludeAllPages: serverArgs.experimentalIncludeAllPages,
performanceCrux: serverArgs.performanceCrux,
initialViewport:
serverArgs.viewport?.deviceScaleFactor === undefined
? undefined
: serverArgs.viewport,
});
}
return context;
Expand Down
35 changes: 35 additions & 0 deletions tests/browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,41 @@ describe('browser', () => {
await browser.close();
}
});

it('launches with the initial viewport device scale factor', async () => {
const tmpDir = os.tmpdir();
const folderPath = path.join(tmpDir, `temp-folder-${crypto.randomUUID()}`);
const browser = await launch({
headless: true,
isolated: false,
userDataDir: folderPath,
executablePath: executablePath(),
viewport: {
width: 1501,
height: 801,
deviceScaleFactor: 2,
},
devtools: false,
});
try {
const [page] = await browser.pages();
const result = await page.evaluate(() => {
return {
width: window.innerWidth,
height: window.innerHeight,
devicePixelRatio: window.devicePixelRatio,
};
});
assert.deepStrictEqual(result, {
width: 1501,
height: 801,
devicePixelRatio: 2,
});
} finally {
await browser.close();
}
});

it('connects to an existing browser with userDataDir', async () => {
const tmpDir = os.tmpdir();
const folderPath = path.join(tmpDir, `temp-folder-${crypto.randomUUID()}`);
Expand Down
21 changes: 21 additions & 0 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,27 @@ describe('cli args parsing', () => {
});
});

it('parses viewport with device scale factor', async () => {
const args = parseArguments('1.0.0', [
'node',
'main.js',
'--viewport',
'888x777x2',
]);
assert.deepStrictEqual(args, {
...defaultArgs,
_: [],
headless: false,
$0: 'npx chrome-devtools-mcp@latest',
channel: 'stable',
viewport: {
width: 888,
height: 777,
deviceScaleFactor: 2,
},
});
});

it('parses chrome args', async () => {
const args = parseArguments('1.0.0', [
'node',
Expand Down
64 changes: 64 additions & 0 deletions tests/tools/lighthouse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import os from 'node:os';
import path from 'node:path';
import {describe, it} from 'node:test';

import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js';
import {lighthouseAudit} from '../../src/tools/lighthouse.js';
import {serverHooks} from '../server.js';
import {html, withMcpContext} from '../utils.js';
Expand Down Expand Up @@ -117,6 +118,69 @@ describe('lighthouse', () => {
});
});

it('restores launch-time viewport device scale factor', async () => {
server.addHtmlRoute('/test-launch-viewport', html`<div>Test DPR</div>`);

await withMcpContext(
async (response, context) => {
const page = context.getSelectedPptrPage();
await page.goto(server.getRoute('/test-launch-viewport'));

{
const viewportData = await page.evaluate(() => {
return {
width: window.innerWidth,
height: window.innerHeight,
deviceScaleFactor: window.devicePixelRatio,
};
});

assert.deepStrictEqual(viewportData, {
width: 400,
height: 400,
deviceScaleFactor: 2,
});
}

await lighthouseAudit.handler(
{
params: {
mode: 'snapshot',
device: 'desktop',
},
page: context.getSelectedMcpPage(),
},
response,
context,
);

{
const viewportData = await page.evaluate(() => {
return {
width: window.innerWidth,
height: window.innerHeight,
deviceScaleFactor: window.devicePixelRatio,
};
});

assert.deepStrictEqual(viewportData, {
width: 400,
height: 400,
deviceScaleFactor: 2,
});
}
},
{},
{
viewport: {
width: 400,
height: 400,
deviceScaleFactor: 2,
},
} as ParsedArguments,
);
});

it('runs Lighthouse in snapshot mode with mobile device', async () => {
server.addHtmlRoute('/test-mobile', html`<div>Test Mobile</div>`);

Expand Down
14 changes: 13 additions & 1 deletion tests/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,15 @@ export async function withMcpContext(
} = {},
args: ParsedArguments = {} as ParsedArguments,
) {
await withBrowser(async browser => {
await withBrowser(async (browser, page) => {
if (args.viewport?.deviceScaleFactor !== undefined) {
await page.setViewport({
width: args.viewport.width,
height: args.viewport.height,
deviceScaleFactor: args.viewport.deviceScaleFactor,
});
}

const response = new McpResponse(args);
if (context) {
context.dispose();
Expand All @@ -116,6 +124,10 @@ export async function withMcpContext(
{
experimentalDevToolsDebugging: false,
performanceCrux: options.performanceCrux ?? true,
initialViewport:
args.viewport?.deviceScaleFactor === undefined
? undefined
: args.viewport,
},
Locator,
);
Expand Down