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
45 changes: 44 additions & 1 deletion packages/playwright-core/src/tools/backend/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
*/

import * as z from 'zod';
import { formatObject } from '@isomorphic/stringUtils';

import { defineTabTool, defineTool } from './tool';
import { renderTabsMarkdown } from './response';

Expand Down Expand Up @@ -56,7 +58,48 @@ const resize = defineTabTool({
},
});

const emulateMedia = defineTabTool({
capability: 'core',
schema: {
name: 'browser_emulate_media',
title: 'Emulate media features',
description: 'Emulate CSS media features for the page, for example switch between the light and dark color scheme. Omitted parameters are left unchanged.',
inputSchema: z.object({
colorScheme: z.enum(['light', 'dark']).optional().describe('Emulates the prefers-color-scheme media feature'),
reducedMotion: z.enum(['reduce', 'no-preference']).optional().describe('Emulates the prefers-reduced-motion media feature'),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's use kebob case to be consistent with other options

forcedColors: z.enum(['active', 'none']).optional().describe('Emulates the forced-colors media feature'),
contrast: z.enum(['more', 'no-preference']).optional().describe('Emulates the prefers-contrast media feature'),
media: z.enum(['screen', 'print']).optional().describe('Changes the CSS media type of the page'),
}),
type: 'action',
},

handle: async (tab, params, response) => {
const requested = {
colorScheme: params.colorScheme,
contrast: params.contrast,
forcedColors: params.forcedColors,
media: params.media,
reducedMotion: params.reducedMotion,
};
// Only pass through the features that were specified, so that the omitted
// ones keep their current emulation state.
const options = Object.fromEntries(
Object.entries(requested).filter(([, value]) => value !== undefined)
) as typeof requested;

if (!Object.keys(options).length) {
response.addError('Error: Specify at least one media feature to emulate.');
return;
}

response.addCode(`await page.emulateMedia(${formatObject(options, ' ', 'oneline')});`);
await tab.page.emulateMedia(options);
},
});

export default [
close,
resize
resize,
emulateMedia
];
16 changes: 16 additions & 0 deletions packages/playwright-core/src/tools/cli-daemon/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,21 @@ const resize = declareCommand({
toolParams: ({ w: width, h: height }) => ({ width, height }),
});

const emulateMedia = declareCommand({
name: 'emulate-media',
description: 'Emulate CSS media features, for example the light or dark color scheme',
category: 'core',
options: z.object({
colorScheme: z.enum(['light', 'dark']).optional().describe('Emulates the prefers-color-scheme media feature'),
reducedMotion: z.enum(['reduce', 'no-preference']).optional().describe('Emulates the prefers-reduced-motion media feature'),
forcedColors: z.enum(['active', 'none']).optional().describe('Emulates the forced-colors media feature'),
contrast: z.enum(['more', 'no-preference']).optional().describe('Emulates the prefers-contrast media feature'),
media: z.enum(['screen', 'print']).optional().describe('Changes the CSS media type of the page'),
}),
toolName: 'browser_emulate_media',
toolParams: ({ colorScheme, reducedMotion, forcedColors, contrast, media }) => ({ colorScheme, reducedMotion, forcedColors, contrast, media }),
});

