mirror of
https://github.com/hanzoai/playwright.git
synced 2026-09-24 17:01:53 +00:00
feat(mcp): add --mobile emulation and plumb --device through the CLI (#41657)
This commit is contained in:
@@ -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.')
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user