feat(mcp): add --mobile emulation and plumb --device through the CLI (#41657)

This commit is contained in:
Pavel Feldman
2026-07-06 17:55:15 -07:00
committed by GitHub
parent 2fb635e20f
commit 5de1365327
10 changed files with 134 additions and 11 deletions
@@ -52,7 +52,9 @@ type AttachOptions = {
type OpenOptions = {
browser?: string;
config?: string;
device?: string;
headed?: boolean;
mobile?: boolean;
persistent?: boolean;
profile?: string;
};
@@ -125,6 +125,10 @@ export class Session {
];
if (cliArgs.headed)
args.push('--headed');
if (cliArgs.mobile)
args.push('--mobile');
if (cliArgs.device)
args.push(`--device=${cliArgs.device}`);
if (cliArgs.browser)
args.push(`--browser=${cliArgs.browser}`);
if (cliArgs.persistent)
@@ -215,6 +215,12 @@ playwright-cli open --browser=firefox
playwright-cli open --browser=webkit
playwright-cli open --browser=msedge
# Emulate a generic mobile device (Pixel 10 for Chromium, iPhone 17 for WebKit).
# Prefer this when a mobile layout is acceptable: mobile pages are usually
# lighter, so snapshots are smaller and cheaper.
playwright-cli open --mobile
playwright-cli open --device="iPhone 15"
# Use persistent profile (by default profile is in-memory)
playwright-cli open --persistent
# Use persistent profile with custom directory
@@ -48,7 +48,9 @@ const open = declareCommand({
options: z.object({
browser: z.string().optional().describe('Browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.'),
config: z.string().optional().describe('Path to the configuration file, defaults to .playwright/cli.config.json'),
device: z.string().optional().describe('Emulate a specific device, for example "iPhone 15".'),
headed: z.boolean().optional().describe('Run browser in headed mode'),
mobile: z.boolean().optional().describe('Emulate a generic mobile device (Pixel 10 for Chromium, iPhone 17 for WebKit). Mobile pages are usually lighter, which saves tokens.'),
persistent: z.boolean().optional().describe('Use persistent browser profile'),
profile: z.string().optional().describe('Path to a persistent user data directory.'),
}),
@@ -34,6 +34,8 @@ import type { Command } from 'commander';
export function decorateProgram(program: Command) {
program.argument('[session-name]', 'name of the session to create or connect to', 'default')
.option('--headed', 'run in headed mode (non-headless)')
.option('--device <device>', 'emulate a specific device, for example "iPhone 15"')
.option('--mobile', 'emulate a generic mobile device (Pixel 10 for Chromium, iPhone 17 for WebKit)')
.option('--extension', 'run with the extension')
.option('--browser <name>', 'browser to use (chromium, chrome, firefox, webkit)')
.option('--persistent', 'use a persistent browser context')
@@ -58,6 +58,7 @@ export type CLIOptions = {
initPage?: string[];
isolated?: boolean;
imageResponses?: 'allow' | 'omit';
mobile?: boolean;
sandbox?: boolean;
outputDir?: string;
outputMaxSize?: number;
@@ -155,7 +156,9 @@ export async function resolveCLIConfigForCLI(daemonProfilesDir: string, sessionN
cdpEndpoint: options.cdp,
config: options.config,
browser: options.browser,
device: options.device,
headless: options.headed ? false : undefined,
mobile: options.mobile,
extension: options.extension,
userDataDir: options.profile,
snapshotMode: 'full',
@@ -291,11 +294,20 @@ function configFromCLIOptions(cliOptions: CLIOptions): Config & { configFile?: s
if (cliOptions.sandbox !== undefined)
launchOptions.chromiumSandbox = cliOptions.sandbox;
if (cliOptions.device && cliOptions.cdpEndpoint)
let device = cliOptions.device;
if (cliOptions.mobile) {
if (device)
throw new Error('Cannot use --mobile together with --device, pick one.');
if (browserName === 'firefox')
throw new Error('--mobile is not supported with the Firefox browser.');
device = browserName === 'webkit' ? 'iPhone 17' : 'Pixel 10';
}
if (device && cliOptions.cdpEndpoint)
throw new Error('Device emulation is not supported with cdpEndpoint.');
// Context options
const contextOptions: playwrightTypes.BrowserContextOptions = cliOptions.device ? playwright.devices[cliOptions.device] : {};
const contextOptions: playwrightTypes.BrowserContextOptions = device ? playwright.devices[device] : {};
if (cliOptions.proxyServer) {
const proxy: playwrightTypes.LaunchOptions['proxy'] = { server: cliOptions.proxyServer };
@@ -409,6 +421,7 @@ export function configFromEnv(env?: NodeJS.ProcessEnv): Config & { configFile?:
options.isolated = envToBoolean(e.PLAYWRIGHT_MCP_ISOLATED);
if (e.PLAYWRIGHT_MCP_IMAGE_RESPONSES)
options.imageResponses = enumParser<'allow' | 'omit'>('--image-responses', ['allow', 'omit'], e.PLAYWRIGHT_MCP_IMAGE_RESPONSES);
options.mobile = envToBoolean(e.PLAYWRIGHT_MCP_MOBILE);
options.sandbox = envToBoolean(e.PLAYWRIGHT_MCP_SANDBOX);
options.outputDir = envToString(e.PLAYWRIGHT_MCP_OUTPUT_DIR);
options.outputMaxSize = numberParser(e.PLAYWRIGHT_MCP_OUTPUT_MAX_SIZE);
@@ -46,6 +46,7 @@ export function decorateMCPCommand(command: Command) {
.option('--config <path>', 'path to the configuration file.')
.option('--console-level <level>', 'level of console messages to return: "error", "warning", "info", "debug". Each level includes the messages of more severe levels.', enumParser.bind(null, '--console-level', ['error', 'warning', 'info', 'debug']))
.option('--device <device>', 'device to emulate, for example: "iPhone 15"')
.option('--mobile', 'emulate a generic mobile device (Pixel 10 for Chromium, iPhone 17 for WebKit). Mobile pages are usually lighter, which saves tokens. Cannot be combined with --device.')
.option('--executable-path <path>', 'path to the browser executable.')
.option('--extension', 'Connect to a running browser instance (Edge/Chrome only). Requires the "Playwright Extension" to be installed.')
.option('--endpoint <endpoint>', 'Bound browser endpoint to connect to.')
+24
View File
@@ -43,6 +43,30 @@ test('context options', async ({ cli, server }, testInfo) => {
expect(output).toContain('800x600');
});
test('--mobile emulates a mobile viewport', async ({ cli, server, mcpBrowser }) => {
test.skip(mcpBrowser !== 'chrome', 'Default --mobile device (Pixel 10) is Chromium; family mapping is covered by config-resolve tests.');
server.setContent('/', `
<head><meta name="viewport" content="width=device-width, initial-scale=1"></head>
<body></body>
`, 'text/html');
await cli('open', '--mobile', server.PREFIX);
const { output } = await cli('eval', 'window.innerWidth');
// Pixel 10 device width.
expect(output).toContain('360');
});
test('--device emulates the given device', async ({ cli, server, mcpBrowser }) => {
test.skip(mcpBrowser !== 'chrome', 'Device viewport is engine-independent; asserting one browser is enough.');
server.setContent('/', `
<head><meta name="viewport" content="width=device-width, initial-scale=1"></head>
<body></body>
`, 'text/html');
await cli('open', '--device=iPhone 15', server.PREFIX);
const { output } = await cli('eval', 'window.innerWidth');
// iPhone 15 device width.
expect(output).toContain('393');
});
test('config-print prints merged config', async ({ cli }) => {
await cli('open');
const { output } = await cli('config-print');
+42
View File
@@ -189,6 +189,48 @@ test.describe('viewport', () => {
});
});
test.describe('mobile', () => {
test('--mobile defaults to a Chromium mobile device', async () => {
const config = await resolveCLIConfigForMCP({ mobile: true }, emptyEnv);
expect(config.browser.contextOptions.isMobile).toBe(true);
expect(config.browser.contextOptions.userAgent).toContain('Pixel 10');
});
test('--mobile with a Chromium channel picks a Chromium mobile device', async () => {
const config = await resolveCLIConfigForMCP({ mobile: true, browser: 'msedge' }, emptyEnv);
expect(config.browser.contextOptions.isMobile).toBe(true);
expect(config.browser.contextOptions.userAgent).toContain('Pixel 10');
});
test('--mobile with WebKit picks a WebKit mobile device', async () => {
const config = await resolveCLIConfigForMCP({ mobile: true, browser: 'webkit' }, emptyEnv);
expect(config.browser.contextOptions.isMobile).toBe(true);
expect(config.browser.contextOptions.userAgent).toContain('iPhone');
});
test('explicit viewport still wins over --mobile', async () => {
const config = await resolveCLIConfigForMCP({ mobile: true, viewportSize: { width: 800, height: 600 } }, emptyEnv);
expect(config.browser.contextOptions.isMobile).toBe(true);
expect(config.browser.contextOptions.viewport).toEqual({ width: 800, height: 600 });
});
test('--mobile via env var', async () => {
const config = await resolveCLIConfigForMCP({}, { PLAYWRIGHT_MCP_MOBILE: '1' });
expect(config.browser.contextOptions.isMobile).toBe(true);
expect(config.browser.contextOptions.userAgent).toContain('Pixel 10');
});
test('--mobile is rejected with Firefox', async () => {
await expect(resolveCLIConfigForMCP({ mobile: true, browser: 'firefox' }, emptyEnv))
.rejects.toThrow('--mobile is not supported with the Firefox browser.');
});
test('--mobile cannot be combined with --device', async () => {
await expect(resolveCLIConfigForMCP({ mobile: true, device: 'iPhone 15' }, emptyEnv))
.rejects.toThrow('Cannot use --mobile together with --device');
});
});
// ---------------------------------------------------------------------------
// Shared behavior — merge order and config file
// ---------------------------------------------------------------------------
+36 -9
View File
@@ -16,6 +16,16 @@
import { test, expect } from './fixtures';
const viewportProbe = `
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body></body>
<script>
document.body.textContent = window.innerWidth + "x" + window.innerHeight;
</script>
`;
test('--device should work', async ({ startClient, server }) => {
const { client } = await startClient({
args: ['--device', 'iPhone 15'],
@@ -23,15 +33,7 @@ test('--device should work', async ({ startClient, server }) => {
server.setRoute('/', (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body></body>
<script>
document.body.textContent = window.innerWidth + "x" + window.innerHeight;
</script>
`);
res.end(viewportProbe);
});
expect(await client.callTool({
@@ -43,3 +45,28 @@ test('--device should work', async ({ startClient, server }) => {
snapshot: expect.stringContaining(`393x659`),
});
});
test('--mobile emulates a mobile viewport', async ({ startClient, server, mcpBrowser }) => {
test.skip(mcpBrowser === 'firefox', '--mobile is not supported with Firefox.');
const { client } = await startClient({
args: ['--mobile'],
});
server.setRoute('/', (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(viewportProbe);
});
// Pixel 10 for Chromium, iPhone 17 for WebKit — both are narrow mobile
// viewports, so assert the width rather than an exact model dimension.
const width = mcpBrowser === 'webkit' ? '402x' : '360x';
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
})).toHaveResponse({
snapshot: expect.stringContaining(width),
});
});