diff --git a/packages/isomorphic/stringUtils.ts b/packages/isomorphic/stringUtils.ts index 8629b3813db03..4bddaffbda09f 100644 --- a/packages/isomorphic/stringUtils.ts +++ b/packages/isomorphic/stringUtils.ts @@ -48,6 +48,8 @@ export function toSnakeCase(name: string): string { } export function formatObject(value: any, indent = ' ', mode: 'multiline' | 'oneline' = 'multiline'): string { + if (value === null) + return 'null'; if (typeof value === 'string') return escapeWithQuotes(value, '\''); if (Array.isArray(value)) diff --git a/packages/playwright-core/src/tools/backend/emulation.ts b/packages/playwright-core/src/tools/backend/emulation.ts new file mode 100644 index 0000000000000..58d593d73fa92 --- /dev/null +++ b/packages/playwright-core/src/tools/backend/emulation.ts @@ -0,0 +1,50 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as z from 'zod'; +import { formatObject } from '@isomorphic/stringUtils'; + +import { defineTabTool } from './tool'; + +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; null clears an override.', + inputSchema: z.object({ + colorScheme: z.enum(['light', 'dark']).nullable().optional().describe('Emulates the prefers-color-scheme media feature'), + reducedMotion: z.enum(['reduce', 'no-preference']).nullable().optional().describe('Emulates the prefers-reduced-motion media feature'), + forcedColors: z.enum(['active', 'none']).nullable().optional().describe('Emulates the forced-colors media feature'), + contrast: z.enum(['more', 'no-preference']).nullable().optional().describe('Emulates the prefers-contrast media feature'), + media: z.enum(['screen', 'print']).nullable().optional().describe('Changes the CSS media type of the page'), + }), + type: 'action', + }, + + handle: async (tab, params, response) => { + if (Object.values(params).every(value => value === undefined)) { + response.addError('Error: Specify at least one media feature to emulate.'); + return; + } + response.addCode(`await page.emulateMedia(${formatObject(params, ' ', 'oneline')});`); + await tab.page.emulateMedia(params); + }, +}); + +export default [ + emulateMedia, +]; diff --git a/packages/playwright-core/src/tools/backend/tools.ts b/packages/playwright-core/src/tools/backend/tools.ts index 212c98fcfa6f7..7024c30b38766 100644 --- a/packages/playwright-core/src/tools/backend/tools.ts +++ b/packages/playwright-core/src/tools/backend/tools.ts @@ -21,6 +21,7 @@ import console from './console'; import cookies from './cookies'; import devtools from './devtools'; import dialogs from './dialogs'; +import emulation from './emulation'; import evaluate from './evaluate'; import files from './files'; import find from './find'; @@ -54,6 +55,7 @@ export const browserTools: Tool[] = [ ...cookies, ...devtools, ...dialogs, + ...emulation, ...evaluate, ...files, ...find, diff --git a/packages/playwright-core/src/tools/cli-daemon/command.ts b/packages/playwright-core/src/tools/cli-daemon/command.ts index dff63f2801a3a..e3b8336e9f3a8 100644 --- a/packages/playwright-core/src/tools/cli-daemon/command.ts +++ b/packages/playwright-core/src/tools/cli-daemon/command.ts @@ -17,7 +17,7 @@ import * as z from 'zod'; import type zodType from 'zod'; -export type Category = 'core' | 'navigation' | 'keyboard' | 'mouse' | 'export' | 'storage' | 'tabs' | 'network' | 'devtools' | 'browsers' | 'config' | 'install' | 'webmcp'; +export type Category = 'core' | 'navigation' | 'keyboard' | 'mouse' | 'export' | 'storage' | 'emulation' | 'tabs' | 'network' | 'devtools' | 'browsers' | 'config' | 'install' | 'webmcp'; export type CommandSchema = { name: string; diff --git a/packages/playwright-core/src/tools/cli-daemon/commands.ts b/packages/playwright-core/src/tools/cli-daemon/commands.ts index 5eb00f8835f52..5494537982703 100644 --- a/packages/playwright-core/src/tools/cli-daemon/commands.ts +++ b/packages/playwright-core/src/tools/cli-daemon/commands.ts @@ -474,6 +474,106 @@ const resize = declareCommand({ toolParams: ({ w: width, h: height }) => ({ width, height }), }); +const setColorScheme = declareCommand({ + name: 'set-color-scheme', + description: 'Emulate the light or dark color scheme', + category: 'emulation', + args: z.object({ + scheme: z.enum(['light', 'dark']).describe('Color scheme to emulate'), + }), + toolName: 'browser_emulate_media', + toolParams: ({ scheme: colorScheme }) => ({ colorScheme }), +}); + +const setReducedMotion = declareCommand({ + name: 'set-reduced-motion', + description: 'Emulate the reduced motion preference', + category: 'emulation', + args: z.object({ + motion: z.enum(['reduce', 'no-preference']).describe('Reduced motion preference to emulate'), + }), + toolName: 'browser_emulate_media', + toolParams: ({ motion: reducedMotion }) => ({ reducedMotion }), +}); + +const setForcedColors = declareCommand({ + name: 'set-forced-colors', + description: 'Emulate forced colors mode', + category: 'emulation', + args: z.object({ + colors: z.enum(['active', 'none']).describe('Forced colors mode to emulate'), + }), + toolName: 'browser_emulate_media', + toolParams: ({ colors: forcedColors }) => ({ forcedColors }), +}); + +const setContrast = declareCommand({ + name: 'set-contrast', + description: 'Emulate the preferred contrast', + category: 'emulation', + args: z.object({ + contrast: z.enum(['more', 'no-preference']).describe('Contrast preference to emulate'), + }), + toolName: 'browser_emulate_media', + toolParams: ({ contrast }) => ({ contrast }), +}); + +const setMedia = declareCommand({ + name: 'set-media', + description: 'Emulate the CSS media type', + category: 'emulation', + args: z.object({ + media: z.enum(['screen', 'print']).describe('CSS media type to emulate'), + }), + toolName: 'browser_emulate_media', + toolParams: ({ media }) => ({ media }), +}); + +const clearColorScheme = declareCommand({ + name: 'clear-color-scheme', + description: 'Clear color scheme emulation', + category: 'emulation', + args: z.object({}), + toolName: 'browser_emulate_media', + toolParams: () => ({ colorScheme: null }), +}); + +const clearReducedMotion = declareCommand({ + name: 'clear-reduced-motion', + description: 'Clear reduced motion emulation', + category: 'emulation', + args: z.object({}), + toolName: 'browser_emulate_media', + toolParams: () => ({ reducedMotion: null }), +}); + +const clearForcedColors = declareCommand({ + name: 'clear-forced-colors', + description: 'Clear forced colors emulation', + category: 'emulation', + args: z.object({}), + toolName: 'browser_emulate_media', + toolParams: () => ({ forcedColors: null }), +}); + +const clearContrast = declareCommand({ + name: 'clear-contrast', + description: 'Clear preferred contrast emulation', + category: 'emulation', + args: z.object({}), + toolName: 'browser_emulate_media', + toolParams: () => ({ contrast: null }), +}); + +const clearMedia = declareCommand({ + name: 'clear-media', + description: 'Clear CSS media type emulation', + category: 'emulation', + args: z.object({}), + toolName: 'browser_emulate_media', + toolParams: () => ({ media: null }), +}); + const runCode = declareCommand({ name: 'run-code', description: 'Run Playwright code snippet', @@ -1277,6 +1377,18 @@ const commandsArray: AnyCommandSchema[] = [ sessionStorageDelete, sessionStorageClear, + // emulation category + setColorScheme, + setReducedMotion, + setForcedColors, + setContrast, + setMedia, + clearColorScheme, + clearReducedMotion, + clearForcedColors, + clearContrast, + clearMedia, + // network category networkRequests, networkRequest, diff --git a/packages/playwright-core/src/tools/cli-daemon/helpGenerator.ts b/packages/playwright-core/src/tools/cli-daemon/helpGenerator.ts index 23e3df62e43a7..a764802b687e3 100644 --- a/packages/playwright-core/src/tools/cli-daemon/helpGenerator.ts +++ b/packages/playwright-core/src/tools/cli-daemon/helpGenerator.ts @@ -83,6 +83,7 @@ const categories: { name: Category, title: string }[] = [ { name: 'export', title: 'Save as' }, { name: 'tabs', title: 'Tabs' }, { name: 'storage', title: 'Storage' }, + { name: 'emulation', title: 'Emulation' }, { name: 'network', title: 'Network' }, { name: 'devtools', title: 'DevTools' }, { name: 'webmcp', title: 'WebMCP' }, diff --git a/packages/playwright-core/src/tools/skills/playwright-cli/SKILL.md b/packages/playwright-core/src/tools/skills/playwright-cli/SKILL.md index b67c94028834b..c312fa8fde9e2 100644 --- a/packages/playwright-core/src/tools/skills/playwright-cli/SKILL.md +++ b/packages/playwright-core/src/tools/skills/playwright-cli/SKILL.md @@ -144,6 +144,21 @@ playwright-cli sessionstorage-delete step playwright-cli sessionstorage-clear ``` +### Emulation + +```bash +playwright-cli set-color-scheme dark +playwright-cli clear-color-scheme +playwright-cli set-reduced-motion reduce +playwright-cli clear-reduced-motion +playwright-cli set-forced-colors active +playwright-cli clear-forced-colors +playwright-cli set-contrast more +playwright-cli clear-contrast +playwright-cli set-media print +playwright-cli clear-media +``` + ### Network ```bash diff --git a/tests/mcp/capabilities.spec.ts b/tests/mcp/capabilities.spec.ts index cc8e0eb36a1e9..0f53998534df8 100644 --- a/tests/mcp/capabilities.spec.ts +++ b/tests/mcp/capabilities.spec.ts @@ -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', diff --git a/tests/mcp/cli-emulation.spec.ts b/tests/mcp/cli-emulation.spec.ts new file mode 100644 index 0000000000000..738b4ed559a8a --- /dev/null +++ b/tests/mcp/cli-emulation.spec.ts @@ -0,0 +1,51 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from './cli-fixtures'; + +test('set and clear media features', async ({ cli, server }) => { + await cli('open', server.PREFIX); + + const expectMatches = async (query: string) => { + const { output } = await cli('eval', `() => matchMedia('${query}').matches`); + expect(output).toContain('### Result\ntrue'); + }; + + const expectClear = async (command: string, option: string) => { + const { output } = await cli(command); + expect(output).toContain(`await page.emulateMedia({ ${option}: null });`); + }; + + await cli('set-color-scheme', 'dark'); + await expectMatches('(prefers-color-scheme: dark)'); + await expectClear('clear-color-scheme', 'colorScheme'); + + await cli('set-reduced-motion', 'reduce'); + await expectMatches('(prefers-reduced-motion: reduce)'); + await expectClear('clear-reduced-motion', 'reducedMotion'); + + await cli('set-forced-colors', 'active'); + await expectMatches('(forced-colors: active)'); + await expectClear('clear-forced-colors', 'forcedColors'); + + await cli('set-contrast', 'more'); + await expectMatches('(prefers-contrast: more)'); + await expectClear('clear-contrast', 'contrast'); + + await cli('set-media', 'print'); + await expectMatches('print'); + await expectClear('clear-media', 'media'); +}); diff --git a/tests/mcp/cli-help.spec.ts b/tests/mcp/cli-help.spec.ts index be25ffbe13296..20cce78bb3f69 100644 --- a/tests/mcp/cli-help.spec.ts +++ b/tests/mcp/cli-help.spec.ts @@ -21,6 +21,23 @@ test('prints help', async ({ cli }) => { expect(output).toContain('Usage: playwright-cli '); }); +test('prints emulation help after storage', async ({ cli }) => { + const { output } = await cli('--help'); + const headings = output.split('\n').filter(line => /^[A-Z].*:$/.test(line)); + expect(headings.indexOf('Emulation:')).toBe(headings.indexOf('Storage:') + 1); + const emulationHelp = output.slice(output.indexOf('\nEmulation:'), output.indexOf('\nNetwork:')); + expect(emulationHelp).toContain('set-color-scheme'); + expect(emulationHelp).toContain('clear-color-scheme'); + expect(emulationHelp).toContain('set-reduced-motion'); + expect(emulationHelp).toContain('clear-reduced-motion'); + expect(emulationHelp).toContain('set-forced-colors'); + expect(emulationHelp).toContain('clear-forced-colors'); + expect(emulationHelp).toContain('set-contrast'); + expect(emulationHelp).toContain('clear-contrast'); + expect(emulationHelp).toContain('set-media'); + expect(emulationHelp).toContain('clear-media'); +}); + test('prints help by default', async ({ cli }) => { const { output } = await cli(); expect(output).toContain('Usage: playwright-cli '); diff --git a/tests/mcp/config-resolve.spec.ts b/tests/mcp/config-resolve.spec.ts index 9123930e51562..7ba4e2d5da47b 100644 --- a/tests/mcp/config-resolve.spec.ts +++ b/tests/mcp/config-resolve.spec.ts @@ -254,6 +254,23 @@ test.describe('viewport', () => { }); }); +test('config file preserves context media preferences', async ({}, testInfo) => { + const configFile = testInfo.outputPath('config.json'); + const fileConfig: Config = { + browser: { + contextOptions: { + colorScheme: 'dark', + contrast: 'more', + forcedColors: 'active', + reducedMotion: 'reduce', + }, + }, + }; + await fs.promises.writeFile(configFile, JSON.stringify(fileConfig)); + const config = await resolveCLIConfigForMCP({ config: configFile }, emptyEnv); + expect(config.browser.contextOptions).toMatchObject(fileConfig.browser!.contextOptions!); +}); + test.describe('mobile', () => { test('--mobile defaults to a Chromium mobile device', async () => { const config = await resolveCLIConfigForMCP({ mobile: true }, emptyEnv); diff --git a/tests/mcp/emulation.spec.ts b/tests/mcp/emulation.spec.ts new file mode 100644 index 0000000000000..ff2392f2ea441 --- /dev/null +++ b/tests/mcp/emulation.spec.ts @@ -0,0 +1,89 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from './fixtures'; + +test('browser_emulate_media', async ({ client, server }) => { + server.setContent('/', ` +
+
+ + `, '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: { media: 'screen', reducedMotion: 'reduce' }, + })).toHaveResponse({ + code: `await page.emulateMedia({ media: 'screen', reducedMotion: 'reduce' });`, + }); + await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toHaveResponse({ + inlineSnapshot: expect.stringContaining(`Color scheme: dark`), + }); + + expect(await client.callTool({ + name: 'browser_emulate_media', + arguments: { media: 'print' }, + })).toHaveResponse({ + code: `await page.emulateMedia({ media: 'print' });`, + }); + await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toHaveResponse({ + inlineSnapshot: expect.stringContaining(`Media type: print`), + }); + + expect(await client.callTool({ + name: 'browser_emulate_media', + arguments: { colorScheme: null, contrast: null, forcedColors: null, media: null, reducedMotion: null }, + })).toHaveResponse({ + code: `await page.emulateMedia({ colorScheme: null, contrast: null, forcedColors: null, media: null, reducedMotion: null });`, + }); + await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toHaveResponse({ + inlineSnapshot: expect.stringContaining(`Media type: screen`), + }); +}); + +test('browser_emulate_media requires a media feature', async ({ client }) => { + expect(await client.callTool({ + name: 'browser_emulate_media', + arguments: {}, + })).toHaveResponse({ + error: expect.stringContaining('Specify at least one media feature to emulate.'), + isError: true, + }); +});