Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .github/workflows/tests_primary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ jobs:
- os: ubuntu-22.04
node-version: 24
browser: chromium
- os: ubuntu-22.04-arm
node-version: 20
browser: chromium
runs-on: ${{ matrix.os }}
permissions:
id-token: write # This is required for OIDC login (azure/login) to succeed
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests_secondary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, macos-13, windows-latest]
os: [ubuntu-22.04, macos-14-large, windows-latest]
headed: ['--headed', '']
exclude:
# Tested in tests_primary.yml already
Expand Down
28 changes: 20 additions & 8 deletions packages/playwright/src/mcp/terminal/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ import { declareCommand } from './command';

import type { AnyCommandSchema } from './command';

const numberArg = z.preprocess((val, ctx) => {
const number = Number(val);
if (Number.isNaN(number)) {
ctx.issues.push({
code: 'custom',
message: `expected number, received '${val}'`,
input: val,
});
}
return number;
}, z.number());

// Navigation commands

const open = declareCommand({
Expand Down Expand Up @@ -143,8 +155,8 @@ const mouseMove = declareCommand({
description: 'Move mouse to a given position',
category: 'mouse',
args: z.object({
x: z.number().describe('X coordinate'),
y: z.number().describe('Y coordinate'),
x: numberArg.describe('X coordinate'),
y: numberArg.describe('Y coordinate'),
}),
toolName: 'browser_mouse_move_xy',
toolParams: ({ x, y }) => ({ x, y }),
Expand Down Expand Up @@ -177,8 +189,8 @@ const mouseWheel = declareCommand({
description: 'Scroll mouse wheel',
category: 'mouse',
args: z.object({
dx: z.number().describe('Y delta'),
dy: z.number().describe('X delta'),
dx: numberArg.describe('Y delta'),
dy: numberArg.describe('X delta'),
}),
toolName: 'browser_mouse_wheel',
toolParams: ({ dx: deltaY, dy: deltaX }) => ({ deltaY, deltaX }),
Expand Down Expand Up @@ -348,8 +360,8 @@ const resize = declareCommand({
description: 'Resize the browser window',
category: 'core',
args: z.object({
w: z.number().describe('Width of the browser window'),
h: z.number().describe('Height of the browser window'),
w: numberArg.describe('Width of the browser window'),
h: numberArg.describe('Height of the browser window'),
}),
toolName: 'browser_resize',
toolParams: ({ w: width, h: height }) => ({ width, height }),
Expand Down Expand Up @@ -393,7 +405,7 @@ const tabClose = declareCommand({
description: 'Close a browser tab',
category: 'tabs',
args: z.object({
index: z.number().optional().describe('Tab index. If omitted, current tab is closed.'),
index: numberArg.optional().describe('Tab index. If omitted, current tab is closed.'),
}),
toolName: 'browser_tabs',
toolParams: ({ index }) => ({ action: 'close', index }),
Expand All @@ -404,7 +416,7 @@ const tabSelect = declareCommand({
description: 'Select a browser tab',
category: 'tabs',
args: z.object({
index: z.number().describe('Tab index'),
index: numberArg.describe('Tab index'),
}),
toolName: 'browser_tabs',
toolParams: ({ index }) => ({ action: 'select', index }),
Expand Down
25 changes: 25 additions & 0 deletions packages/playwright/src/mcp/terminal/helpGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
* limitations under the License.
*/

import { z } from 'playwright-core/lib/mcpBundle';

import { commands } from './commands';

import type zodType from 'zod';
Expand Down Expand Up @@ -146,12 +148,35 @@ function generateReadmeEntry(command: AnyCommandSchema): string {
return formatWithGap(prefix, suffix, 40);
}

function unwrapZodType(schema: zodType.ZodTypeAny): zodType.ZodTypeAny {
if ('unwrap' in schema && typeof schema.unwrap === 'function')
return unwrapZodType(schema.unwrap());
return schema;
}

export function generateHelpJSON() {
const stringOptions = new Set<string>();
const booleanOptions = new Set<string>();
for (const command of Object.values(commands)) {
if (!command.options)
continue;
const optionsShape = (command.options as zodType.ZodObject<any>).shape;
for (const [name, schema] of Object.entries(optionsShape)) {
const innerSchema = unwrapZodType(schema as zodType.ZodTypeAny);
if (innerSchema instanceof z.ZodString)
stringOptions.add(name);
if (innerSchema instanceof z.ZodBoolean)
booleanOptions.add(name);
}
}

const help = {
global: generateHelp(),
commands: Object.fromEntries(
Object.entries(commands).map(([name, command]) => [name, generateCommandHelp(command)])
),
stringOptions: [...stringOptions],
booleanOptions: [...booleanOptions],
};
return help;
}
Expand Down
21 changes: 13 additions & 8 deletions packages/playwright/src/mcp/terminal/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,20 +493,25 @@ const booleanOptions: (keyof (GlobalOptions & OpenOptions & { all?: boolean }))[

export async function program(packageLocation: string) {
const clientInfo = createClientInfo(packageLocation);
const help = require('./help.json');

const argv = process.argv.slice(2);
const args: MinimistArgs = require('minimist')(argv, { boolean: booleanOptions });
for (const option of booleanOptions) {
const boolean = [...help.booleanOptions, ...booleanOptions];
const args: MinimistArgs = require('minimist')(argv, { boolean, string: [...help.stringOptions, '_'] });
for (const option of boolean) {
if (!argv.includes(`--${option}`) && !argv.includes(`--no-${option}`))
delete args[option];
if (argv.some(arg => arg.startsWith(`--${option}=`) || arg.startsWith(`--no-${option}=`))) {
console.error(`boolean option '--${option}' should not be passed with '=value', use '--${option}' or '--no-${option}' instead`);
process.exit(1);
}
}
// Normalize -s alias to --session
if (args.s) {
args.session = args.s;
delete args.s;
}

const help = require('./help.json');
const commandName = args._?.[0];

if (args.version || args.v) {
Expand Down Expand Up @@ -573,19 +578,19 @@ async function install(args: MinimistArgs) {
// Create .playwright folder to mark workspace root
const playwrightDir = path.join(cwd, '.playwright');
await fs.promises.mkdir(playwrightDir, { recursive: true });
console.log(`Workspace initialized at ${cwd}`);
console.log(`Workspace initialized at \`${cwd}\`.`);

if (args.skills) {
const skillSourceDir = path.join(__dirname, '../../skill');
const skillDestDir = path.join(cwd, '.claude', 'skills', 'playwright-cli');

if (!fs.existsSync(skillSourceDir)) {
console.error('Skills source directory not found:', skillSourceDir);
console.error('Skills source directory not found:', skillSourceDir);
process.exit(1);
}

await fs.promises.cp(skillSourceDir, skillDestDir, { recursive: true });
console.log(`Skills installed to ${path.relative(cwd, skillDestDir)}`);
console.log(`Skills installed to \`${path.relative(cwd, skillDestDir)}\`.`);
}

if (!args.config)
Expand Down Expand Up @@ -622,7 +627,7 @@ async function createDefaultConfig(channel: string) {
},
};
await fs.promises.writeFile(defaultConfigFile(), JSON.stringify(config, null, 2));
console.log(`Created default config for ${channel} at ${path.relative(process.cwd(), defaultConfigFile())}.`);
console.log(`Created default config for ${channel} at ${path.relative(process.cwd(), defaultConfigFile())}.`);
}

async function findOrInstallDefaultBrowser() {
Expand All @@ -632,7 +637,7 @@ async function findOrInstallDefaultBrowser() {
const executable = registry.findExecutable(channel);
if (!executable?.executablePath())
continue;
console.log(`Found ${channel}, will use it as the default browser.`);
console.log(`Found ${channel}, will use it as the default browser.`);
return channel;
}
const chromiumExecutable = registry.findExecutable('chromium');
Expand Down
34 changes: 23 additions & 11 deletions packages/playwright/src/skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,29 @@ allowed-tools: Bash(playwright-cli:*)
## Quick start

```bash
playwright-cli open https://playwright.dev
# open new browser
playwright-cli open
# navigate to a page
playwright-cli goto https://playwright.dev
# interact with the page using refs from the snapshot
playwright-cli click e15
playwright-cli type "page.click"
playwright-cli press Enter
# take a screenshot
playwright-cli screenshot
# close the browser
playwright-cli close
```

## Core workflow

1. Navigate: `playwright-cli open https://example.com`
2. Interact using refs from the snapshot
3. Re-snapshot after significant changes

## Commands

### Core

```bash
playwright-cli open
# open and navigate right away
playwright-cli open https://example.com/
playwright-cli close
playwright-cli goto https://playwright.dev
playwright-cli type "search query"
playwright-cli click e3
playwright-cli dblclick e7
Expand All @@ -46,6 +50,7 @@ playwright-cli dialog-accept
playwright-cli dialog-accept "confirmation text"
playwright-cli dialog-dismiss
playwright-cli resize 1920 1080
playwright-cli close
```

### Navigation
Expand Down Expand Up @@ -153,8 +158,8 @@ playwright-cli video-stop video.webm
### Install

```bash
playwright-cli install --skills
playwright-cli install-browser
playwright-cli install-skills
```

### Configuration
Expand Down Expand Up @@ -184,10 +189,13 @@ playwright-cli delete-data
### Browser Sessions

```bash
playwright-cli -s=mysession open example.com
# create new browser session named "mysession" with persistent profile
playwright-cli -s=mysession open example.com --persistent
# same with manually specified profile directory (use when requested explicitly)
playwright-cli -s=mysession open example.com --profile=/path/to/profile
playwright-cli -s=mysession click e6
playwright-cli -s=mysession close # stop a named browser
playwright-cli -s=mysession delete-data # delete user data for named browser
playwright-cli -s=mysession delete-data # delete user data for persistent session

playwright-cli list
# Close all browsers
Expand All @@ -206,6 +214,7 @@ playwright-cli fill e1 "user@example.com"
playwright-cli fill e2 "password123"
playwright-cli click e3
playwright-cli snapshot
playwright-cli close
```

## Example: Multi-tab workflow
Expand All @@ -216,6 +225,7 @@ playwright-cli tab-new https://example.com/other
playwright-cli tab-list
playwright-cli tab-select 0
playwright-cli snapshot
playwright-cli close
```

## Example: Debugging with DevTools
Expand All @@ -226,6 +236,7 @@ playwright-cli click e4
playwright-cli fill e7 "test"
playwright-cli console
playwright-cli network
playwright-cli close
```

```bash
Expand All @@ -234,6 +245,7 @@ playwright-cli tracing-start
playwright-cli click e4
playwright-cli fill e7 "test"
playwright-cli tracing-stop
playwright-cli close
```

## Specific tasks
Expand Down
9 changes: 9 additions & 0 deletions tests/mcp/cli-core.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@ test('fill', async ({ cli, server }) => {
expect(fillSnapshot).toBe(`- textbox [active] [ref=e2]: Hello, world!`);
});

test('fill numeric', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-cli/issues/235' } }, async ({ cli, server }) => {
server.setContent('/', `<input type=text>`, 'text/html');
const { snapshot } = await cli('open', server.PREFIX);
expect(snapshot).toContain(`- textbox [ref=e2]`);

const { snapshot: fillSnapshot } = await cli('fill', 'e2', '42', '--submit');
expect(fillSnapshot).toContain(`[ref=e2]: "42"`);
});

test('hover', async ({ cli, server }) => {
server.setContent('/', eventsPage, 'text/html');
await cli('open', server.PREFIX);
Expand Down
2 changes: 1 addition & 1 deletion tests/mcp/cli-misc.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ test('install workspace', async ({ cli }, testInfo) => {

test('install workspace w/skills', async ({ cli }, testInfo) => {
const { output } = await cli('install', '--skills');
expect(output).toContain(`Skills installed to .claude${path.sep}skills${path.sep}playwright-cli`);
expect(output).toContain(`Skills installed to \`.claude${path.sep}skills${path.sep}playwright-cli\`.`);

const skillFile = testInfo.outputPath('.claude', 'skills', 'playwright-cli', 'SKILL.md');
expect(fs.existsSync(skillFile)).toBe(true);
Expand Down
17 changes: 13 additions & 4 deletions tests/mcp/cli-parsing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,18 @@ test('too many arguments', async ({ cli, server }) => {

test('wrong option type', async ({ cli, server }) => {
await cli('open', server.HELLO_WORLD);
const { error, exitCode } = await cli('type', 'foo', '--submit=bar');
expect(exitCode).toBe(1);
expect(error).toContain(`error: '--submit' option: expected boolean, received string`);
const boolean = await cli('type', 'foo', '--submit=bar');
expect(boolean.exitCode).toBe(1);
expect(boolean.error).toContain(`boolean option '--submit' should not be passed with '=value', use '--submit' or '--no-submit' instead`);
const status = await cli('route', '.', '--status=OK');
expect(status.exitCode).toBe(1);
expect(status.error).toContain(`error: '--status' option: expected number, received string`);
});

test('arg after boolean option', async ({ cli, server }) => {
await cli('open', server.HELLO_WORLD);
const boolean = await cli('type', '--submit', 'foo');
expect(boolean.exitCode).toBe(0);
});

test('missing argument', async ({ cli, server }) => {
Expand All @@ -46,5 +55,5 @@ test('wrong argument type', async ({ cli, server }) => {
await cli('open', server.HELLO_WORLD);
const { error, exitCode } = await cli('mousemove', '12', 'foo');
expect(exitCode).toBe(1);
expect(error).toContain(`error: 'y' argument: expected number, received string`);
expect(error).toContain(`error: 'y' argument: expected number, received 'foo'`);
});
10 changes: 10 additions & 0 deletions tests/mcp/cli-route.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ test('route with header', async ({ cli, server }) => {
expect(output).toContain('Authorization');
});

test('route with numerical body', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-cli/issues/235' } }, async ({ cli, server }) => {
await cli('open', server.EMPTY_PAGE);

await cli('route', '**/api/data', '--body', '42', '--content-type', 'text/plain');

const { output } = await cli('route-list');
expect(output).toContain('**/api/data');
expect(output).toContain('contentType=text/plain');
});

test('unroute removes specific route', async ({ cli, server }) => {
await cli('open', server.EMPTY_PAGE);

Expand Down
Loading
Loading