const runCode = declareCommand({
name: 'run-code',
description: 'Run Playwright code snippet',
Expand Down Expand Up @@ -1173,6 +1188,7 @@ const commandsArray: AnyCommandSchema[] = [
dialogAccept,
dialogDismiss,
resize,
emulateMedia,
runCode,
deleteData,

Expand Down
6 changes: 6 additions & 0 deletions packages/playwright-core/src/tools/mcp/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export type CLIOptions = {
cdpHeader?: Record<string, string>;
cdpTimeout?: number;
codegen?: 'typescript' | 'python' | 'java' | 'csharp' | 'none';
colorScheme?: 'light' | 'dark';
config?: string;
consoleLevel?: 'error' | 'warning' | 'info' | 'debug';
device?: string;
Expand Down Expand Up @@ -337,6 +338,9 @@ function configFromCLIOptions(cliOptions: CLIOptions): Config & { configFile?: s
if (cliOptions.viewportSize)
contextOptions.viewport = cliOptions.viewportSize;

if (cliOptions.colorScheme)
contextOptions.colorScheme = cliOptions.colorScheme;

if (cliOptions.ignoreHttpsErrors)
contextOptions.ignoreHTTPSErrors = true;

Expand Down Expand Up @@ -414,6 +418,8 @@ export function configFromEnv(env?: NodeJS.ProcessEnv): Config & { configFile?:
options.cdpTimeout = numberParser(e.PLAYWRIGHT_MCP_CDP_TIMEOUT);
if (e.PLAYWRIGHT_MCP_CODEGEN)
options.codegen = enumParser<'typescript' | 'python' | 'java' | 'csharp' | 'none'>('--codegen', ['none', 'typescript', 'python', 'java', 'csharp'], e.PLAYWRIGHT_MCP_CODEGEN);
if (e.PLAYWRIGHT_MCP_COLOR_SCHEME)
options.colorScheme = enumParser<'light' | 'dark'>('--color-scheme', ['light', 'dark'], e.PLAYWRIGHT_MCP_COLOR_SCHEME);
options.config = envToString(e.PLAYWRIGHT_MCP_CONFIG);
if (e.PLAYWRIGHT_MCP_CONSOLE_LEVEL)
options.consoleLevel = enumParser<'error' | 'warning' | 'info' | 'debug'>('--console-level', ['error', 'warning', 'info', 'debug'], e.PLAYWRIGHT_MCP_CONSOLE_LEVEL);
Expand Down
1 change: 1 addition & 0 deletions packages/playwright-core/src/tools/mcp/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export function decorateMCPCommand(command: Command) {
.option('--cdp-header <headers...>', 'CDP headers to send with the connect request, multiple can be specified.', headerParser)
.option('--cdp-timeout <timeout>', 'timeout in milliseconds for connecting to CDP endpoint, defaults to 30000ms', numberParser)
.option('--codegen <lang>', `specify the language to use for code generation, possible values: "typescript", "python", "java", "csharp", "none". Default is "${defaultCodegenLanguage}".`, enumParser.bind(null, '--codegen', ['none', 'typescript', 'python', 'java', 'csharp']))
.option('--color-scheme <scheme>', 'emulate the preferred color scheme, possible values: "light", "dark".', enumParser.bind(null, '--color-scheme', ['light', 'dark']))
.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"')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ playwright-cli dialog-accept
playwright-cli dialog-accept "confirmation text"
playwright-cli dialog-dismiss
playwright-cli resize 1920 1080
# emulate CSS media features, for example to check a UI in dark mode
playwright-cli emulate-media --colorScheme=dark
playwright-cli emulate-media --colorScheme=light --reducedMotion=reduce
playwright-cli close
```

Expand Down
1 change: 1 addition & 0 deletions tests/mcp/capabilities.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ test('test snapshot tool list', async ({ client }) => {
'browser_select_option',
'browser_type',
'browser_close',
'browser_emulate_media',
'browser_navigate_back',
'browser_navigate',
'browser_network_request',
Expand Down
32 changes: 32 additions & 0 deletions tests/mcp/config-resolve.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,38 @@ test.describe('mobile', () => {
});
});

test.describe('color scheme', () => {
test('unset by default', async () => {
const config = await resolveCLIConfigForMCP({}, emptyEnv);
expect(config.browser.contextOptions.colorScheme).toBeUndefined();
});

test('--color-scheme sets the context option', async () => {
const config = await resolveCLIConfigForMCP({ colorScheme: 'dark' }, emptyEnv);
expect(config.browser.contextOptions.colorScheme).toBe('dark');
});

test('--color-scheme via env var', async () => {
const config = await resolveCLIConfigForMCP({}, { PLAYWRIGHT_MCP_COLOR_SCHEME: 'dark' });
expect(config.browser.contextOptions.colorScheme).toBe('dark');
});

test('--color-scheme rejects an unknown value', async () => {
await expect(resolveCLIConfigForMCP({}, { PLAYWRIGHT_MCP_COLOR_SCHEME: 'sepia' }))
.rejects.toThrow('--color-scheme');
});

test('cli overrides config file', async ({}, testInfo) => {
const configFile = testInfo.outputPath('config.json');
const fileConfig: Config = {
browser: { contextOptions: { colorScheme: 'light' } },
};
await fs.promises.writeFile(configFile, JSON.stringify(fileConfig));
const config = await resolveCLIConfigForMCP({ config: configFile, colorScheme: 'dark' }, emptyEnv);
expect(config.browser.contextOptions.colorScheme).toBe('dark');
});
});

// ---------------------------------------------------------------------------
// Shared behavior — merge order and config file
// ---------------------------------------------------------------------------
Expand Down
68 changes: 68 additions & 0 deletions tests/mcp/core.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,74 @@ test('browser_resize', async ({ client, server }) => {
});
});

test('browser_emulate_media', async ({ client, server }) => {
server.setContent('/', `
<title>Media Test</title>
<body>
<div id="scheme"></div>
<script>
const query = matchMedia('(prefers-color-scheme: dark)');
const update = () => document.getElementById('scheme').textContent = \`Color scheme: \${query.matches ? 'dark' : 'light'}\`;
query.addEventListener('change', update);
update();
</script>
</body>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});

expect(await client.callTool({
name: 'browser_emulate_media',
arguments: { colorScheme: 'dark' },
})).toHaveResponse({
code: `await page.emulateMedia({ colorScheme: 'dark' });`,
});
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toHaveResponse({
inlineSnapshot: expect.stringContaining(`Color scheme: dark`),
});

expect(await client.callTool({
name: 'browser_emulate_media',
arguments: { colorScheme: 'light' },
})).toHaveResponse({
code: `await page.emulateMedia({ colorScheme: 'light' });`,
});
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toHaveResponse({
inlineSnapshot: expect.stringContaining(`Color scheme: light`),
});
});

test('browser_emulate_media multiple features', async ({ client, server }) => {
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});

expect(await client.callTool({
name: 'browser_emulate_media',
arguments: { colorScheme: 'dark', media: 'print', reducedMotion: 'reduce' },
})).toHaveResponse({
code: `await page.emulateMedia({ colorScheme: 'dark', media: 'print', reducedMotion: 'reduce' });`,
});
});

test('browser_emulate_media without parameters', async ({ client, server }) => {
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});

expect(await client.callTool({
name: 'browser_emulate_media',
arguments: {},
})).toHaveResponse({
error: expect.stringContaining('Specify at least one media feature to emulate.'),
isError: true,
});
});

test('old locator error message', async ({ client, server }) => {
server.setContent('/', `
<button>Button 1</button>
Expand Down
25 changes: 25 additions & 0 deletions tests/mcp/device.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,31 @@ test('--device should work', async ({ startClient, server }) => {
});
});

test('--color-scheme starts the session in dark mode', async ({ startClient, server }) => {
const { client } = await startClient({
args: ['--color-scheme', 'dark'],
});

server.setRoute('/', (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<body></body>
<script>
document.body.textContent = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
</script>
`);
});

expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
})).toHaveResponse({
snapshot: expect.stringContaining(`dark`),
});
});

test('--mobile emulates a mobile viewport', async ({ startClient, server, mcpBrowser }) => {
test.skip(mcpBrowser === 'firefox', '--mobile is not supported with Firefox.');

Expand Down
Loading