42 Commits

Author SHA1 Message Date
Pavel Feldman
9578a5b2af chore: mark v0.0.14 (#237) 2025-04-21 17:52:35 -07:00
Pavel Feldman
cd5aa344f1 docs: push docker doc down the readme (#236) 2025-04-21 17:31:18 -07:00
Cody Rigney
dc955c73a3 Add Docker support (#220) 2025-04-21 17:26:50 -07:00
Rui Figueira
d4f8f87b03 docs: fix "programmatic usage with custom transports" code snippet (#235)
Fixes: #230
2025-04-21 15:09:58 -07:00
Max Schmitt
0c3792d231 chore: auto update tools in README (#219)
Motivation: Keeping the readme up to date is a manual effort - this
keeps it automatically up to date and prevents things like
https://github.com/microsoft/playwright-mcp/pull/214 and other
consistency errors in the future.
2025-04-21 20:22:57 +02:00
Pavel Feldman
7695717546 docs: provide missing docs (#214) 2025-04-17 14:49:22 -07:00
Pavel Feldman
6a070a0dd8 chore: restore page-side timeout (#213) 2025-04-17 14:25:27 -07:00
Pavel Feldman
6481100bdf feat(dialog): handle dialogs (#212) 2025-04-17 14:03:13 -07:00
Pavel Feldman
4b261286bf chore: test list tabs (#208) 2025-04-17 09:58:02 +02:00
Pavel Feldman
7e4a964b0a chore: flatten tool calling, prep for timeout handling (#205) 2025-04-16 19:36:48 -07:00
Pavel Feldman
cea347d067 chore: introduce modal states (#204) 2025-04-16 15:21:45 -07:00
Pavel Feldman
6054290d9a chore: follow up to the element screenshot change (#199) 2025-04-16 12:53:27 -07:00
Andrei-Daniel Barzu
6d4adfe5c6 feat: add element screenshot action for snapshots (#182) 2025-04-16 10:28:44 -07:00
Simon Knott
e7c7709b33 chore: include "playwright" keyword, add examples (#196) 2025-04-16 08:18:40 -07:00
Pavel Feldman
5c2e11017d chore: convert console resource to tool (#193) 2025-04-15 18:01:59 -07:00
Pavel Feldman
e4331313f9 chore: update exported types (#192)
Fixes https://github.com/microsoft/playwright-mcp/issues/186
2025-04-15 16:39:52 -07:00
Pavel Feldman
bc48600a49 chore: mark v0.0.13 (#190) 2025-04-15 15:27:29 -07:00
Yury Semikhatsky
0d6bb2f547 devops: add bots for other browsers/platforms (#174) 2025-04-15 13:16:56 -07:00
Pavel Feldman
795a9d578a chore: generalize status & action as code (#188) 2025-04-15 12:54:45 -07:00
Simon Knott
4a19e18999 feat: respond with action and generated locator (#181)
Closes https://github.com/microsoft/playwright-mcp/issues/163
2025-04-15 10:55:20 -07:00
Simon Knott
4d59e06184 test: fix flaky test (#180)
Closes https://github.com/microsoft/playwright-mcp/issues/177

`ResizeObserver` isn't instant!
2025-04-15 16:10:49 +02:00
Pavel Feldman
6891a525b3 chore: add npx install step to the publish workflow (#178) 2025-04-14 20:09:38 -07:00
Yury Semikhatsky
0f7fd1362f chore: mark 0.0.12 (#176) 2025-04-14 19:39:10 -07:00
Yury Semikhatsky
de08c24b96 fix: consider DISPLAY only on linux (#175) 2025-04-14 19:07:39 -07:00
Pavel Feldman
71e51ea42a chore: mark v0.0.11 (#173) 2025-04-14 16:48:36 -07:00
Pavel Feldman
0c5a104e0f chore: default to headless when DISPLAY is missing (#172)
Fixes https://github.com/microsoft/playwright-mcp/issues/165
2025-04-14 16:47:32 -07:00
Pavel Feldman
606b898a71 chore: allow reusing tab over cdp (#170)
Fixes https://github.com/microsoft/playwright-mcp/issues/164
2025-04-14 16:39:58 -07:00
Simon Knott
e729494bd9 feat: browser_resize (#92) 2025-04-14 16:09:48 -07:00
Cameron
77080e8ca4 Restore package-lock.json module hashes (#151)
- Adds integrity hashes that were missing for 5 npm packages in
`package-lock.json`

<hr />

Is there a reason hashes for some of these dependencies are missing from
`package-lock.json`?

Right now these omissions prevent me from packaging a nix derivation for
this mcp server directly off this repo
(https://github.com/cameronfyfe/nix-mcp-servers/blob/main/pkgs/servers/mcp-server-playwright/default.nix#L17)
and I was wondering if they might just be missing due to a bad merge or
something odd like that at some point. `npm install` under normal use
doesn't seem to care if the hashes are missing and installs the packages
anyway, but it's a blocker for hermetic build systems like nix.

If this is intentional for some reason I'm not familiar with feel free
to ignore and close.
2025-04-10 15:24:36 +02:00
Simon Knott
31ac1ed191 fix: exit watchdog should listen for SIGINT/SIGTERM (#144) 2025-04-07 14:51:57 -07:00
Paul Irish
b8ff009b0a chore: add back stable vscode install button (#145) 2025-04-07 14:18:01 -07:00
Yoshiki Nakagawa
42167878fb chore: Update README.md (#140)
Remove old documents
2025-04-07 08:54:05 +02:00
Pavel Feldman
6b15c7e422 chore: mark v0.0.10 (#138) 2025-04-05 19:14:50 -07:00
Pavel Feldman
abd56f514b chore: introduce capabilities argument (#135) 2025-04-04 17:14:30 -07:00
Pavel Feldman
707ebbf4d4 chore: group tools, prepare for capabilities (#134) 2025-04-04 15:22:00 -07:00
Pavel Feldman
fc0cccf4a5 chore: reuse the first tab when navigating (#131) 2025-04-03 22:39:55 -07:00
Pavel Feldman
e36d4ea695 chore: allow multiple tabs (#129) 2025-04-03 19:24:17 -07:00
Pavel Feldman
b358e47d71 chore: prep for multiple pages in context (#124) 2025-04-03 10:30:05 -07:00
Yury Semikhatsky
38f038a5dc chore: typo in description (#127) 2025-04-02 17:26:45 -07:00
Yury Semikhatsky
2291011dc7 feat: add slowly option for typing one character at a time (#121) 2025-04-02 14:36:30 -07:00
Pavel Feldman
89627fd23a chore: extract page snapshot, prep for multipage (#120) 2025-04-02 11:42:39 -07:00
Pavel Feldman
23f392dd91 chore: mark v0.0.9 (#114) 2025-04-01 15:45:00 -07:00
45 changed files with 3354 additions and 1085 deletions

View File

@@ -7,8 +7,30 @@ on:
branches: [ main ]
jobs:
build-and-test:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 18
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- run: npm run build
- name: Run ESLint
run: npm run lint
- run: npm run update-readme
- name: Ensure no changes
run: git diff --exit-code
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
@@ -22,8 +44,12 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Run linting
run: npm run lint
- name: Playwright install
run: npx playwright install --with-deps
- name: Install MS Edge
if: ${{ matrix.os == 'macos-latest' }} # MS Edge is not preinstalled on macOS runners.
run: npx playwright install msedge
- name: Build
run: npm run build

View File

@@ -15,9 +15,10 @@ jobs:
node-version: 18
registry-url: https://registry.npmjs.org/
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run build
- run: npm run lint
- run: npm run test
- run: npm run ctest
- run: npm publish --provenance
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
lib/
node_modules/
test-results/
.vscode/mcp.json

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM node:22-bookworm-slim
# Set the working directory
WORKDIR /app
# Copy package.json and package-lock.json at this stage to leverage the build cache
COPY package*.json ./
# Install dependencies
RUN npm ci
# Install chromium and its dependencies, but only for headless mode
RUN npx -y playwright install --with-deps --only-shell chromium
# Copy the rest of the app
COPY . .
# Build the app
RUN npm run build
# Run in headless and only with chromium (other browsers need more dependencies not included in this image)
ENTRYPOINT ["node", "cli.js", "--headless", "--browser", "chromium"]

290
README.md
View File

@@ -17,6 +17,8 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
### Example config
#### NPX
```js
{
"mcpServers": {
@@ -30,7 +32,6 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
}
```
#### Installation in VS Code
Install the Playwright MCP server in VS Code using one of these buttons:
@@ -43,7 +44,7 @@ const urlForWebsites = `vscode:mcp/install?${encodeURIComponent(config)}`;
const urlForGithub = `https://insiders.vscode.dev/redirect?url=${encodeURIComponent(urlForWebsites)}`;
-->
[<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
Alternatively, you can install the Playwright MCP server using the VS Code CLI:
@@ -68,6 +69,7 @@ The Playwright MCP server supports the following command-line options:
- Chrome channels: `chrome-beta`, `chrome-canary`, `chrome-dev`
- Edge channels: `msedge-beta`, `msedge-canary`, `msedge-dev`
- Default: `chrome`
- `--caps <caps>`: Comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.
- `--cdp-endpoint <endpoint>`: CDP endpoint to connect to
- `--executable-path <path>`: Path to the browser executable
- `--headless`: Run browser in headless mode (headed by default)
@@ -127,6 +129,20 @@ And then in MCP client config, set the `url` to the SSE endpoint:
}
```
### Docker
**NOTE:** The Docker implementation only supports headless chromium at the moment.
```js
{
"mcpServers": {
"playwright": {
"command": "docker",
"args": ["run", "-i", "--rm", "--init", "mcp/playwright"]
}
}
}
```
### Tool Modes
The tools are available in two modes:
@@ -153,36 +169,45 @@ To use Vision Mode, add the `--vision` flag when starting the server:
Vision Mode works best with the computer use models that are able to interact with elements using
X Y coordinate space, based on the provided screenshot.
### Build with Docker
You can build the Docker image yourself.
```
docker build -t mcp/playwright .
```
### Programmatic usage with custom transports
```js
import http from 'http';
import { createServer } from '@playwright/mcp';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
// ...
http.createServer(async (req, res) => {
// ...
const server = createServer({
launchOptions: { headless: true }
// Creates a headless Playwright MCP server with SSE transport
const mcpServer = await createServer({ headless: true });
const transport = new SSEServerTransport('/messages', res);
await mcpServer.connect(transport);
// ...
});
transport = new SSEServerTransport("/messages", res);
server.connect(transport);
```
### Snapshot Mode
<!--- Generated by update-readme.js -->
The Playwright MCP provides a set of tools for browser automation. Here are all available tools:
### Snapshot-based Interactions
- **browser_navigate**
- Description: Navigate to a URL
- Parameters:
- `url` (string): The URL to navigate to
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_go_back**
- Description: Go back to the previous page
- **browser_snapshot**
- Description: Capture accessibility snapshot of the current page, this is better than screenshot
- Parameters: None
- **browser_go_forward**
- Description: Go forward to the next page
- Parameters: None
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_click**
- Description: Perform click on a web page
@@ -190,19 +215,25 @@ The Playwright MCP provides a set of tools for browser automation. Here are all
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `ref` (string): Exact target element reference from the page snapshot
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_drag**
- Description: Perform drag and drop between two elements
- Parameters:
- `startElement` (string): Human-readable source element description used to obtain the permission to interact with the element
- `startRef` (string): Exact source element reference from the page snapshot
- `endElement` (string): Human-readable target element description used to obtain the permission to interact with the element
- `endRef` (string): Exact target element reference from the page snapshot
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_hover**
- Description: Hover over element on page
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `ref` (string): Exact target element reference from the page snapshot
- **browser_drag**
- Description: Perform drag and drop between two elements
- Parameters:
- `startElement` (string): Human-readable source element description used to obtain permission to interact with the element
- `startRef` (string): Exact source element reference from the page snapshot
- `endElement` (string): Human-readable target element description used to obtain permission to interact with the element
- `endRef` (string): Exact target element reference from the page snapshot
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_type**
- Description: Type text into editable element
@@ -210,114 +241,189 @@ The Playwright MCP provides a set of tools for browser automation. Here are all
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `ref` (string): Exact target element reference from the page snapshot
- `text` (string): Text to type into the element
- `submit` (boolean): Whether to submit entered text (press Enter after)
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
- `slowly` (boolean, optional): Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_select_option**
- Description: Select option in a dropdown
- Description: Select an option in a dropdown
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `ref` (string): Exact target element reference from the page snapshot
- `values` (array): Array of values to select in the dropdown.
- `values` (array): Array of values to select in the dropdown. This can be a single value or multiple values.
- **browser_choose_file**
- Description: Choose one or multiple files to upload
- Parameters:
- `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files.
- **browser_press_key**
- Description: Press a key on the keyboard
- Parameters:
- `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`
- **browser_snapshot**
- Description: Capture accessibility snapshot of the current page (better than screenshot)
- Parameters: None
- **browser_save_as_pdf**
- Description: Save page as PDF
- Parameters: None
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_take_screenshot**
- Description: Capture screenshot of the page
- Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.
- Parameters:
- `raw` (string): Optionally returns lossless PNG screenshot. JPEG by default.
- `raw` (boolean, optional): Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.
- `element` (string, optional): Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.
- `ref` (string, optional): Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.
- **browser_wait**
- Description: Wait for a specified time in seconds
- Parameters:
- `time` (number): The time to wait in seconds (capped at 10 seconds)
### Vision-based Interactions
- **browser_close**
- Description: Close the page
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_screen_capture**
- Description: Take a screenshot of the current page
- Parameters: None
<!-- NOTE: This has been generated via update-readme.js -->
### Vision Mode
- **browser_screen_move_mouse**
- Description: Move mouse to a given position
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `x` (number): X coordinate
- `y` (number): Y coordinate
Vision Mode provides tools for visual-based interactions using screenshots. Here are all available tools:
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_screen_click**
- Description: Click left mouse button
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `x` (number): X coordinate
- `y` (number): Y coordinate
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_screen_drag**
- Description: Drag left mouse button
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `startX` (number): Start X coordinate
- `startY` (number): Start Y coordinate
- `endX` (number): End X coordinate
- `endY` (number): End Y coordinate
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_screen_type**
- Description: Type text
- Parameters:
- `text` (string): Text to type into the element
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
### Tab Management
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_list**
- Description: List browser tabs
- Parameters: None
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_new**
- Description: Open a new tab
- Parameters:
- `url` (string, optional): The URL to navigate to in the new tab. If not provided, the new tab will be blank.
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_select**
- Description: Select a tab by index
- Parameters:
- `index` (number): The index of the tab to select
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_close**
- Description: Close a tab
- Parameters:
- `index` (number, optional): The index of the tab to close. Closes current tab if not provided.
### Navigation
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_navigate**
- Description: Navigate to a URL
- Parameters:
- `url` (string): The URL to navigate to
- **browser_go_back**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_navigate_back**
- Description: Go back to the previous page
- Parameters: None
- **browser_go_forward**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_navigate_forward**
- Description: Go forward to the next page
- Parameters: None
- **browser_screenshot**
- Description: Capture screenshot of the current page
- Parameters: None
### Keyboard
- **browser_move_mouse**
- Description: Move mouse to specified coordinates
- Parameters:
- `x` (number): X coordinate
- `y` (number): Y coordinate
- **browser_click**
- Description: Click at specified coordinates
- Parameters:
- `x` (number): X coordinate to click at
- `y` (number): Y coordinate to click at
- **browser_drag**
- Description: Perform drag and drop operation
- Parameters:
- `startX` (number): Start X coordinate
- `startY` (number): Start Y coordinate
- `endX` (number): End X coordinate
- `endY` (number): End Y coordinate
- **browser_type**
- Description: Type text at specified coordinates
- Parameters:
- `text` (string): Text to type
- `submit` (boolean): Whether to submit entered text (press Enter after)
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_press_key**
- Description: Press a key on the keyboard
- Parameters:
- `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`
- **browser_choose_file**
- Description: Choose one or multiple files to upload
### Console
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_console_messages**
- Description: Returns all console messages
- Parameters: None
### Files and Media
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_file_upload**
- Description: Upload one or multiple files
- Parameters:
- `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files.
- **browser_save_as_pdf**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_pdf_save**
- Description: Save page as PDF
- Parameters: None
- **browser_wait**
- Description: Wait for a specified time in seconds
- Parameters:
- `time` (number): The time to wait in seconds (capped at 10 seconds)
### Utilities
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_close**
- Description: Close the page
- Parameters: None
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_wait**
- Description: Wait for a specified time in seconds
- Parameters:
- `time` (number): The time to wait in seconds
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_resize**
- Description: Resize the browser window
- Parameters:
- `width` (number): Width of the browser window
- `height` (number): Height of the browser window
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_install**
- Description: Install the browser specified in the config. Call this if you get an error about the browser not being installed.
- Parameters: None
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_handle_dialog**
- Description: Handle a dialog
- Parameters:
- `accept` (boolean): Whether to accept the dialog.
- `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
<!--- End of generated section -->

47
examples.md Normal file
View File

@@ -0,0 +1,47 @@
# Examples
Below are some examples of prompts to use with Playwright MCP. Draw inspiration from them to create your own. If you come up with a new prompt that works well, feel free to let us know in an issue or pull request.
## Write a regression test for a bug report
We have received a user report about a bug on our website https://debs-obrien.github.io/playwright-movies-app. I'd like you to verify if this bug exists by trying to follow the steps. If you see different behaviour, explain how it's different. If you see the same behaviour, please write me a Playwright regression test that encapsulates the buggy user flow. While writing the test, draw inspiration from the Playwright code you ran. Here's the user report:
Howdy. I love your movies site, but there's something fishy going on. I'm looking at The Garfield Movie - a spectacular film - but somehow it's showing the wrong length. The movie is 90mins long, I measured myself yesterday! But somehow your site is claiming 101mins. Am I looking at it wrong, or is there a bug?
**Output:**
```ts
import { test, expect } from '@playwright/test';
/**
* Regression test for bug: The Garfield Movie showing incorrect runtime
* User reported actual runtime is 90 minutes, but site shows 101 minutes
*/
test('verify The Garfield Movie runtime bug', async ({ page }) => {
// Navigate to the movies app home page
await page.goto('https://debs-obrien.github.io/playwright-movies-app');
// Wait for page to load completely
await expect(page.getByRole('heading', { name: 'Popular', level: 1 })).toBeVisible();
// Look for The Garfield Movie and click on it
const garfieldMovieLink = page.getByRole('link', { name: /poster of The Garfield Movie/i });
await expect(garfieldMovieLink).toBeVisible();
await garfieldMovieLink.click();
// Verify we're on the correct movie page
await expect(page.getByRole('heading', { name: 'The Garfield Movie', level: 1 })).toBeVisible();
// Check the runtime information
// The bug: Expected runtime should be 90 min, but site shows 101 min
const movieInfoText = await page.locator('text=English / 101 min. / 2024').textContent();
// This test will fail because of the bug (which is what we want to demonstrate)
// Once fixed, this assertion should be updated to the correct runtime (90 min)
expect(movieInfoText).toContain('90 min');
// Alternative assertion that verifies the incorrect runtime is still present
// Uncomment this and comment the above assertion to verify the bug exists
// expect(movieInfoText).toContain('101 min');
});
```

58
index.d.ts vendored
View File

@@ -15,26 +15,46 @@
* limitations under the License.
*/
import type { LaunchOptions } from 'playwright';
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install';
type Options = {
/**
* Path to the user data directory.
*/
userDataDir?: string;
/**
* Launch options for the browser.
*/
launchOptions?: LaunchOptions;
/**
* Use screenshots instead of snapshots. Less accurate, reliable and overall
* slower, but contains visual representation of the page.
* @default false
*/
vision?: boolean;
/**
* The browser to use (e.g., 'chrome', 'chromium', 'firefox', 'webkit', 'msedge').
*/
browser?: string;
/**
* Path to a user data directory for browser profile persistence.
*/
userDataDir?: string;
/**
* Whether to run the browser in headless mode (default: true).
*/
headless?: boolean;
/**
* Path to a custom browser executable.
*/
executablePath?: string;
/**
* Chrome DevTools Protocol endpoint to connect to an existing browser instance.
*/
cdpEndpoint?: string;
/**
* Enable vision capabilities (e.g., visual automation or OCR).
*/
vision?: boolean;
/**
* List of enabled tool capabilities. Possible values:
* - 'core': Core browser automation features.
* - 'tabs': Tab management features.
* - 'pdf': PDF generation and manipulation.
* - 'history': Browser history access.
* - 'wait': Wait and timing utilities.
* - 'files': File upload/download support.
* - 'install': Browser installation utilities.
*/
capabilities?: ToolCapability[];
};
export function createServer(options?: Options): Server;
export declare function createServer(options?: Options): Promise<Server>;
export {};

14
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@playwright/mcp",
"version": "0.0.8",
"version": "0.0.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@playwright/mcp",
"version": "0.0.8",
"version": "0.0.14",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1",
@@ -377,6 +377,8 @@
},
"node_modules/@types/node": {
"version": "22.13.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
"integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1019,6 +1021,8 @@
},
"node_modules/commander": {
"version": "13.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
"integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -4188,6 +4192,8 @@
},
"node_modules/undici-types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"dev": true,
"license": "MIT"
},
@@ -4376,6 +4382,8 @@
},
"node_modules/zod": {
"version": "3.24.2",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",
"integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
@@ -4383,6 +4391,8 @@
},
"node_modules/zod-to-json-schema": {
"version": "3.24.4",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.4.tgz",
"integrity": "sha512-0uNlcvgabyrni9Ag8Vghj21drk7+7tp7VTwwR7KxxXXc/3pbXz2PHlDgj3cICahgF1kHm4dExBFj7BXrZJXzig==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.24.1"

View File

@@ -1,6 +1,6 @@
{
"name": "@playwright/mcp",
"version": "0.0.8",
"version": "0.0.14",
"description": "Playwright Tools for MCP",
"repository": {
"type": "git",
@@ -17,8 +17,12 @@
"scripts": {
"build": "tsc",
"lint": "eslint .",
"update-readme": "node utils/update-readme.js",
"watch": "tsc --watch",
"test": "playwright test",
"ctest": "playwright test --project=chrome",
"ftest": "playwright test --project=firefox",
"wtest": "playwright test --project=webkit",
"clean": "rm -rf lib",
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
},

View File

@@ -16,6 +16,8 @@
import { defineConfig } from '@playwright/test';
import type { Project } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
@@ -23,5 +25,11 @@ export default defineConfig({
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'list',
projects: [{ name: 'default' }],
projects: [
{ name: 'chrome' },
{ name: 'msedge', use: { mcpBrowser: 'msedge' } },
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
].filter(Boolean) as Project[],
});

View File

@@ -14,12 +14,15 @@
* limitations under the License.
*/
import { fork } from 'child_process';
import path from 'path';
import * as playwright from 'playwright';
import yaml from 'yaml';
import { waitForCompletion } from './tools/utils';
import { ManualPromise } from './manualPromise';
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
import type { ModalState, Tool, ToolActionResult } from './tools/tool';
export type ContextOptions = {
browserName?: 'chromium' | 'firefox' | 'webkit';
userDataDir: string;
@@ -28,147 +31,376 @@ export type ContextOptions = {
remoteEndpoint?: string;
};
type PageOrFrameLocator = playwright.Page | playwright.FrameLocator;
type PendingAction = {
dialogShown: ManualPromise<void>;
};
export class Context {
private _options: ContextOptions;
readonly tools: Tool[];
readonly options: ContextOptions;
private _browser: playwright.Browser | undefined;
private _page: playwright.Page | undefined;
private _console: playwright.ConsoleMessage[] = [];
private _createPagePromise: Promise<playwright.Page> | undefined;
private _fileChooser: playwright.FileChooser | undefined;
private _lastSnapshotFrames: (playwright.Page | playwright.FrameLocator)[] = [];
private _browserContext: playwright.BrowserContext | undefined;
private _tabs: Tab[] = [];
private _currentTab: Tab | undefined;
private _modalStates: (ModalState & { tab: Tab })[] = [];
private _pendingAction: PendingAction | undefined;
constructor(options: ContextOptions) {
this._options = options;
constructor(tools: Tool[], options: ContextOptions) {
this.tools = tools;
this.options = options;
}
async createPage(): Promise<playwright.Page> {
if (this._createPagePromise)
return this._createPagePromise;
this._createPagePromise = (async () => {
const { browser, page } = await this._createPage();
page.on('console', event => this._console.push(event));
page.on('framenavigated', frame => {
if (!frame.parentFrame())
this._console.length = 0;
});
page.on('close', () => this._onPageClose());
page.on('filechooser', chooser => this._fileChooser = chooser);
page.setDefaultNavigationTimeout(60000);
page.setDefaultTimeout(5000);
this._page = page;
this._browser = browser;
return page;
})();
return this._createPagePromise;
modalStates(): ModalState[] {
return this._modalStates;
}
private _onPageClose() {
const browser = this._browser;
const page = this._page;
void page?.context()?.close().then(() => browser?.close()).catch(() => {});
this._createPagePromise = undefined;
this._browser = undefined;
this._page = undefined;
this._fileChooser = undefined;
this._console.length = 0;
setModalState(modalState: ModalState, inTab: Tab) {
this._modalStates.push({ ...modalState, tab: inTab });
}
async install(): Promise<string> {
const channel = this._options.launchOptions?.channel ?? this._options.browserName ?? 'chrome';
const cli = path.join(require.resolve('playwright/package.json'), '..', 'cli.js');
const child = fork(cli, ['install', channel], {
stdio: 'pipe',
});
const output: string[] = [];
child.stdout?.on('data', data => output.push(data.toString()));
child.stderr?.on('data', data => output.push(data.toString()));
return new Promise((resolve, reject) => {
child.on('close', code => {
if (code === 0)
resolve(channel);
else
reject(new Error(`Failed to install browser: ${output.join('')}`));
});
});
clearModalState(modalState: ModalState) {
this._modalStates = this._modalStates.filter(state => state !== modalState);
}
existingPage(): playwright.Page {
if (!this._page)
throw new Error('Navigate to a location to create a page');
return this._page;
modalStatesMarkdown(): string[] {
const result: string[] = ['### Modal state'];
for (const state of this._modalStates) {
const tool = this.tools.find(tool => tool.clearsModalState === state.type);
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
}
return result;
}
async console(): Promise<playwright.ConsoleMessage[]> {
return this._console;
tabs(): Tab[] {
return this._tabs;
}
currentTabOrDie(): Tab {
if (!this._currentTab)
throw new Error('No current snapshot available. Capture a snapshot of navigate to a new location first.');
return this._currentTab;
}
async newTab(): Promise<Tab> {
const browserContext = await this._ensureBrowserContext();
const page = await browserContext.newPage();
this._currentTab = this._tabs.find(t => t.page === page)!;
return this._currentTab;
}
async selectTab(index: number) {
this._currentTab = this._tabs[index - 1];
await this._currentTab.page.bringToFront();
}
async ensureTab(): Promise<Tab> {
const context = await this._ensureBrowserContext();
if (!this._currentTab)
await context.newPage();
return this._currentTab!;
}
async listTabsMarkdown(): Promise<string> {
if (!this._tabs.length)
return '### No tabs open';
const lines: string[] = ['### Open tabs'];
for (let i = 0; i < this._tabs.length; i++) {
const tab = this._tabs[i];
const title = await tab.page.title();
const url = tab.page.url();
const current = tab === this._currentTab ? ' (current)' : '';
lines.push(`- ${i + 1}:${current} [${title}] (${url})`);
}
return lines.join('\n');
}
async closeTab(index: number | undefined) {
const tab = index === undefined ? this._currentTab : this._tabs[index - 1];
await tab?.page.close();
return await this.listTabsMarkdown();
}
async run(tool: Tool, params: Record<string, unknown> | undefined) {
// Tab management is done outside of the action() call.
const toolResult = await tool.handle(this, params);
const { code, action, waitForNetwork, captureSnapshot, resultOverride } = toolResult;
const racingAction = action ? () => this._raceAgainstModalDialogs(action) : undefined;
if (resultOverride)
return resultOverride;
if (!this._currentTab) {
return {
content: [{
type: 'text',
text: 'No open pages available. Use the "browser_navigate" tool to navigate to a page first.',
}],
};
}
const tab = this.currentTabOrDie();
// TODO: race against modal dialogs to resolve clicks.
let actionResult: { content?: (ImageContent | TextContent)[] } | undefined;
try {
if (waitForNetwork)
actionResult = await waitForCompletion(this, tab.page, async () => racingAction?.()) ?? undefined;
else
actionResult = await racingAction?.() ?? undefined;
} finally {
if (captureSnapshot && !this._javaScriptBlocked())
await tab.captureSnapshot();
}
const result: string[] = [];
result.push(`- Ran Playwright code:
\`\`\`js
${code.join('\n')}
\`\`\`
`);
if (this.modalStates().length) {
result.push(...this.modalStatesMarkdown());
return {
content: [{
type: 'text',
text: result.join('\n'),
}],
};
}
if (this.tabs().length > 1)
result.push(await this.listTabsMarkdown(), '');
if (this.tabs().length > 1)
result.push('### Current tab');
result.push(
`- Page URL: ${tab.page.url()}`,
`- Page Title: ${await tab.page.title()}`
);
if (captureSnapshot && tab.hasSnapshot())
result.push(tab.snapshotOrDie().text());
const content = actionResult?.content ?? [];
return {
content: [
...content,
{
type: 'text',
text: result.join('\n'),
}
],
};
}
async waitForTimeout(time: number) {
if (this._currentTab && !this._javaScriptBlocked())
await this._currentTab.page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
else
await new Promise(f => setTimeout(f, time));
}
private async _raceAgainstModalDialogs(action: () => Promise<ToolActionResult>): Promise<ToolActionResult> {
this._pendingAction = {
dialogShown: new ManualPromise(),
};
let result: ToolActionResult | undefined;
try {
await Promise.race([
action().then(r => result = r),
this._pendingAction.dialogShown,
]);
} finally {
this._pendingAction = undefined;
}
return result;
}
private _javaScriptBlocked(): boolean {
return this._modalStates.some(state => state.type === 'dialog');
}
dialogShown(tab: Tab, dialog: playwright.Dialog) {
this.setModalState({
type: 'dialog',
description: `"${dialog.type()}" dialog with message "${dialog.message()}"`,
dialog,
}, tab);
this._pendingAction?.dialogShown.resolve();
}
private _onPageCreated(page: playwright.Page) {
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
this._tabs.push(tab);
if (!this._currentTab)
this._currentTab = tab;
}
private _onPageClosed(tab: Tab) {
this._modalStates = this._modalStates.filter(state => state.tab !== tab);
const index = this._tabs.indexOf(tab);
if (index === -1)
return;
this._tabs.splice(index, 1);
if (this._currentTab === tab)
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
if (this._browserContext && !this._tabs.length)
void this.close();
}
async close() {
if (!this._page)
if (!this._browserContext)
return;
await this._page.close();
const browserContext = this._browserContext;
const browser = this._browser;
this._browserContext = undefined;
this._browser = undefined;
await browserContext?.close().then(async () => {
await browser?.close();
}).catch(() => {});
}
async submitFileChooser(paths: string[]) {
if (!this._fileChooser)
throw new Error('No file chooser visible');
await this._fileChooser.setFiles(paths);
this._fileChooser = undefined;
private async _ensureBrowserContext() {
if (!this._browserContext) {
const context = await this._createBrowserContext();
this._browser = context.browser;
this._browserContext = context.browserContext;
for (const page of this._browserContext.pages())
this._onPageCreated(page);
this._browserContext.on('page', page => this._onPageCreated(page));
}
return this._browserContext;
}
hasFileChooser() {
return !!this._fileChooser;
}
clearFileChooser() {
this._fileChooser = undefined;
}
private async _createPage(): Promise<{ browser?: playwright.Browser, page: playwright.Page }> {
if (this._options.remoteEndpoint) {
const url = new URL(this._options.remoteEndpoint);
if (this._options.browserName)
url.searchParams.set('browser', this._options.browserName);
if (this._options.launchOptions)
url.searchParams.set('launch-options', JSON.stringify(this._options.launchOptions));
const browser = await playwright[this._options.browserName ?? 'chromium'].connect(String(url));
const page = await browser.newPage();
return { browser, page };
private async _createBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
if (this.options.remoteEndpoint) {
const url = new URL(this.options.remoteEndpoint);
if (this.options.browserName)
url.searchParams.set('browser', this.options.browserName);
if (this.options.launchOptions)
url.searchParams.set('launch-options', JSON.stringify(this.options.launchOptions));
const browser = await playwright[this.options.browserName ?? 'chromium'].connect(String(url));
const browserContext = await browser.newContext();
return { browser, browserContext };
}
if (this._options.cdpEndpoint) {
const browser = await playwright.chromium.connectOverCDP(this._options.cdpEndpoint);
if (this.options.cdpEndpoint) {
const browser = await playwright.chromium.connectOverCDP(this.options.cdpEndpoint);
const browserContext = browser.contexts()[0];
let [page] = browserContext.pages();
if (!page)
page = await browserContext.newPage();
return { browser, page };
return { browser, browserContext };
}
const context = await this._launchPersistentContext();
const [page] = context.pages();
return { page };
const browserContext = await this._launchPersistentContext();
return { browserContext };
}
private async _launchPersistentContext(): Promise<playwright.BrowserContext> {
try {
const browserType = this._options.browserName ? playwright[this._options.browserName] : playwright.chromium;
return await browserType.launchPersistentContext(this._options.userDataDir, this._options.launchOptions);
const browserType = this.options.browserName ? playwright[this.options.browserName] : playwright.chromium;
return await browserType.launchPersistentContext(this.options.userDataDir, this.options.launchOptions);
} catch (error: any) {
if (error.message.includes('Executable doesn\'t exist'))
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
throw error;
}
}
}
async allFramesSnapshot() {
this._lastSnapshotFrames = [];
const yaml = await this._allFramesSnapshot(this.existingPage());
return yaml.toString().trim();
export class Tab {
readonly context: Context;
readonly page: playwright.Page;
private _console: playwright.ConsoleMessage[] = [];
private _snapshot: PageSnapshot | undefined;
private _onPageClose: (tab: Tab) => void;
constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) {
this.context = context;
this.page = page;
this._onPageClose = onPageClose;
page.on('console', event => this._console.push(event));
page.on('framenavigated', frame => {
if (!frame.parentFrame())
this._console.length = 0;
});
page.on('close', () => this._onClose());
page.on('filechooser', chooser => {
this.context.setModalState({
type: 'fileChooser',
description: 'File chooser',
fileChooser: chooser,
}, this);
});
page.on('dialog', dialog => this.context.dialogShown(this, dialog));
page.setDefaultNavigationTimeout(60000);
page.setDefaultTimeout(5000);
}
private async _allFramesSnapshot(frame: playwright.Page | playwright.FrameLocator): Promise<yaml.Document> {
const frameIndex = this._lastSnapshotFrames.push(frame) - 1;
private _onClose() {
this._console.length = 0;
this._onPageClose(this);
}
async navigate(url: string) {
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
// Cap load event to 5 seconds, the page is operational at this point.
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
}
hasSnapshot(): boolean {
return !!this._snapshot;
}
snapshotOrDie(): PageSnapshot {
if (!this._snapshot)
throw new Error('No snapshot available');
return this._snapshot;
}
async console(): Promise<playwright.ConsoleMessage[]> {
return this._console;
}
async captureSnapshot() {
this._snapshot = await PageSnapshot.create(this.page);
}
}
class PageSnapshot {
private _frameLocators: PageOrFrameLocator[] = [];
private _text!: string;
constructor() {
}
static async create(page: playwright.Page): Promise<PageSnapshot> {
const snapshot = new PageSnapshot();
await snapshot._build(page);
return snapshot;
}
text(): string {
return this._text;
}
private async _build(page: playwright.Page) {
const yamlDocument = await this._snapshotFrame(page);
this._text = [
`- Page Snapshot`,
'```yaml',
yamlDocument.toString({ indentSeq: false }).trim(),
'```',
].join('\n');
}
private async _snapshotFrame(frame: playwright.Page | playwright.FrameLocator) {
const frameIndex = this._frameLocators.push(frame) - 1;
const snapshotString = await frame.locator('body').ariaSnapshot({ ref: true });
const snapshot = yaml.parseDocument(snapshotString);
@@ -189,7 +421,7 @@ export class Context {
const ref = value.match(/\[ref=(.*)\]/)?.[1];
if (ref) {
try {
const childSnapshot = await this._allFramesSnapshot(frame.frameLocator(`aria-ref=${ref}`));
const childSnapshot = await this._snapshotFrame(frame.frameLocator(`aria-ref=${ref}`));
return snapshot.createPair(node.value, childSnapshot);
} catch (error) {
return snapshot.createPair(node.value, '<could not take iframe snapshot>');
@@ -206,11 +438,11 @@ export class Context {
}
refLocator(ref: string): playwright.Locator {
let frame = this._lastSnapshotFrames[0];
let frame = this._frameLocators[0];
const match = ref.match(/^f(\d+)(.*)/);
if (match) {
const frameIndex = parseInt(match[1], 10);
frame = this._lastSnapshotFrames[frameIndex];
frame = this._frameLocators[frameIndex];
ref = match[2];
}
@@ -220,3 +452,7 @@ export class Context {
return frame.locator(`aria-ref=${ref}`);
}
}
export async function generateLocator(locator: playwright.Locator): Promise<string> {
return (locator as any)._generateLocatorString();
}

View File

@@ -14,76 +14,126 @@
* limitations under the License.
*/
import { createServerWithTools } from './server';
import * as snapshot from './tools/snapshot';
import * as common from './tools/common';
import * as screenshot from './tools/screenshot';
import { console } from './resources/console';
import path from 'path';
import os from 'os';
import fs from 'fs';
import type { Tool } from './tools/tool';
import type { Resource } from './resources/resource';
import { createServerWithTools } from './server';
import common from './tools/common';
import console from './tools/console';
import dialogs from './tools/dialogs';
import files from './tools/files';
import install from './tools/install';
import keyboard from './tools/keyboard';
import navigate from './tools/navigate';
import pdf from './tools/pdf';
import snapshot from './tools/snapshot';
import tabs from './tools/tabs';
import screen from './tools/screen';
import type { Tool, ToolCapability } from './tools/tool';
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
import type { LaunchOptions } from 'playwright';
const commonTools: Tool[] = [
common.pressKey,
common.wait,
common.pdf,
common.close,
common.install,
];
const snapshotTools: Tool[] = [
common.navigate(true),
common.goBack(true),
common.goForward(true),
common.chooseFile(true),
snapshot.snapshot,
snapshot.click,
snapshot.hover,
snapshot.type,
snapshot.selectOption,
snapshot.screenshot,
...commonTools,
...common(true),
...console,
...dialogs(true),
...files(true),
...install,
...keyboard(true),
...navigate(true),
...pdf,
...snapshot,
...tabs(true),
];
const screenshotTools: Tool[] = [
common.navigate(false),
common.goBack(false),
common.goForward(false),
common.chooseFile(false),
screenshot.screenshot,
screenshot.moveMouse,
screenshot.click,
screenshot.drag,
screenshot.type,
...commonTools,
];
const resources: Resource[] = [
console,
...common(false),
...console,
...dialogs(false),
...files(false),
...install,
...keyboard(false),
...navigate(false),
...pdf,
...screen,
...tabs(false),
];
type Options = {
browserName?: 'chromium' | 'firefox' | 'webkit';
browser?: string;
userDataDir?: string;
launchOptions?: LaunchOptions;
headless?: boolean;
executablePath?: string;
cdpEndpoint?: string;
vision?: boolean;
capabilities?: ToolCapability[];
};
const packageJSON = require('../package.json');
export function createServer(options?: Options): Server {
const tools = options?.vision ? screenshotTools : snapshotTools;
export async function createServer(options?: Options): Promise<Server> {
let browserName: 'chromium' | 'firefox' | 'webkit';
let channel: string | undefined;
switch (options?.browser) {
case 'chrome':
case 'chrome-beta':
case 'chrome-canary':
case 'chrome-dev':
case 'msedge':
case 'msedge-beta':
case 'msedge-canary':
case 'msedge-dev':
browserName = 'chromium';
channel = options.browser;
break;
case 'chromium':
browserName = 'chromium';
break;
case 'firefox':
browserName = 'firefox';
break;
case 'webkit':
browserName = 'webkit';
break;
default:
browserName = 'chromium';
channel = 'chrome';
}
const userDataDir = options?.userDataDir ?? await createUserDataDir(browserName);
const launchOptions: LaunchOptions = {
headless: !!(options?.headless ?? (os.platform() === 'linux' && !process.env.DISPLAY)),
channel,
executablePath: options?.executablePath,
};
const allTools = options?.vision ? screenshotTools : snapshotTools;
const tools = allTools.filter(tool => !options?.capabilities || tool.capability === 'core' || options.capabilities.includes(tool.capability));
return createServerWithTools({
name: 'Playwright',
version: packageJSON.version,
tools,
resources,
browserName: options?.browserName,
userDataDir: options?.userDataDir ?? '',
launchOptions: options?.launchOptions,
resources: [],
browserName,
userDataDir,
launchOptions,
cdpEndpoint: options?.cdpEndpoint,
});
}
async function createUserDataDir(browserName: 'chromium' | 'firefox' | 'webkit') {
let cacheDirectory: string;
if (process.platform === 'linux')
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
else if (process.platform === 'darwin')
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
else if (process.platform === 'win32')
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
else
throw new Error('Unsupported platform: ' + process.platform);
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserName}-profile`);
await fs.promises.mkdir(result, { recursive: true });
return result;
}

53
src/javascript.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* 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.
*/
// adapted from:
// - https://github.com/microsoft/playwright/blob/76ee48dc9d4034536e3ec5b2c7ce8be3b79418a8/packages/playwright-core/src/utils/isomorphic/stringUtils.ts
// - https://github.com/microsoft/playwright/blob/76ee48dc9d4034536e3ec5b2c7ce8be3b79418a8/packages/playwright-core/src/server/codegen/javascript.ts
// NOTE: this function should not be used to escape any selectors.
export function escapeWithQuotes(text: string, char: string = '\'') {
const stringified = JSON.stringify(text);
const escapedText = stringified.substring(1, stringified.length - 1).replace(/\\"/g, '"');
if (char === '\'')
return char + escapedText.replace(/[']/g, '\\\'') + char;
if (char === '"')
return char + escapedText.replace(/["]/g, '\\"') + char;
if (char === '`')
return char + escapedText.replace(/[`]/g, '`') + char;
throw new Error('Invalid escape char');
}
export function quote(text: string) {
return escapeWithQuotes(text, '\'');
}
export function formatObject(value: any, indent = ' '): string {
if (typeof value === 'string')
return quote(value);
if (Array.isArray(value))
return `[${value.map(o => formatObject(o)).join(', ')}]`;
if (typeof value === 'object') {
const keys = Object.keys(value).filter(key => value[key] !== undefined).sort();
if (!keys.length)
return '{}';
const tokens: string[] = [];
for (const key of keys)
tokens.push(`${key}: ${formatObject(value[key])}`);
return `{\n${indent}${tokens.join(`,\n${indent}`)}\n}`;
}
return String(value);
}

127
src/manualPromise.ts Normal file
View File

@@ -0,0 +1,127 @@
/**
* 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.
*/
export class ManualPromise<T = void> extends Promise<T> {
private _resolve!: (t: T) => void;
private _reject!: (e: Error) => void;
private _isDone: boolean;
constructor() {
let resolve: (t: T) => void;
let reject: (e: Error) => void;
super((f, r) => {
resolve = f;
reject = r;
});
this._isDone = false;
this._resolve = resolve!;
this._reject = reject!;
}
isDone() {
return this._isDone;
}
resolve(t: T) {
this._isDone = true;
this._resolve(t);
}
reject(e: Error) {
this._isDone = true;
this._reject(e);
}
static override get [Symbol.species]() {
return Promise;
}
override get [Symbol.toStringTag]() {
return 'ManualPromise';
}
}
export class LongStandingScope {
private _terminateError: Error | undefined;
private _closeError: Error | undefined;
private _terminatePromises = new Map<ManualPromise<Error>, string[]>();
private _isClosed = false;
reject(error: Error) {
this._isClosed = true;
this._terminateError = error;
for (const p of this._terminatePromises.keys())
p.resolve(error);
}
close(error: Error) {
this._isClosed = true;
this._closeError = error;
for (const [p, frames] of this._terminatePromises)
p.resolve(cloneError(error, frames));
}
isClosed() {
return this._isClosed;
}
static async raceMultiple<T>(scopes: LongStandingScope[], promise: Promise<T>): Promise<T> {
return Promise.race(scopes.map(s => s.race(promise)));
}
async race<T>(promise: Promise<T> | Promise<T>[]): Promise<T> {
return this._race(Array.isArray(promise) ? promise : [promise], false) as Promise<T>;
}
async safeRace<T>(promise: Promise<T>, defaultValue?: T): Promise<T> {
return this._race([promise], true, defaultValue);
}
private async _race(promises: Promise<any>[], safe: boolean, defaultValue?: any): Promise<any> {
const terminatePromise = new ManualPromise<Error>();
const frames = captureRawStack();
if (this._terminateError)
terminatePromise.resolve(this._terminateError);
if (this._closeError)
terminatePromise.resolve(cloneError(this._closeError, frames));
this._terminatePromises.set(terminatePromise, frames);
try {
return await Promise.race([
terminatePromise.then(e => safe ? defaultValue : Promise.reject(e)),
...promises
]);
} finally {
this._terminatePromises.delete(terminatePromise);
}
}
}
function cloneError(error: Error, frames: string[]) {
const clone = new Error();
clone.name = error.name;
clone.message = error.message;
clone.stack = [error.name + ':' + error.message, ...frames].join('\n');
return clone;
}
function captureRawStack(): string[] {
const stackTraceLimit = Error.stackTraceLimit;
Error.stackTraceLimit = 50;
const error = new Error();
const stack = error.stack || '';
Error.stackTraceLimit = stackTraceLimit;
return stack.split('\n');
}

View File

@@ -15,9 +15,6 @@
*/
import http from 'http';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { program } from 'commander';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
@@ -27,8 +24,8 @@ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { createServer } from './index';
import { ServerList } from './server';
import type { LaunchOptions } from 'playwright';
import assert from 'assert';
import { ToolCapability } from './tools/tool';
const packageJSON = require('../package.json');
@@ -36,6 +33,7 @@ program
.version('Version ' + packageJSON.version)
.name(packageJSON.name)
.option('--browser <browser>', 'Browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
.option('--caps <caps>', 'Comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
.option('--executable-path <path>', 'Path to the browser executable.')
.option('--headless', 'Run browser in headless mode, headed by default')
@@ -43,48 +41,14 @@ program
.option('--user-data-dir <path>', 'Path to the user data directory')
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
.action(async options => {
let browserName: 'chromium' | 'firefox' | 'webkit';
let channel: string | undefined;
switch (options.browser) {
case 'chrome':
case 'chrome-beta':
case 'chrome-canary':
case 'chrome-dev':
case 'msedge':
case 'msedge-beta':
case 'msedge-canary':
case 'msedge-dev':
browserName = 'chromium';
channel = options.browser;
break;
case 'chromium':
browserName = 'chromium';
break;
case 'firefox':
browserName = 'firefox';
break;
case 'webkit':
browserName = 'webkit';
break;
default:
browserName = 'chromium';
channel = 'chrome';
}
const launchOptions: LaunchOptions = {
headless: !!options.headless,
channel,
executablePath: options.executablePath,
};
const userDataDir = options.userDataDir ?? await createUserDataDir(browserName);
const serverList = new ServerList(() => createServer({
browserName,
userDataDir,
launchOptions,
browser: options.browser,
userDataDir: options.userDataDir,
headless: options.headless,
executablePath: options.executablePath,
vision: !!options.vision,
cdpEndpoint: options.cdpEndpoint,
capabilities: options.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
}));
setupExitWatchdog(serverList);
@@ -97,30 +61,19 @@ program
});
function setupExitWatchdog(serverList: ServerList) {
process.stdin.on('close', async () => {
const handleExit = async () => {
setTimeout(() => process.exit(0), 15000);
await serverList.closeAll();
process.exit(0);
});
};
process.stdin.on('close', handleExit);
process.on('SIGINT', handleExit);
process.on('SIGTERM', handleExit);
}
program.parse(process.argv);
async function createUserDataDir(browserName: 'chromium' | 'firefox' | 'webkit') {
let cacheDirectory: string;
if (process.platform === 'linux')
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
else if (process.platform === 'darwin')
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
else if (process.platform === 'win32')
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
else
throw new Error('Unsupported platform: ' + process.platform);
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserName}-profile`);
await fs.promises.mkdir(result, { recursive: true });
return result;
}
async function startSSEServer(port: number, serverList: ServerList) {
const sessions = new Map<string, SSEServerTransport>();
const httpServer = http.createServer(async (req, res) => {

View File

@@ -32,7 +32,7 @@ type Options = ContextOptions & {
export function createServerWithTools(options: Options): Server {
const { name, version, tools, resources } = options;
const context = new Context(options);
const context = new Context(tools, options);
const server = new Server({ name, version }, {
capabilities: {
tools: {},
@@ -57,9 +57,21 @@ export function createServerWithTools(options: Options): Server {
};
}
const modalStates = context.modalStates().map(state => state.type);
if ((tool.clearsModalState && !modalStates.includes(tool.clearsModalState)) ||
(!tool.clearsModalState && modalStates.length)) {
const text = [
`Tool "${request.params.name}" does not handle the modal state.`,
...context.modalStatesMarkdown(),
].join('\n');
return {
content: [{ type: 'text', text }],
isError: true,
};
}
try {
const result = await tool.handle(context, request.params.arguments);
return result;
return await context.run(tool, request.params.arguments);
} catch (error) {
return {
content: [{ type: 'text', text: String(error) }],
@@ -89,14 +101,14 @@ export function createServerWithTools(options: Options): Server {
export class ServerList {
private _servers: Server[] = [];
private _serverFactory: () => Server;
private _serverFactory: () => Promise<Server>;
constructor(serverFactory: () => Server) {
constructor(serverFactory: () => Promise<Server>) {
this._serverFactory = serverFactory;
}
async create() {
const server = this._serverFactory();
const server = await this._serverFactory();
this._servers.push(server);
return server;
}

View File

@@ -14,180 +14,94 @@
* limitations under the License.
*/
import os from 'os';
import path from 'path';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { captureAriaSnapshot, runAndWait, sanitizeForFilePath } from './utils';
import type { ToolFactory, Tool } from './tool';
const navigateSchema = z.object({
url: z.string().describe('The URL to navigate to'),
});
export const navigate: ToolFactory = snapshot => ({
schema: {
name: 'browser_navigate',
description: 'Navigate to a URL',
inputSchema: zodToJsonSchema(navigateSchema),
},
handle: async (context, params) => {
const validatedParams = navigateSchema.parse(params);
const page = await context.createPage();
await page.goto(validatedParams.url, { waitUntil: 'domcontentloaded' });
// Cap load event to 5 seconds, the page is operational at this point.
await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
if (snapshot)
return captureAriaSnapshot(context);
return {
content: [{
type: 'text',
text: `Navigated to ${validatedParams.url}`,
}],
};
},
});
const goBackSchema = z.object({});
export const goBack: ToolFactory = snapshot => ({
schema: {
name: 'browser_go_back',
description: 'Go back to the previous page',
inputSchema: zodToJsonSchema(goBackSchema),
},
handle: async context => {
return await runAndWait(context, 'Navigated back', async page => page.goBack(), snapshot);
},
});
const goForwardSchema = z.object({});
export const goForward: ToolFactory = snapshot => ({
schema: {
name: 'browser_go_forward',
description: 'Go forward to the next page',
inputSchema: zodToJsonSchema(goForwardSchema),
},
handle: async context => {
return await runAndWait(context, 'Navigated forward', async page => page.goForward(), snapshot);
},
});
import type { Tool, ToolFactory } from './tool';
const waitSchema = z.object({
time: z.number().describe('The time to wait in seconds'),
});
export const wait: Tool = {
const wait: ToolFactory = captureSnapshot => ({
capability: 'wait',
schema: {
name: 'browser_wait',
description: 'Wait for a specified time in seconds',
inputSchema: zodToJsonSchema(waitSchema),
},
handle: async (context, params) => {
const validatedParams = waitSchema.parse(params);
await new Promise(f => setTimeout(f, Math.min(10000, validatedParams.time * 1000)));
return {
content: [{
type: 'text',
text: `Waited for ${validatedParams.time} seconds`,
}],
code: [`// Waited for ${validatedParams.time} seconds`],
captureSnapshot,
waitForNetwork: false,
};
},
};
const pressKeySchema = z.object({
key: z.string().describe('Name of the key to press or a character to generate, such as `ArrowLeft` or `a`'),
});
export const pressKey: Tool = {
schema: {
name: 'browser_press_key',
description: 'Press a key on the keyboard',
inputSchema: zodToJsonSchema(pressKeySchema),
},
handle: async (context, params) => {
const validatedParams = pressKeySchema.parse(params);
return await runAndWait(context, `Pressed key ${validatedParams.key}`, async page => {
await page.keyboard.press(validatedParams.key);
});
},
};
const pdfSchema = z.object({});
export const pdf: Tool = {
schema: {
name: 'browser_save_as_pdf',
description: 'Save page as PDF',
inputSchema: zodToJsonSchema(pdfSchema),
},
handle: async context => {
const page = context.existingPage();
const fileName = path.join(os.tmpdir(), sanitizeForFilePath(`page-${new Date().toISOString()}`)) + '.pdf';
await page.pdf({ path: fileName });
return {
content: [{
type: 'text',
text: `Saved as ${fileName}`,
}],
};
},
};
const closeSchema = z.object({});
export const close: Tool = {
const close: Tool = {
capability: 'core',
schema: {
name: 'browser_close',
description: 'Close the page',
inputSchema: zodToJsonSchema(closeSchema),
},
handle: async context => {
await context.close();
return {
content: [{
type: 'text',
text: `Page closed`,
}],
code: [`// Internal to close the page`],
captureSnapshot: false,
waitForNetwork: false,
};
},
};
const chooseFileSchema = z.object({
paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'),
const resizeSchema = z.object({
width: z.number().describe('Width of the browser window'),
height: z.number().describe('Height of the browser window'),
});
export const chooseFile: ToolFactory = snapshot => ({
const resize: ToolFactory = captureSnapshot => ({
capability: 'core',
schema: {
name: 'browser_choose_file',
description: 'Choose one or multiple files to upload',
inputSchema: zodToJsonSchema(chooseFileSchema),
name: 'browser_resize',
description: 'Resize the browser window',
inputSchema: zodToJsonSchema(resizeSchema),
},
handle: async (context, params) => {
const validatedParams = chooseFileSchema.parse(params);
return await runAndWait(context, `Chose files ${validatedParams.paths.join(', ')}`, async () => {
await context.submitFileChooser(validatedParams.paths);
}, snapshot);
const validatedParams = resizeSchema.parse(params);
const tab = context.currentTabOrDie();
const code = [
`// Resize browser window to ${validatedParams.width}x${validatedParams.height}`,
`await page.setViewportSize({ width: ${validatedParams.width}, height: ${validatedParams.height} });`
];
const action = async () => {
await tab.page.setViewportSize({ width: validatedParams.width, height: validatedParams.height });
};
return {
code,
action,
captureSnapshot,
waitForNetwork: true
};
},
});
export const install: Tool = {
schema: {
name: 'browser_install',
description: 'Install the browser specified in the config. Call this if you get an error about the browser not being installed.',
inputSchema: zodToJsonSchema(z.object({})),
},
handle: async context => {
const channel = await context.install();
return {
content: [{
type: 'text',
text: `Browser ${channel} installed`,
}],
};
},
};
export default (captureSnapshot: boolean) => [
close,
wait(captureSnapshot),
resize(captureSnapshot)
];

49
src/tools/console.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* 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 { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import type { Tool } from './tool';
const consoleSchema = z.object({});
const console: Tool = {
capability: 'core',
schema: {
name: 'browser_console_messages',
description: 'Returns all console messages',
inputSchema: zodToJsonSchema(consoleSchema),
},
handle: async context => {
const messages = await context.currentTabOrDie().console();
const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n');
return {
code: [`// <internal code to get console messages>`],
action: async () => {
return {
content: [{ type: 'text', text: log }]
};
},
captureSnapshot: false,
waitForNetwork: false,
};
},
};
export default [
console,
];

65
src/tools/dialogs.ts Normal file
View File

@@ -0,0 +1,65 @@
/**
* 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 { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import type { ToolFactory } from './tool';
const handleDialogSchema = z.object({
accept: z.boolean().describe('Whether to accept the dialog.'),
promptText: z.string().optional().describe('The text of the prompt in case of a prompt dialog.'),
});
const handleDialog: ToolFactory = captureSnapshot => ({
capability: 'core',
schema: {
name: 'browser_handle_dialog',
description: 'Handle a dialog',
inputSchema: zodToJsonSchema(handleDialogSchema),
},
handle: async (context, params) => {
const validatedParams = handleDialogSchema.parse(params);
const dialogState = context.modalStates().find(state => state.type === 'dialog');
if (!dialogState)
throw new Error('No dialog visible');
if (validatedParams.accept)
await dialogState.dialog.accept(validatedParams.promptText);
else
await dialogState.dialog.dismiss();
context.clearModalState(dialogState);
const code = [
`// <internal code to handle "${dialogState.dialog.type()}" dialog>`,
];
return {
code,
captureSnapshot,
waitForNetwork: false,
};
},
clearsModalState: 'dialog',
});
export default (captureSnapshot: boolean) => [
handleDialog(captureSnapshot),
];

62
src/tools/files.ts Normal file
View File

@@ -0,0 +1,62 @@
/**
* 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 { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import type { ToolFactory } from './tool';
const uploadFileSchema = z.object({
paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'),
});
const uploadFile: ToolFactory = captureSnapshot => ({
capability: 'files',
schema: {
name: 'browser_file_upload',
description: 'Upload one or multiple files',
inputSchema: zodToJsonSchema(uploadFileSchema),
},
handle: async (context, params) => {
const validatedParams = uploadFileSchema.parse(params);
const modalState = context.modalStates().find(state => state.type === 'fileChooser');
if (!modalState)
throw new Error('No file chooser visible');
const code = [
`// <internal code to chose files ${validatedParams.paths.join(', ')}`,
];
const action = async () => {
await modalState.fileChooser.setFiles(validatedParams.paths);
context.clearModalState(modalState);
};
return {
code,
action,
captureSnapshot,
waitForNetwork: true,
};
},
clearsModalState: 'fileChooser',
});
export default (captureSnapshot: boolean) => [
uploadFile(captureSnapshot),
];

60
src/tools/install.ts Normal file
View File

@@ -0,0 +1,60 @@
/**
* 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 { fork } from 'child_process';
import path from 'path';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import type { Tool } from './tool';
const install: Tool = {
capability: 'install',
schema: {
name: 'browser_install',
description: 'Install the browser specified in the config. Call this if you get an error about the browser not being installed.',
inputSchema: zodToJsonSchema(z.object({})),
},
handle: async context => {
const channel = context.options.launchOptions?.channel ?? context.options.browserName ?? 'chrome';
const cli = path.join(require.resolve('playwright/package.json'), '..', 'cli.js');
const child = fork(cli, ['install', channel], {
stdio: 'pipe',
});
const output: string[] = [];
child.stdout?.on('data', data => output.push(data.toString()));
child.stderr?.on('data', data => output.push(data.toString()));
await new Promise<void>((resolve, reject) => {
child.on('close', code => {
if (code === 0)
resolve();
else
reject(new Error(`Failed to install browser: ${output.join('')}`));
});
});
return {
code: [`// Browser ${channel} installed`],
captureSnapshot: false,
waitForNetwork: false,
};
},
};
export default [
install,
];

57
src/tools/keyboard.ts Normal file
View File

@@ -0,0 +1,57 @@
/**
* 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 { z } from 'zod';
import zodToJsonSchema from 'zod-to-json-schema';
import type { ToolFactory } from './tool';
const pressKeySchema = z.object({
key: z.string().describe('Name of the key to press or a character to generate, such as `ArrowLeft` or `a`'),
});
const pressKey: ToolFactory = captureSnapshot => ({
capability: 'core',
schema: {
name: 'browser_press_key',
description: 'Press a key on the keyboard',
inputSchema: zodToJsonSchema(pressKeySchema),
},
handle: async (context, params) => {
const validatedParams = pressKeySchema.parse(params);
const tab = context.currentTabOrDie();
const code = [
`// Press ${validatedParams.key}`,
`await page.keyboard.press('${validatedParams.key}');`,
];
const action = () => tab.page.keyboard.press(validatedParams.key);
return {
code,
action,
captureSnapshot,
waitForNetwork: true
};
},
});
export default (captureSnapshot: boolean) => [
pressKey(captureSnapshot),
];

107
src/tools/navigate.ts Normal file
View File

@@ -0,0 +1,107 @@
/**
* 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 { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import type { ToolFactory } from './tool';
const navigateSchema = z.object({
url: z.string().describe('The URL to navigate to'),
});
const navigate: ToolFactory = captureSnapshot => ({
capability: 'core',
schema: {
name: 'browser_navigate',
description: 'Navigate to a URL',
inputSchema: zodToJsonSchema(navigateSchema),
},
handle: async (context, params) => {
const validatedParams = navigateSchema.parse(params);
const tab = await context.ensureTab();
await tab.navigate(validatedParams.url);
const code = [
`// Navigate to ${validatedParams.url}`,
`await page.goto('${validatedParams.url}');`,
];
return {
code,
captureSnapshot,
waitForNetwork: false,
};
},
});
const goBackSchema = z.object({});
const goBack: ToolFactory = captureSnapshot => ({
capability: 'history',
schema: {
name: 'browser_navigate_back',
description: 'Go back to the previous page',
inputSchema: zodToJsonSchema(goBackSchema),
},
handle: async context => {
const tab = await context.ensureTab();
await tab.page.goBack();
const code = [
`// Navigate back`,
`await page.goBack();`,
];
return {
code,
captureSnapshot,
waitForNetwork: false,
};
},
});
const goForwardSchema = z.object({});
const goForward: ToolFactory = captureSnapshot => ({
capability: 'history',
schema: {
name: 'browser_navigate_forward',
description: 'Go forward to the next page',
inputSchema: zodToJsonSchema(goForwardSchema),
},
handle: async context => {
const tab = context.currentTabOrDie();
await tab.page.goForward();
const code = [
`// Navigate forward`,
`await page.goForward();`,
];
return {
code,
captureSnapshot,
waitForNetwork: false,
};
},
});
export default (captureSnapshot: boolean) => [
navigate(captureSnapshot),
goBack(captureSnapshot),
goForward(captureSnapshot),
];

59
src/tools/pdf.ts Normal file
View File

@@ -0,0 +1,59 @@
/**
* 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 os from 'os';
import path from 'path';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { sanitizeForFilePath } from './utils';
import * as javascript from '../javascript';
import type { Tool } from './tool';
const pdfSchema = z.object({});
const pdf: Tool = {
capability: 'pdf',
schema: {
name: 'browser_pdf_save',
description: 'Save page as PDF',
inputSchema: zodToJsonSchema(pdfSchema),
},
handle: async context => {
const tab = context.currentTabOrDie();
const fileName = path.join(os.tmpdir(), sanitizeForFilePath(`page-${new Date().toISOString()}`)) + '.pdf';
const code = [
`// Save page as ${fileName}`,
`await page.pdf(${javascript.formatObject({ path: fileName })});`,
];
return {
code,
action: async () => tab.page.pdf({ path: fileName }).then(() => {}),
captureSnapshot: false,
waitForNetwork: false,
};
},
};
export default [
pdf,
];

219
src/tools/screen.ts Normal file
View File

@@ -0,0 +1,219 @@
/**
* 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 { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import * as javascript from '../javascript';
import type { Tool } from './tool';
const screenshot: Tool = {
capability: 'core',
schema: {
name: 'browser_screen_capture',
description: 'Take a screenshot of the current page',
inputSchema: zodToJsonSchema(z.object({})),
},
handle: async context => {
const tab = await context.ensureTab();
const options = { type: 'jpeg' as 'jpeg', quality: 50, scale: 'css' as 'css' };
const code = [
`// Take a screenshot of the current page`,
`await page.screenshot(${javascript.formatObject(options)});`,
];
const action = () => tab.page.screenshot(options).then(buffer => {
return {
content: [{ type: 'image' as 'image', data: buffer.toString('base64'), mimeType: 'image/jpeg' }],
};
});
return {
code,
action,
captureSnapshot: false,
waitForNetwork: false
};
},
};
const elementSchema = z.object({
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
});
const moveMouseSchema = elementSchema.extend({
x: z.number().describe('X coordinate'),
y: z.number().describe('Y coordinate'),
});
const moveMouse: Tool = {
capability: 'core',
schema: {
name: 'browser_screen_move_mouse',
description: 'Move mouse to a given position',
inputSchema: zodToJsonSchema(moveMouseSchema),
},
handle: async (context, params) => {
const validatedParams = moveMouseSchema.parse(params);
const tab = context.currentTabOrDie();
const code = [
`// Move mouse to (${validatedParams.x}, ${validatedParams.y})`,
`await page.mouse.move(${validatedParams.x}, ${validatedParams.y});`,
];
const action = () => tab.page.mouse.move(validatedParams.x, validatedParams.y);
return {
code,
action,
captureSnapshot: false,
waitForNetwork: false
};
},
};
const clickSchema = elementSchema.extend({
x: z.number().describe('X coordinate'),
y: z.number().describe('Y coordinate'),
});
const click: Tool = {
capability: 'core',
schema: {
name: 'browser_screen_click',
description: 'Click left mouse button',
inputSchema: zodToJsonSchema(clickSchema),
},
handle: async (context, params) => {
const validatedParams = clickSchema.parse(params);
const tab = context.currentTabOrDie();
const code = [
`// Click mouse at coordinates (${validatedParams.x}, ${validatedParams.y})`,
`await page.mouse.move(${validatedParams.x}, ${validatedParams.y});`,
`await page.mouse.down();`,
`await page.mouse.up();`,
];
const action = async () => {
await tab.page.mouse.move(validatedParams.x, validatedParams.y);
await tab.page.mouse.down();
await tab.page.mouse.up();
};
return {
code,
action,
captureSnapshot: false,
waitForNetwork: true,
};
},
};
const dragSchema = elementSchema.extend({
startX: z.number().describe('Start X coordinate'),
startY: z.number().describe('Start Y coordinate'),
endX: z.number().describe('End X coordinate'),
endY: z.number().describe('End Y coordinate'),
});
const drag: Tool = {
capability: 'core',
schema: {
name: 'browser_screen_drag',
description: 'Drag left mouse button',
inputSchema: zodToJsonSchema(dragSchema),
},
handle: async (context, params) => {
const validatedParams = dragSchema.parse(params);
const tab = context.currentTabOrDie();
const code = [
`// Drag mouse from (${validatedParams.startX}, ${validatedParams.startY}) to (${validatedParams.endX}, ${validatedParams.endY})`,
`await page.mouse.move(${validatedParams.startX}, ${validatedParams.startY});`,
`await page.mouse.down();`,
`await page.mouse.move(${validatedParams.endX}, ${validatedParams.endY});`,
`await page.mouse.up();`,
];
const action = async () => {
await tab.page.mouse.move(validatedParams.startX, validatedParams.startY);
await tab.page.mouse.down();
await tab.page.mouse.move(validatedParams.endX, validatedParams.endY);
await tab.page.mouse.up();
};
return {
code,
action,
captureSnapshot: false,
waitForNetwork: true,
};
},
};
const typeSchema = z.object({
text: z.string().describe('Text to type into the element'),
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
});
const type: Tool = {
capability: 'core',
schema: {
name: 'browser_screen_type',
description: 'Type text',
inputSchema: zodToJsonSchema(typeSchema),
},
handle: async (context, params) => {
const validatedParams = typeSchema.parse(params);
const tab = context.currentTabOrDie();
const code = [
`// Type ${validatedParams.text}`,
`await page.keyboard.type('${validatedParams.text}');`,
];
const action = async () => {
await tab.page.keyboard.type(validatedParams.text);
if (validatedParams.submit)
await tab.page.keyboard.press('Enter');
};
if (validatedParams.submit) {
code.push(`// Submit text`);
code.push(`await page.keyboard.press('Enter');`);
}
return {
code,
action,
captureSnapshot: false,
waitForNetwork: true,
};
},
};
export default [
screenshot,
moveMouse,
click,
drag,
type,
];

View File

@@ -1,133 +0,0 @@
/**
* 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 { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { runAndWait } from './utils';
import type { Tool } from './tool';
export const screenshot: Tool = {
schema: {
name: 'browser_screenshot',
description: 'Take a screenshot of the current page',
inputSchema: zodToJsonSchema(z.object({})),
},
handle: async context => {
const page = context.existingPage();
const screenshot = await page.screenshot({ type: 'jpeg', quality: 50, scale: 'css' });
return {
content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: 'image/jpeg' }],
};
},
};
const elementSchema = z.object({
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
});
const moveMouseSchema = elementSchema.extend({
x: z.number().describe('X coordinate'),
y: z.number().describe('Y coordinate'),
});
export const moveMouse: Tool = {
schema: {
name: 'browser_move_mouse',
description: 'Move mouse to a given position',
inputSchema: zodToJsonSchema(moveMouseSchema),
},
handle: async (context, params) => {
const validatedParams = moveMouseSchema.parse(params);
const page = context.existingPage();
await page.mouse.move(validatedParams.x, validatedParams.y);
return {
content: [{ type: 'text', text: `Moved mouse to (${validatedParams.x}, ${validatedParams.y})` }],
};
},
};
const clickSchema = elementSchema.extend({
x: z.number().describe('X coordinate'),
y: z.number().describe('Y coordinate'),
});
export const click: Tool = {
schema: {
name: 'browser_click',
description: 'Click left mouse button',
inputSchema: zodToJsonSchema(clickSchema),
},
handle: async (context, params) => {
return await runAndWait(context, 'Clicked mouse', async page => {
const validatedParams = clickSchema.parse(params);
await page.mouse.move(validatedParams.x, validatedParams.y);
await page.mouse.down();
await page.mouse.up();
});
},
};
const dragSchema = elementSchema.extend({
startX: z.number().describe('Start X coordinate'),
startY: z.number().describe('Start Y coordinate'),
endX: z.number().describe('End X coordinate'),
endY: z.number().describe('End Y coordinate'),
});
export const drag: Tool = {
schema: {
name: 'browser_drag',
description: 'Drag left mouse button',
inputSchema: zodToJsonSchema(dragSchema),
},
handle: async (context, params) => {
const validatedParams = dragSchema.parse(params);
return await runAndWait(context, `Dragged mouse from (${validatedParams.startX}, ${validatedParams.startY}) to (${validatedParams.endX}, ${validatedParams.endY})`, async page => {
await page.mouse.move(validatedParams.startX, validatedParams.startY);
await page.mouse.down();
await page.mouse.move(validatedParams.endX, validatedParams.endY);
await page.mouse.up();
});
},
};
const typeSchema = z.object({
text: z.string().describe('Text to type into the element'),
submit: z.boolean().describe('Whether to submit entered text (press Enter after)'),
});
export const type: Tool = {
schema: {
name: 'browser_type',
description: 'Type text',
inputSchema: zodToJsonSchema(typeSchema),
},
handle: async (context, params) => {
const validatedParams = typeSchema.parse(params);
return await runAndWait(context, `Typed text "${validatedParams.text}"`, async page => {
await page.keyboard.type(validatedParams.text);
if (validatedParams.submit)
await page.keyboard.press('Enter');
});
},
};

View File

@@ -14,15 +14,21 @@
* limitations under the License.
*/
import path from 'path';
import os from 'os';
import { z } from 'zod';
import zodToJsonSchema from 'zod-to-json-schema';
import { captureAriaSnapshot, runAndWait } from './utils';
import { sanitizeForFilePath } from './utils';
import { generateLocator } from '../context';
import * as javascript from '../javascript';
import type * as playwright from 'playwright';
import type { Tool } from './tool';
export const snapshot: Tool = {
const snapshot: Tool = {
capability: 'core',
schema: {
name: 'browser_snapshot',
description: 'Capture accessibility snapshot of the current page, this is better than screenshot',
@@ -30,7 +36,13 @@ export const snapshot: Tool = {
},
handle: async context => {
return await captureAriaSnapshot(context);
await context.ensureTab();
return {
code: [`// <internal code to capture accessibility snapshot>`],
captureSnapshot: true,
waitForNetwork: false,
};
},
};
@@ -39,7 +51,8 @@ const elementSchema = z.object({
ref: z.string().describe('Exact target element reference from the page snapshot'),
});
export const click: Tool = {
const click: Tool = {
capability: 'core',
schema: {
name: 'browser_click',
description: 'Perform click on a web page',
@@ -48,7 +61,20 @@ export const click: Tool = {
handle: async (context, params) => {
const validatedParams = elementSchema.parse(params);
return runAndWait(context, `"${validatedParams.element}" clicked`, () => context.refLocator(validatedParams.ref).click(), true);
const tab = context.currentTabOrDie();
const locator = tab.snapshotOrDie().refLocator(validatedParams.ref);
const code = [
`// Click ${validatedParams.element}`,
`await page.${await generateLocator(locator)}.click();`
];
return {
code,
action: () => locator.click(),
captureSnapshot: true,
waitForNetwork: true,
};
},
};
@@ -59,7 +85,8 @@ const dragSchema = z.object({
endRef: z.string().describe('Exact target element reference from the page snapshot'),
});
export const drag: Tool = {
const drag: Tool = {
capability: 'core',
schema: {
name: 'browser_drag',
description: 'Perform drag and drop between two elements',
@@ -68,15 +95,26 @@ export const drag: Tool = {
handle: async (context, params) => {
const validatedParams = dragSchema.parse(params);
return runAndWait(context, `Dragged "${validatedParams.startElement}" to "${validatedParams.endElement}"`, async () => {
const startLocator = context.refLocator(validatedParams.startRef);
const endLocator = context.refLocator(validatedParams.endRef);
await startLocator.dragTo(endLocator);
}, true);
const snapshot = context.currentTabOrDie().snapshotOrDie();
const startLocator = snapshot.refLocator(validatedParams.startRef);
const endLocator = snapshot.refLocator(validatedParams.endRef);
const code = [
`// Drag ${validatedParams.startElement} to ${validatedParams.endElement}`,
`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`
];
return {
code,
action: () => startLocator.dragTo(endLocator),
captureSnapshot: true,
waitForNetwork: true,
};
},
};
export const hover: Tool = {
const hover: Tool = {
capability: 'core',
schema: {
name: 'browser_hover',
description: 'Hover over element on page',
@@ -85,16 +123,31 @@ export const hover: Tool = {
handle: async (context, params) => {
const validatedParams = elementSchema.parse(params);
return runAndWait(context, `Hovered over "${validatedParams.element}"`, () => context.refLocator(validatedParams.ref).hover(), true);
const snapshot = context.currentTabOrDie().snapshotOrDie();
const locator = snapshot.refLocator(validatedParams.ref);
const code = [
`// Hover over ${validatedParams.element}`,
`await page.${await generateLocator(locator)}.hover();`
];
return {
code,
action: () => locator.hover(),
captureSnapshot: true,
waitForNetwork: true,
};
},
};
const typeSchema = elementSchema.extend({
text: z.string().describe('Text to type into the element'),
submit: z.boolean().describe('Whether to submit entered text (press Enter after)'),
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
slowly: z.boolean().optional().describe('Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.'),
});
export const type: Tool = {
const type: Tool = {
capability: 'core',
schema: {
name: 'browser_type',
description: 'Type text into editable element',
@@ -103,12 +156,34 @@ export const type: Tool = {
handle: async (context, params) => {
const validatedParams = typeSchema.parse(params);
return await runAndWait(context, `Typed "${validatedParams.text}" into "${validatedParams.element}"`, async () => {
const locator = context.refLocator(validatedParams.ref);
await locator.fill(validatedParams.text);
if (validatedParams.submit)
await locator.press('Enter');
}, true);
const snapshot = context.currentTabOrDie().snapshotOrDie();
const locator = snapshot.refLocator(validatedParams.ref);
const code: string[] = [];
const steps: (() => Promise<void>)[] = [];
if (validatedParams.slowly) {
code.push(`// Press "${validatedParams.text}" sequentially into "${validatedParams.element}"`);
code.push(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(validatedParams.text)});`);
steps.push(() => locator.pressSequentially(validatedParams.text));
} else {
code.push(`// Fill "${validatedParams.text}" into "${validatedParams.element}"`);
code.push(`await page.${await generateLocator(locator)}.fill(${javascript.quote(validatedParams.text)});`);
steps.push(() => locator.fill(validatedParams.text));
}
if (validatedParams.submit) {
code.push(`// Submit text`);
code.push(`await page.${await generateLocator(locator)}.press('Enter');`);
steps.push(() => locator.press('Enter'));
}
return {
code,
action: () => steps.reduce((acc, step) => acc.then(step), Promise.resolve()),
captureSnapshot: true,
waitForNetwork: true,
};
},
};
@@ -116,7 +191,8 @@ const selectOptionSchema = elementSchema.extend({
values: z.array(z.string()).describe('Array of values to select in the dropdown. This can be a single value or multiple values.'),
});
export const selectOption: Tool = {
const selectOption: Tool = {
capability: 'core',
schema: {
name: 'browser_select_option',
description: 'Select an option in a dropdown',
@@ -125,18 +201,36 @@ export const selectOption: Tool = {
handle: async (context, params) => {
const validatedParams = selectOptionSchema.parse(params);
return await runAndWait(context, `Selected option in "${validatedParams.element}"`, async () => {
const locator = context.refLocator(validatedParams.ref);
await locator.selectOption(validatedParams.values);
}, true);
const snapshot = context.currentTabOrDie().snapshotOrDie();
const locator = snapshot.refLocator(validatedParams.ref);
const code = [
`// Select options [${validatedParams.values.join(', ')}] in ${validatedParams.element}`,
`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(validatedParams.values)});`
];
return {
code,
action: () => locator.selectOption(validatedParams.values).then(() => {}),
captureSnapshot: true,
waitForNetwork: true,
};
},
};
const screenshotSchema = z.object({
raw: z.boolean().optional().describe('Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.'),
element: z.string().optional().describe('Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.'),
ref: z.string().optional().describe('Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.'),
}).refine(data => {
return !!data.element === !!data.ref;
}, {
message: 'Both element and ref must be provided or neither.',
path: ['ref', 'element']
});
export const screenshot: Tool = {
const screenshot: Tool = {
capability: 'core',
schema: {
name: 'browser_take_screenshot',
description: `Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.`,
@@ -145,11 +239,51 @@ export const screenshot: Tool = {
handle: async (context, params) => {
const validatedParams = screenshotSchema.parse(params);
const page = context.existingPage();
const options: playwright.PageScreenshotOptions = validatedParams.raw ? { type: 'png', scale: 'css' } : { type: 'jpeg', quality: 50, scale: 'css' };
const screenshot = await page.screenshot(options);
return {
content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: validatedParams.raw ? 'image/png' : 'image/jpeg' }],
const tab = context.currentTabOrDie();
const snapshot = tab.snapshotOrDie();
const fileType = validatedParams.raw ? 'png' : 'jpeg';
const fileName = path.join(os.tmpdir(), sanitizeForFilePath(`page-${new Date().toISOString()}`)) + `.${fileType}`;
const options: playwright.PageScreenshotOptions = { type: fileType, quality: fileType === 'png' ? undefined : 50, scale: 'css', path: fileName };
const isElementScreenshot = validatedParams.element && validatedParams.ref;
const code = [
`// Screenshot ${isElementScreenshot ? validatedParams.element : 'viewport'} and save it as ${fileName}`,
];
const locator = validatedParams.ref ? snapshot.refLocator(validatedParams.ref) : null;
if (locator)
code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
else
code.push(`await page.screenshot(${javascript.formatObject(options)});`);
const action = async () => {
const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
return {
content: [{
type: 'image' as 'image',
data: screenshot.toString('base64'),
mimeType: fileType === 'png' ? 'image/png' : 'image/jpeg',
}]
};
};
},
return {
code,
action,
captureSnapshot: true,
waitForNetwork: false,
};
}
};
export default [
snapshot,
click,
drag,
hover,
type,
selectOption,
screenshot,
];

137
src/tools/tabs.ts Normal file
View File

@@ -0,0 +1,137 @@
/**
* 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 { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import type { ToolFactory, Tool } from './tool';
const listTabs: Tool = {
capability: 'tabs',
schema: {
name: 'browser_tab_list',
description: 'List browser tabs',
inputSchema: zodToJsonSchema(z.object({})),
},
handle: async context => {
await context.ensureTab();
return {
code: [`// <internal code to list tabs>`],
captureSnapshot: false,
waitForNetwork: false,
resultOverride: {
content: [{
type: 'text',
text: await context.listTabsMarkdown(),
}],
},
};
},
};
const selectTabSchema = z.object({
index: z.number().describe('The index of the tab to select'),
});
const selectTab: ToolFactory = captureSnapshot => ({
capability: 'tabs',
schema: {
name: 'browser_tab_select',
description: 'Select a tab by index',
inputSchema: zodToJsonSchema(selectTabSchema),
},
handle: async (context, params) => {
const validatedParams = selectTabSchema.parse(params);
await context.selectTab(validatedParams.index);
const code = [
`// <internal code to select tab ${validatedParams.index}>`,
];
return {
code,
captureSnapshot,
waitForNetwork: false
};
},
});
const newTabSchema = z.object({
url: z.string().optional().describe('The URL to navigate to in the new tab. If not provided, the new tab will be blank.'),
});
const newTab: ToolFactory = captureSnapshot => ({
capability: 'tabs',
schema: {
name: 'browser_tab_new',
description: 'Open a new tab',
inputSchema: zodToJsonSchema(newTabSchema),
},
handle: async (context, params) => {
const validatedParams = newTabSchema.parse(params);
await context.newTab();
if (validatedParams.url)
await context.currentTabOrDie().navigate(validatedParams.url);
const code = [
`// <internal code to open a new tab>`,
];
return {
code,
captureSnapshot,
waitForNetwork: false
};
},
});
const closeTabSchema = z.object({
index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
});
const closeTab: ToolFactory = captureSnapshot => ({
capability: 'tabs',
schema: {
name: 'browser_tab_close',
description: 'Close a tab',
inputSchema: zodToJsonSchema(closeTabSchema),
},
handle: async (context, params) => {
const validatedParams = closeTabSchema.parse(params);
await context.closeTab(validatedParams.index);
const code = [
`// <internal code to close tab ${validatedParams.index}>`,
];
return {
code,
captureSnapshot,
waitForNetwork: false
};
},
});
export default (captureSnapshot: boolean) => [
listTabs,
newTab(captureSnapshot),
selectTab(captureSnapshot),
closeTab(captureSnapshot),
];

View File

@@ -17,6 +17,8 @@
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
import type { JsonSchema7Type } from 'zod-to-json-schema';
import type { Context } from '../context';
import type * as playwright from 'playwright';
export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install';
export type ToolSchema = {
name: string;
@@ -24,13 +26,34 @@ export type ToolSchema = {
inputSchema: JsonSchema7Type;
};
export type FileUploadModalState = {
type: 'fileChooser';
description: string;
fileChooser: playwright.FileChooser;
};
export type DialogModalState = {
type: 'dialog';
description: string;
dialog: playwright.Dialog;
};
export type ModalState = FileUploadModalState | DialogModalState;
export type ToolActionResult = { content?: (ImageContent | TextContent)[] } | undefined | void;
export type ToolResult = {
content: (ImageContent | TextContent)[];
isError?: boolean;
code: string[];
action?: () => Promise<ToolActionResult>;
captureSnapshot: boolean;
waitForNetwork: boolean;
resultOverride?: ToolActionResult;
};
export type Tool = {
capability: ToolCapability;
schema: ToolSchema;
clearsModalState?: ModalState['type'];
handle: (context: Context, params?: Record<string, any>) => Promise<ToolResult>;
};

View File

@@ -15,10 +15,9 @@
*/
import type * as playwright from 'playwright';
import type { ToolResult } from './tool';
import type { Context } from '../context';
async function waitForCompletion<R>(page: playwright.Page, callback: () => Promise<R>): Promise<R> {
export async function waitForCompletion<R>(context: Context, page: playwright.Page, callback: () => Promise<R>): Promise<R> {
const requests = new Set<playwright.Request>();
let frameNavigated = false;
let waitCallback: () => void = () => {};
@@ -64,49 +63,13 @@ async function waitForCompletion<R>(page: playwright.Page, callback: () => Promi
if (!requests.size && !frameNavigated)
waitCallback();
await waitBarrier;
await page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
await context.waitForTimeout(1000);
return result;
} finally {
dispose();
}
}
export async function runAndWait(context: Context, status: string, callback: (page: playwright.Page) => Promise<any>, snapshot: boolean = false): Promise<ToolResult> {
const page = context.existingPage();
const dismissFileChooser = context.hasFileChooser();
await waitForCompletion(page, () => callback(page));
if (dismissFileChooser)
context.clearFileChooser();
const result: ToolResult = snapshot ? await captureAriaSnapshot(context, status) : {
content: [{ type: 'text', text: status }],
};
return result;
}
export async function captureAriaSnapshot(context: Context, status: string = ''): Promise<ToolResult> {
const page = context.existingPage();
const lines = [];
if (status)
lines.push(`${status}`);
lines.push(
'',
`- Page URL: ${page.url()}`,
`- Page Title: ${await page.title()}`
);
if (context.hasFileChooser())
lines.push(`- There is a file chooser visible that requires browser_choose_file to be called`);
lines.push(
`- Page Snapshot`,
'```yaml',
await context.allFramesSnapshot(),
'```',
''
);
return {
content: [{ type: 'text', text: lines.join('\n') }],
};
}
export function sanitizeForFilePath(s: string) {
return s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
}

View File

@@ -1,370 +0,0 @@
/**
* 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 fs from 'fs/promises';
import { spawn } from 'node:child_process';
import path from 'node:path';
import { test, expect } from './fixtures';
test('test tool list', async ({ client, visionClient }) => {
const { tools } = await client.listTools();
expect(tools.map(t => t.name)).toEqual([
'browser_navigate',
'browser_go_back',
'browser_go_forward',
'browser_choose_file',
'browser_snapshot',
'browser_click',
'browser_hover',
'browser_type',
'browser_select_option',
'browser_take_screenshot',
'browser_press_key',
'browser_wait',
'browser_save_as_pdf',
'browser_close',
'browser_install',
]);
const { tools: visionTools } = await visionClient.listTools();
expect(visionTools.map(t => t.name)).toEqual([
'browser_navigate',
'browser_go_back',
'browser_go_forward',
'browser_choose_file',
'browser_screenshot',
'browser_move_mouse',
'browser_click',
'browser_drag',
'browser_type',
'browser_press_key',
'browser_wait',
'browser_save_as_pdf',
'browser_close',
'browser_install',
]);
});
test('test resources list', async ({ client }) => {
const { resources } = await client.listResources();
expect(resources).toEqual([
expect.objectContaining({
uri: 'browser://console',
mimeType: 'text/plain',
}),
]);
});
test('test browser_navigate', async ({ client }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toHaveTextContent(`
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- text: Hello, world!
\`\`\`
`
);
});
test('test browser_click', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><button>Submit</button></html>',
},
});
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Submit button',
ref: 's1e3',
},
})).toHaveTextContent(`"Submit button" clicked
- Page URL: data:text/html,<html><title>Title</title><button>Submit</button></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- button "Submit" [ref=s2e3]
\`\`\`
`);
});
test('test reopen browser', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
});
expect(await client.callTool({
name: 'browser_close',
})).toHaveTextContent('Page closed');
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toHaveTextContent(`
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- text: Hello, world!
\`\`\`
`);
});
test('single option', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><select><option value="foo">Foo</option><option value="bar">Bar</option></select></html>',
},
});
expect(await client.callTool({
name: 'browser_select_option',
arguments: {
element: 'Select',
ref: 's1e3',
values: ['bar'],
},
})).toHaveTextContent(`Selected option in "Select"
- Page URL: data:text/html,<html><title>Title</title><select><option value="foo">Foo</option><option value="bar">Bar</option></select></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- combobox [ref=s2e3]:
- option "Foo" [ref=s2e4]
- option "Bar" [selected] [ref=s2e5]
\`\`\`
`);
});
test('multiple option', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><select multiple><option value="foo">Foo</option><option value="bar">Bar</option><option value="baz">Baz</option></select></html>',
},
});
expect(await client.callTool({
name: 'browser_select_option',
arguments: {
element: 'Select',
ref: 's1e3',
values: ['bar', 'baz'],
},
})).toHaveTextContent(`Selected option in "Select"
- Page URL: data:text/html,<html><title>Title</title><select multiple><option value="foo">Foo</option><option value="bar">Bar</option><option value="baz">Baz</option></select></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- listbox [ref=s2e3]:
- option "Foo" [ref=s2e4]
- option "Bar" [selected] [ref=s2e5]
- option "Baz" [selected] [ref=s2e6]
\`\`\`
`);
});
test('browser://console', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><script>console.log("Hello, world!");console.error("Error"); </script></html>',
},
});
const resource = await client.readResource({
uri: 'browser://console',
});
expect(resource.contents).toEqual([{
uri: 'browser://console',
mimeType: 'text/plain',
text: '[LOG] Hello, world!\n[ERROR] Error',
}]);
});
test('stitched aria frames', async ({ client }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: `data:text/html,<h1>Hello</h1><iframe src="data:text/html,<button>World</button><main><iframe src='data:text/html,<p>Nested</p>'></iframe></main>"></iframe><iframe src="data:text/html,<h1>Should be invisible</h1>" style="display: none;"></iframe>`,
},
})).toContainTextContent(`
\`\`\`yaml
- heading "Hello" [level=1] [ref=s1e3]
- iframe [ref=s1e4]:
- button "World" [ref=f1s1e3]
- main [ref=f1s1e4]:
- iframe [ref=f1s1e5]:
- paragraph [ref=f2s1e3]: Nested
\`\`\`
`);
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'World',
ref: 'f1s1e3',
},
})).toContainTextContent('"World" clicked');
});
test('browser_choose_file', async ({ client }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><input type="file" /><button>Button</button></html>',
},
})).toContainTextContent('- textbox [ref=s1e3]');
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Textbox',
ref: 's1e3',
},
})).toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called');
const filePath = test.info().outputPath('test.txt');
await fs.writeFile(filePath, 'Hello, world!');
{
const response = await client.callTool({
name: 'browser_choose_file',
arguments: {
paths: [filePath],
},
});
expect(response).not.toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called');
expect(response).toContainTextContent('textbox [ref=s3e3]: C:\\fakepath\\test.txt');
}
{
const response = await client.callTool({
name: 'browser_click',
arguments: {
element: 'Textbox',
ref: 's3e3',
},
});
expect(response).toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called');
expect(response).toContainTextContent('button "Button" [ref=s4e4]');
}
{
const response = await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button',
ref: 's4e4',
},
});
expect(response, 'not submitting browser_choose_file dismisses file chooser').not.toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called');
}
});
test('sse transport', async () => {
const cp = spawn('node', [path.join(__dirname, '../cli.js'), '--port', '0'], { stdio: 'pipe' });
try {
let stdout = '';
const url = await new Promise<string>(resolve => cp.stdout?.on('data', data => {
stdout += data.toString();
const match = stdout.match(/Listening on (http:\/\/.*)/);
if (match)
resolve(match[1]);
}));
// need dynamic import b/c of some ESM nonsense
const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js');
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
const transport = new SSEClientTransport(new URL(url));
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
} finally {
cp.kill();
}
});
test('cdp server', async ({ cdpEndpoint, startClient }) => {
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toHaveTextContent(`
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- text: Hello, world!
\`\`\`
`
);
});
test('save as pdf', async ({ client }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toHaveTextContent(`
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- text: Hello, world!
\`\`\`
`
);
const response = await client.callTool({
name: 'browser_save_as_pdf',
});
expect(response).toHaveTextContent(/^Saved as.*page-[^:]+.pdf$/);
});
test('executable path', async ({ startClient }) => {
const client = await startClient({ args: [`--executable-path=bogus`] });
const response = await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
});
expect(response).toContainTextContent(`executable doesn't exist`);
});

View File

@@ -0,0 +1,93 @@
/**
* 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('test snapshot tool list', async ({ client }) => {
const { tools } = await client.listTools();
expect(new Set(tools.map(t => t.name))).toEqual(new Set([
'browser_click',
'browser_console_messages',
'browser_drag',
'browser_file_upload',
'browser_handle_dialog',
'browser_hover',
'browser_select_option',
'browser_type',
'browser_close',
'browser_install',
'browser_navigate_back',
'browser_navigate_forward',
'browser_navigate',
'browser_pdf_save',
'browser_press_key',
'browser_resize',
'browser_snapshot',
'browser_tab_close',
'browser_tab_list',
'browser_tab_new',
'browser_tab_select',
'browser_take_screenshot',
'browser_wait',
]));
});
test('test vision tool list', async ({ visionClient }) => {
const { tools: visionTools } = await visionClient.listTools();
expect(new Set(visionTools.map(t => t.name))).toEqual(new Set([
'browser_close',
'browser_console_messages',
'browser_file_upload',
'browser_handle_dialog',
'browser_install',
'browser_navigate_back',
'browser_navigate_forward',
'browser_navigate',
'browser_pdf_save',
'browser_press_key',
'browser_resize',
'browser_screen_capture',
'browser_screen_click',
'browser_screen_drag',
'browser_screen_move_mouse',
'browser_screen_type',
'browser_tab_close',
'browser_tab_list',
'browser_tab_new',
'browser_tab_select',
'browser_wait',
]));
});
test('test resources list', async ({ client }) => {
const { resources } = await client.listResources();
expect(resources).toEqual([]);
});
test('test capabilities', async ({ startClient }) => {
const client = await startClient({
args: ['--caps="core"'],
});
const { tools } = await client.listTools();
const toolNames = tools.map(t => t.name);
expect(toolNames).not.toContain('browser_file_upload');
expect(toolNames).not.toContain('browser_pdf_save');
expect(toolNames).not.toContain('browser_screen_capture');
expect(toolNames).not.toContain('browser_screen_click');
expect(toolNames).not.toContain('browser_screen_drag');
expect(toolNames).not.toContain('browser_screen_move_mouse');
expect(toolNames).not.toContain('browser_screen_type');
});

56
tests/cdp.spec.ts Normal file
View File

@@ -0,0 +1,56 @@
/**
* 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('cdp server', async ({ cdpEndpoint, startClient }) => {
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toContainTextContent(`- text: Hello, world!`);
});
test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Hello, world!',
ref: 'f0',
},
})).toHaveTextContent(`Error: No current snapshot available. Capture a snapshot of navigate to a new location first.`);
expect(await client.callTool({
name: 'browser_snapshot',
arguments: {},
})).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
// <internal code to capture accessibility snapshot>
\`\`\`
- Page URL: data:text/html,hello world
- Page Title:
- Page Snapshot
\`\`\`yaml
- text: hello world
\`\`\`
`);
});

View File

@@ -14,22 +14,22 @@
* limitations under the License.
*/
import type { Resource } from './resource';
import { test, expect } from './fixtures';
export const console: Resource = {
schema: {
uri: 'browser://console',
name: 'Page console',
mimeType: 'text/plain',
},
test('browser_console_messages', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><script>console.log("Hello, world!");console.error("Error"); </script></html>',
},
});
read: async (context, uri) => {
const messages = await context.console();
const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n');
return [{
uri,
mimeType: 'text/plain',
text: log
}];
},
};
const resource = await client.callTool({
name: 'browser_console_messages',
arguments: {},
});
expect(resource).toHaveTextContent([
'[LOG] Hello, world!',
'[ERROR] Error',
].join('\n'));
});

210
tests/core.spec.ts Normal file
View File

@@ -0,0 +1,210 @@
/**
* 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_navigate', async ({ client }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
// Navigate to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
await page.goto('data:text/html,<html><title>Title</title><body>Hello, world!</body></html>');
\`\`\`
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- text: Hello, world!
\`\`\`
`
);
});
test('browser_click', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><button>Submit</button></html>',
},
});
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Submit button',
ref: 's1e3',
},
})).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
// Click Submit button
await page.getByRole('button', { name: 'Submit' }).click();
\`\`\`
- Page URL: data:text/html,<html><title>Title</title><button>Submit</button></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- button "Submit" [ref=s2e3]
\`\`\`
`);
});
test('browser_select_option', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><select><option value="foo">Foo</option><option value="bar">Bar</option></select></html>',
},
});
expect(await client.callTool({
name: 'browser_select_option',
arguments: {
element: 'Select',
ref: 's1e3',
values: ['bar'],
},
})).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
// Select options [bar] in Select
await page.getByRole('combobox').selectOption(['bar']);
\`\`\`
- Page URL: data:text/html,<html><title>Title</title><select><option value="foo">Foo</option><option value="bar">Bar</option></select></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- combobox [ref=s2e3]:
- option "Foo" [ref=s2e4]
- option "Bar" [selected] [ref=s2e5]
\`\`\`
`);
});
test('browser_select_option (multiple)', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><select multiple><option value="foo">Foo</option><option value="bar">Bar</option><option value="baz">Baz</option></select></html>',
},
});
expect(await client.callTool({
name: 'browser_select_option',
arguments: {
element: 'Select',
ref: 's1e3',
values: ['bar', 'baz'],
},
})).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
// Select options [bar, baz] in Select
await page.getByRole('listbox').selectOption(['bar', 'baz']);
\`\`\`
- Page URL: data:text/html,<html><title>Title</title><select multiple><option value="foo">Foo</option><option value="bar">Bar</option><option value="baz">Baz</option></select></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- listbox [ref=s2e3]:
- option "Foo" [ref=s2e4]
- option "Bar" [selected] [ref=s2e5]
- option "Baz" [selected] [ref=s2e6]
\`\`\`
`);
});
test('browser_type', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
url: `data:text/html,<input type='keypress' onkeypress="console.log('Key pressed:', event.key, ', Text:', event.target.value)"></input>`,
},
});
await client.callTool({
name: 'browser_type',
arguments: {
element: 'textbox',
ref: 's1e3',
text: 'Hi!',
submit: true,
},
});
expect(await client.callTool({
name: 'browser_console_messages',
arguments: {},
})).toHaveTextContent('[LOG] Key pressed: Enter , Text: Hi!');
});
test('browser_type (slowly)', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
url: `data:text/html,<input type='text' onkeydown="console.log('Key pressed:', event.key, 'Text:', event.target.value)"></input>`,
},
});
await client.callTool({
name: 'browser_type',
arguments: {
element: 'textbox',
ref: 's1e3',
text: 'Hi!',
submit: true,
slowly: true,
},
});
expect(await client.callTool({
name: 'browser_console_messages',
arguments: {},
})).toHaveTextContent([
'[LOG] Key pressed: H Text: ',
'[LOG] Key pressed: i Text: H',
'[LOG] Key pressed: ! Text: Hi',
'[LOG] Key pressed: Enter Text: Hi!',
].join('\n'));
});
test('browser_resize', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Resize Test</title><body><div id="size">Waiting for resize...</div><script>new ResizeObserver(() => { document.getElementById("size").textContent = `Window size: ${window.innerWidth}x${window.innerHeight}`; }).observe(document.body);</script></body></html>',
},
});
const response = await client.callTool({
name: 'browser_resize',
arguments: {
width: 390,
height: 780,
},
});
expect(response).toContainTextContent(`- Ran Playwright code:
\`\`\`js
// Resize browser window to 390x780
await page.setViewportSize({ width: 390, height: 780 });
\`\`\``);
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent('Window size: 390x780');
});

192
tests/dialogs.spec.ts Normal file
View File

@@ -0,0 +1,192 @@
/**
* 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';
// https://github.com/microsoft/playwright/issues/35663
test.skip(({ mcpBrowser, mcpHeadless }) => mcpBrowser === 'webkit' && mcpHeadless);
test('alert dialog', async ({ client }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><button onclick="alert(\'Alert\')">Button</button></html>',
},
})).toContainTextContent('- button "Button" [ref=s1e3]');
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button',
ref: 's1e3',
},
})).toHaveTextContent(`- Ran Playwright code:
\`\`\`js
// Click Button
await page.getByRole('button', { name: 'Button' }).click();
\`\`\`
### Modal state
- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`);
const result = await client.callTool({
name: 'browser_handle_dialog',
arguments: {
accept: true,
},
});
expect(result).not.toContainTextContent('### Modal state');
expect(result).toHaveTextContent(`- Ran Playwright code:
\`\`\`js
// <internal code to handle "alert" dialog>
\`\`\`
- Page URL: data:text/html,<html><title>Title</title><button onclick="alert('Alert')">Button</button></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- button "Button" [ref=s2e3]
\`\`\`
`);
});
test('two alert dialogs', async ({ client }) => {
test.fixme(true, 'Race between the dialog and ariaSnapshot');
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><button onclick="alert(\'Alert 1\');alert(\'Alert 2\');">Button</button></html>',
},
})).toContainTextContent('- button "Button" [ref=s1e3]');
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button',
ref: 's1e3',
},
})).toHaveTextContent(`- Ran Playwright code:
\`\`\`js
// Click Button
await page.getByRole('button', { name: 'Button' }).click();
\`\`\`
### Modal state
- ["alert" dialog with message "Alert 1"]: can be handled by the "browser_handle_dialog" tool`);
const result = await client.callTool({
name: 'browser_handle_dialog',
arguments: {
accept: true,
},
});
expect(result).not.toContainTextContent('### Modal state');
});
test('confirm dialog (true)', async ({ client }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><button onclick="document.body.textContent = confirm(\'Confirm\')">Button</button></html>',
},
})).toContainTextContent('- button "Button" [ref=s1e3]');
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button',
ref: 's1e3',
},
})).toContainTextContent(`### Modal state
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
const result = await client.callTool({
name: 'browser_handle_dialog',
arguments: {
accept: true,
},
});
expect(result).not.toContainTextContent('### Modal state');
expect(result).toContainTextContent('// <internal code to handle "confirm" dialog>');
expect(result).toContainTextContent(`- Page Snapshot
\`\`\`yaml
- text: "true"
\`\`\``);
});
test('confirm dialog (false)', async ({ client }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><button onclick="document.body.textContent = confirm(\'Confirm\')">Button</button></html>',
},
})).toContainTextContent('- button "Button" [ref=s1e3]');
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button',
ref: 's1e3',
},
})).toContainTextContent(`### Modal state
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
const result = await client.callTool({
name: 'browser_handle_dialog',
arguments: {
accept: false,
},
});
expect(result).toContainTextContent(`- Page Snapshot
\`\`\`yaml
- text: "false"
\`\`\``);
});
test('prompt dialog', async ({ client }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><button onclick="document.body.textContent = prompt(\'Prompt\')">Button</button></html>',
},
})).toContainTextContent('- button "Button" [ref=s1e3]');
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button',
ref: 's1e3',
},
})).toContainTextContent(`### Modal state
- ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool`);
const result = await client.callTool({
name: 'browser_handle_dialog',
arguments: {
accept: true,
promptText: 'Answer',
},
});
expect(result).toContainTextContent(`- Page Snapshot
\`\`\`yaml
- text: Answer
\`\`\``);
});

77
tests/files.spec.ts Normal file
View File

@@ -0,0 +1,77 @@
/**
* 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';
import fs from 'fs/promises';
test('browser_file_upload', async ({ client }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><input type="file" /><button>Button</button></html>',
},
})).toContainTextContent('- textbox [ref=s1e3]');
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Textbox',
ref: 's1e3',
},
})).toContainTextContent(`### Modal state
- [File chooser]: can be handled by the "browser_file_upload" tool`);
const filePath = test.info().outputPath('test.txt');
await fs.writeFile(filePath, 'Hello, world!');
{
const response = await client.callTool({
name: 'browser_file_upload',
arguments: {
paths: [filePath],
},
});
expect(response).not.toContainTextContent('### Modal state');
expect(response).toContainTextContent('textbox [ref=s3e3]: C:\\fakepath\\test.txt');
}
{
const response = await client.callTool({
name: 'browser_click',
arguments: {
element: 'Textbox',
ref: 's3e3',
},
});
expect(response).toContainTextContent('- [File chooser]: can be handled by the \"browser_file_upload\" tool');
}
{
const response = await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button',
ref: 's4e4',
},
});
expect(response).toContainTextContent(`Tool "browser_click" does not handle the modal state.
### Modal state
- [File chooser]: can be handled by the "browser_file_upload" tool`);
}
});

View File

@@ -20,33 +20,41 @@ import { chromium } from 'playwright';
import { test as baseTest, expect as baseExpect } from '@playwright/test';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { spawn } from 'child_process';
type Fixtures = {
type TestFixtures = {
client: Client;
visionClient: Client;
startClient: (options?: { args?: string[], vision?: boolean }) => Promise<Client>;
startClient: (options?: { args?: string[] }) => Promise<Client>;
wsEndpoint: string;
cdpEndpoint: string;
};
export const test = baseTest.extend<Fixtures>({
type WorkerFixtures = {
mcpHeadless: boolean;
mcpBrowser: string | undefined;
};
export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
client: async ({ startClient }, use) => {
await use(await startClient());
},
visionClient: async ({ startClient }, use) => {
await use(await startClient({ vision: true }));
await use(await startClient({ args: ['--vision'] }));
},
startClient: async ({ }, use, testInfo) => {
startClient: async ({ mcpHeadless, mcpBrowser }, use, testInfo) => {
const userDataDir = testInfo.outputPath('user-data-dir');
let client: StdioClientTransport | undefined;
use(async options => {
const args = ['--headless', '--user-data-dir', userDataDir];
if (options?.vision)
args.push('--vision');
const args = ['--user-data-dir', userDataDir];
if (mcpHeadless)
args.push('--headless');
if (mcpBrowser)
args.push(`--browser=${mcpBrowser}`);
if (options?.args)
args.push(...options.args);
const transport = new StdioClientTransport({
@@ -70,13 +78,32 @@ export const test = baseTest.extend<Fixtures>({
cdpEndpoint: async ({ }, use, testInfo) => {
const port = 3200 + (+process.env.TEST_PARALLEL_INDEX!);
const browser = await chromium.launchPersistentContext(testInfo.outputPath('user-data-dir'), {
channel: 'chrome',
args: [`--remote-debugging-port=${port}`],
const executablePath = chromium.executablePath();
const browserProcess = spawn(executablePath, [
`--user-data-dir=${testInfo.outputPath('user-data-dir')}`,
`--remote-debugging-port=${port}`,
`--no-first-run`,
`--no-sandbox`,
`--headless`,
`data:text/html,hello world`,
], {
stdio: 'pipe',
});
await new Promise<void>(resolve => {
browserProcess.stderr.on('data', data => {
if (data.toString().includes('DevTools listening on '))
resolve();
});
});
await use(`http://localhost:${port}`);
await browser.close();
browserProcess.kill();
},
mcpHeadless: [async ({ headless }, use) => {
await use(headless);
}, { scope: 'worker' }],
mcpBrowser: ['chromium', { option: true, scope: 'worker' }],
});
type Response = Awaited<ReturnType<Client['callTool']>>;
@@ -86,10 +113,17 @@ export const expect = baseExpect.extend({
const isNot = this.isNot;
try {
const text = (response.content as any)[0].text;
if (isNot)
baseExpect(text).not.toMatch(content);
else
baseExpect(text).toMatch(content);
if (typeof content === 'string') {
if (isNot)
baseExpect(text.trim()).not.toBe(content.trim());
else
baseExpect(text.trim()).toBe(content.trim());
} else {
if (isNot)
baseExpect(text).not.toMatch(content);
else
baseExpect(text).toMatch(content);
}
} catch (e) {
return {
pass: isNot,

42
tests/iframes.spec.ts Normal file
View File

@@ -0,0 +1,42 @@
/**
* 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('stitched aria frames', async ({ client }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: `data:text/html,<h1>Hello</h1><iframe src="data:text/html,<button>World</button><main><iframe src='data:text/html,<p>Nested</p>'></iframe></main>"></iframe><iframe src="data:text/html,<h1>Should be invisible</h1>" style="display: none;"></iframe>`,
},
})).toContainTextContent(`
\`\`\`yaml
- heading "Hello" [level=1] [ref=s1e3]
- iframe [ref=s1e4]:
- button "World" [ref=f1s1e3]
- main [ref=f1s1e4]:
- iframe [ref=f1s1e5]:
- paragraph [ref=f2s1e3]: Nested
\`\`\``);
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'World',
ref: 'f1s1e3',
},
})).toContainTextContent(`// Click World`);
});

48
tests/launch.spec.ts Normal file
View File

@@ -0,0 +1,48 @@
/**
* 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('test reopen browser', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
});
expect(await client.callTool({
name: 'browser_close',
})).toContainTextContent('No open pages available');
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toContainTextContent(`- text: Hello, world!`);
});
test('executable path', async ({ startClient }) => {
const client = await startClient({ args: [`--executable-path=bogus`] });
const response = await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
});
expect(response).toContainTextContent(`executable doesn't exist`);
});

46
tests/pdf.spec.ts Normal file
View File

@@ -0,0 +1,46 @@
/**
* 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('save as pdf unavailable', async ({ startClient }) => {
const client = await startClient({ args: ['--caps="no-pdf"'] });
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
});
expect(await client.callTool({
name: 'browser_pdf_save',
})).toHaveTextContent(/Tool \"browser_pdf_save\" not found/);
});
test('save as pdf', async ({ client, mcpBrowser }) => {
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toContainTextContent(`- text: Hello, world!`);
const response = await client.callTool({
name: 'browser_pdf_save',
});
expect(response).toHaveTextContent(/Save page as.*page-[^:]+.pdf/);
});

72
tests/screenshot.spec.ts Normal file
View File

@@ -0,0 +1,72 @@
/**
* 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_take_screenshot (viewport)', async ({ client }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toContainTextContent(`Navigate to data:text/html`);
expect(await client.callTool({
name: 'browser_take_screenshot',
arguments: {},
})).toEqual({
content: [
{
data: expect.any(String),
mimeType: 'image/jpeg',
type: 'image',
},
{
text: expect.stringContaining(`Screenshot viewport and save it as`),
type: 'text',
},
],
});
});
test('browser_take_screenshot (element)', async ({ client }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><button>Hello, world!</button></html>',
},
})).toContainTextContent(`[ref=s1e3]`);
expect(await client.callTool({
name: 'browser_take_screenshot',
arguments: {
element: 'hello button',
ref: 's1e3',
},
})).toEqual({
content: [
{
data: expect.any(String),
mimeType: 'image/jpeg',
type: 'image',
},
{
text: expect.stringContaining(`page.getByRole('button', { name: 'Hello, world!' }).screenshot`),
type: 'text',
},
],
});
});

42
tests/sse.spec.ts Normal file
View File

@@ -0,0 +1,42 @@
/**
* 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 { spawn } from 'node:child_process';
import path from 'node:path';
import { test } from './fixtures';
test('sse transport', async () => {
const cp = spawn('node', [path.join(__dirname, '../cli.js'), '--port', '0'], { stdio: 'pipe' });
try {
let stdout = '';
const url = await new Promise<string>(resolve => cp.stdout?.on('data', data => {
stdout += data.toString();
const match = stdout.match(/Listening on (http:\/\/.*)/);
if (match)
resolve(match[1]);
}));
// need dynamic import b/c of some ESM nonsense
const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js');
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
const transport = new SSEClientTransport(new URL(url));
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
} finally {
cp.kill();
}
});

157
tests/tabs.spec.ts Normal file
View File

@@ -0,0 +1,157 @@
/**
* 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 { chromium } from 'playwright';
import { test, expect } from './fixtures';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
async function createTab(client: Client, title: string, body: string) {
return await client.callTool({
name: 'browser_tab_new',
arguments: {
url: `data:text/html,<title>${title}</title><body>${body}</body>`,
},
});
}
test('list initial tabs', async ({ client }) => {
expect(await client.callTool({
name: 'browser_tab_list',
})).toHaveTextContent(`### Open tabs
- 1: (current) [] (about:blank)`);
});
test('list first tab', async ({ client }) => {
await createTab(client, 'Tab one', 'Body one');
expect(await client.callTool({
name: 'browser_tab_list',
})).toHaveTextContent(`### Open tabs
- 1: [] (about:blank)
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`);
});
test('create new tab', async ({ client }) => {
expect(await createTab(client, 'Tab one', 'Body one')).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
// <internal code to open a new tab>
\`\`\`
### Open tabs
- 1: [] (about:blank)
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
### Current tab
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
- Page Title: Tab one
- Page Snapshot
\`\`\`yaml
- text: Body one
\`\`\``);
expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
// <internal code to open a new tab>
\`\`\`
### Open tabs
- 1: [] (about:blank)
- 2: [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
- 3: (current) [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
### Current tab
- Page URL: data:text/html,<title>Tab two</title><body>Body two</body>
- Page Title: Tab two
- Page Snapshot
\`\`\`yaml
- text: Body two
\`\`\``);
});
test('select tab', async ({ client }) => {
await createTab(client, 'Tab one', 'Body one');
await createTab(client, 'Tab two', 'Body two');
expect(await client.callTool({
name: 'browser_tab_select',
arguments: {
index: 2,
},
})).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
// <internal code to select tab 2>
\`\`\`
### Open tabs
- 1: [] (about:blank)
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
- 3: [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
### Current tab
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
- Page Title: Tab one
- Page Snapshot
\`\`\`yaml
- text: Body one
\`\`\``);
});
test('close tab', async ({ client }) => {
await createTab(client, 'Tab one', 'Body one');
await createTab(client, 'Tab two', 'Body two');
expect(await client.callTool({
name: 'browser_tab_close',
arguments: {
index: 3,
},
})).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
// <internal code to close tab 3>
\`\`\`
### Open tabs
- 1: [] (about:blank)
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
### Current tab
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
- Page Title: Tab one
- Page Snapshot
\`\`\`yaml
- text: Body one
\`\`\``);
});
test('reuse first tab when navigating', async ({ startClient, cdpEndpoint }) => {
const browser = await chromium.connectOverCDP(cdpEndpoint);
const [context] = browser.contexts();
const pages = context.pages();
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<title>Title</title><body>Body</body>',
},
});
expect(pages.length).toBe(1);
expect(await pages[0].title()).toBe('Title');
});

178
utils/update-readme.js Normal file
View File

@@ -0,0 +1,178 @@
#!/usr/bin/env node
/**
* 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.
*/
// @ts-check
const fs = require('node:fs');
const path = require('node:path');
const commonTools = require('../lib/tools/common').default;
const consoleTools = require('../lib/tools/console').default;
const dialogsTools = require('../lib/tools/dialogs').default;
const filesTools = require('../lib/tools/files').default;
const installTools = require('../lib/tools/install').default;
const keyboardTools = require('../lib/tools/keyboard').default;
const navigateTools = require('../lib/tools/navigate').default;
const pdfTools = require('../lib/tools/pdf').default;
const snapshotTools = require('../lib/tools/snapshot').default;
const tabsTools = require('../lib/tools/tabs').default;
const screenTools = require('../lib/tools/screen').default;
// Category definitions for tools
const categories = {
'Snapshot-based Interactions': [
...snapshotTools,
],
'Vision-based Interactions': [
...screenTools
],
'Tab Management': [
...tabsTools(true),
],
'Navigation': [
...navigateTools(true),
],
'Keyboard': [
...keyboardTools(true)
],
'Console': [
...consoleTools
],
'Files and Media': [
...filesTools(true),
...pdfTools
],
'Utilities': [
...commonTools(true),
...installTools,
...dialogsTools(true),
],
};
const kStartMarker = `<!--- Generated by ${path.basename(__filename)} -->`;
const kEndMarker = `<!--- End of generated section -->`;
/**
* @param {ParsedToolSchema} tool
* @returns {string}
*/
function formatToolForReadme(tool) {
const lines = /** @type {string[]} */ ([]);
lines.push(`<!-- NOTE: This has been generated via ${path.basename(__filename)} -->\n\n`);
lines.push(`- **${tool.name}**\n`);
lines.push(` - Description: ${tool.description}\n`);
if (tool.parameters && tool.parameters.length > 0) {
lines.push(` - Parameters:\n`);
tool.parameters.forEach(param => {
const meta = /** @type {string[]} */ ([]);
if (param.type)
meta.push(param.type);
if (param.optional)
meta.push('optional');
lines.push(` - \`${param.name}\` ${meta.length ? `(${meta.join(', ')})` : ''}: ${param.description}\n`);
});
} else {
lines.push(` - Parameters: None\n`);
}
lines.push('\n');
return lines.join('');
}
/**
* @typedef {{
* name: any;
* description: any;
* parameters: {
* name: string;
* description: string;
* optional: boolean;
* type: string;
* }[];
*}} ParsedToolSchema
*/
/**
* @param {import('../src/tools/tool').ToolSchema} schema
* @returns {ParsedToolSchema}
*/
function processToolSchema(schema) {
const inputSchema = /** @type {import('zod-to-json-schema').JsonSchema7ObjectType} */ (schema.inputSchema || {});
if (inputSchema.type !== 'object')
throw new Error(`Tool ${schema.name} input schema is not an object`);
// In JSON Schema, properties are considered optional unless listed in the required array
const requiredParams = inputSchema?.required || [];
const parameters = Object.entries(inputSchema.properties).map(([name, prop]) => {
return {
name,
description: prop.description || '',
optional: !requiredParams.includes(name),
type: /** @type {any} */ (prop).type,
};
});
return {
name: schema.name,
description: schema.description,
parameters
};
}
async function updateReadme() {
console.log('Loading tool information from compiled modules...');
// Count the tools processed
const totalTools = Object.values(categories).flat().length;
console.log(`Found ${totalTools} tools`);
const generatedLines = /** @type {string[]} */ ([]);
for (const [category, categoryTools] of Object.entries(categories)) {
generatedLines.push(`### ${category}\n\n`);
for (const tool of categoryTools) {
const scheme = processToolSchema(tool.schema);
generatedLines.push(formatToolForReadme(scheme));
}
}
const readmePath = path.join(__dirname, '..', 'README.md');
const readmeContent = await fs.promises.readFile(readmePath, 'utf-8');
const startMarker = readmeContent.indexOf(kStartMarker);
const endMarker = readmeContent.indexOf(kEndMarker);
if (startMarker === -1 || endMarker === -1)
throw new Error('Markers for generated section not found in README');
const newReadmeContent = [
readmeContent.slice(0, startMarker),
kStartMarker + '\n\n',
generatedLines.join(''),
kEndMarker,
readmeContent.slice(endMarker + kEndMarker.length),
].join('');
// Write updated README
await fs.promises.writeFile(readmePath, newReadmeContent, 'utf-8');
console.log('README updated successfully');
}
// Run the update
updateReadme().catch(err => {
console.error('Error updating README:', err);
process.exit(1);
});