Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
878be97668 | ||
|
|
6d6b1a384b | ||
|
|
fd22def4c5 | ||
|
|
1b60870f50 | ||
|
|
1c760b3826 | ||
|
|
9efaea6a1c | ||
|
|
3f72fe53ec | ||
|
|
40d125f0bb | ||
|
|
21d2f80fef | ||
|
|
6efdc90078 | ||
|
|
ad4147da54 | ||
|
|
69703cc882 | ||
|
|
4147e21a3a | ||
|
|
80c9b93b72 | ||
|
|
12e72a96c4 | ||
|
|
697a69a8c2 | ||
|
|
6e76d5e550 | ||
|
|
26779ceb20 | ||
|
|
23704ace1f | ||
|
|
b02370df2f | ||
|
|
bf7dbabca4 | ||
|
|
7256ee3701 | ||
|
|
0ed0bcd914 | ||
|
|
4d95761f66 | ||
|
|
b9dc323734 | ||
|
|
586492a3f0 | ||
|
|
f7e9bae571 | ||
|
|
1bc3c761de | ||
|
|
c80f7cf222 | ||
|
|
9578a5b2af | ||
|
|
cd5aa344f1 | ||
|
|
dc955c73a3 | ||
|
|
d4f8f87b03 | ||
|
|
0c3792d231 | ||
|
|
7695717546 | ||
|
|
6a070a0dd8 | ||
|
|
6481100bdf | ||
|
|
4b261286bf | ||
|
|
7e4a964b0a | ||
|
|
cea347d067 | ||
|
|
6054290d9a | ||
|
|
6d4adfe5c6 | ||
|
|
e7c7709b33 | ||
|
|
5c2e11017d | ||
|
|
e4331313f9 | ||
|
|
bc48600a49 | ||
|
|
0d6bb2f547 | ||
|
|
795a9d578a | ||
|
|
4a19e18999 | ||
|
|
4d59e06184 | ||
|
|
6891a525b3 |
30
.github/workflows/ci.yml
vendored
30
.github/workflows/ci.yml
vendored
@@ -7,8 +7,29 @@ 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
|
||||
- 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
|
||||
@@ -25,8 +46,9 @@ jobs:
|
||||
- name: Playwright install
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Run linting
|
||||
run: npm run lint
|
||||
- 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
|
||||
@@ -35,4 +57,4 @@ jobs:
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
run: npm test -- --forbid-only
|
||||
|
||||
3
.github/workflows/publish.yml
vendored
3
.github/workflows/publish.yml
vendored
@@ -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}}
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,3 +1,7 @@
|
||||
lib/
|
||||
node_modules/
|
||||
test-results/
|
||||
.vscode/mcp.json
|
||||
|
||||
.idea
|
||||
.DS_Store
|
||||
|
||||
@@ -4,3 +4,4 @@ LICENSE
|
||||
!lib/**/*.js
|
||||
!cli.js
|
||||
!index.*
|
||||
!config.d.ts
|
||||
|
||||
22
Dockerfile
Normal file
22
Dockerfile
Normal 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"]
|
||||
309
README.md
309
README.md
@@ -15,6 +15,13 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
|
||||
- Automated testing driven by LLMs
|
||||
- General-purpose browser interaction for agents
|
||||
|
||||
<!--
|
||||
// Generate using:
|
||||
node utils/generate_links.js
|
||||
-->
|
||||
|
||||
[<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%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%2540playwright%252Fmcp%2540latest%2522%255D%257D)
|
||||
|
||||
### Example config
|
||||
|
||||
```js
|
||||
@@ -30,36 +37,29 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
|
||||
}
|
||||
```
|
||||
|
||||
### Table of Contents
|
||||
|
||||
#### Installation in VS Code
|
||||
- [Installation in VS Code](#installation-in-vs-code)
|
||||
- [Command line](#command-line)
|
||||
- [User profile](#user-profile)
|
||||
- [Configuration file](#configuration-file)
|
||||
- [Running on Linux](#running-on-linux)
|
||||
- [Docker](#docker)
|
||||
- [Programmatic usage](#programmatic-usage)
|
||||
- [Tool modes](#tool-modes)
|
||||
|
||||
Install the Playwright MCP server in VS Code using one of these buttons:
|
||||
### Installation in VS Code
|
||||
|
||||
<!--
|
||||
// Generate using?:
|
||||
const config = JSON.stringify({ name: 'playwright', command: 'npx', args: ["-y", "@playwright/mcp@latest"] });
|
||||
const urlForWebsites = `vscode:mcp/install?${encodeURIComponent(config)}`;
|
||||
// Github markdown does not allow linking to `vscode:` directly, so you can use our redirect:
|
||||
const urlForGithub = `https://insiders.vscode.dev/redirect?url=${encodeURIComponent(urlForWebsites)}`;
|
||||
-->
|
||||
|
||||
[<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:
|
||||
You can install the Playwright MCP server using the VS Code CLI:
|
||||
|
||||
```bash
|
||||
# For VS Code
|
||||
code --add-mcp '{"name":"playwright","command":"npx","args":["@playwright/mcp@latest"]}'
|
||||
```
|
||||
|
||||
```bash
|
||||
# For VS Code Insiders
|
||||
code-insiders --add-mcp '{"name":"playwright","command":"npx","args":["@playwright/mcp@latest"]}'
|
||||
```
|
||||
|
||||
After installation, the Playwright MCP server will be available for use with your GitHub Copilot agent in VS Code.
|
||||
|
||||
### CLI Options
|
||||
### Command line
|
||||
|
||||
The Playwright MCP server supports the following command-line options:
|
||||
|
||||
@@ -72,42 +72,102 @@ The Playwright MCP server supports the following command-line options:
|
||||
- `--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)
|
||||
- `--port <port>`: Port to listen on for SSE transport
|
||||
- `--device`: Emulate mobile device
|
||||
- `--user-data-dir <path>`: Path to the user data directory
|
||||
- `--port <port>`: Port to listen on for SSE transport
|
||||
- `--host <host>`: Host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.
|
||||
- `--vision`: Run server that uses screenshots (Aria snapshots are used by default)
|
||||
- `--config <path>`: Path to the configuration file
|
||||
|
||||
### User data directory
|
||||
### User profile
|
||||
|
||||
Playwright MCP will launch the browser with the new profile, located at
|
||||
|
||||
```
|
||||
- `%USERPROFILE%\AppData\Local\ms-playwright\mcp-chrome-profile` on Windows
|
||||
- `~/Library/Caches/ms-playwright/mcp-chrome-profile` on macOS
|
||||
- `~/.cache/ms-playwright/mcp-chrome-profile` on Linux
|
||||
- `%USERPROFILE%\AppData\Local\ms-playwright\mcp-{channel}-profile` on Windows
|
||||
- `~/Library/Caches/ms-playwright/mcp-{channel}-profile` on macOS
|
||||
- `~/.cache/ms-playwright/mcp-{channel}-profile` on Linux
|
||||
```
|
||||
|
||||
All the logged in information will be stored in that profile, you can delete it between sessions if you'd like to clear the offline state.
|
||||
|
||||
### Configuration file
|
||||
|
||||
### Running headless browser (Browser without GUI).
|
||||
The Playwright MCP server can be configured using a JSON configuration file. Here's the complete configuration format:
|
||||
|
||||
This mode is useful for background or batch operations.
|
||||
|
||||
```js
|
||||
```typescript
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest",
|
||||
"--headless"
|
||||
]
|
||||
// Browser configuration
|
||||
browser?: {
|
||||
// Browser type to use (chromium, firefox, or webkit)
|
||||
browserName?: 'chromium' | 'firefox' | 'webkit';
|
||||
|
||||
// Path to user data directory for browser profile persistence
|
||||
userDataDir?: string;
|
||||
|
||||
// Browser launch options (see Playwright docs)
|
||||
// @see https://playwright.dev/docs/api/class-browsertype#browser-type-launch
|
||||
launchOptions?: {
|
||||
channel?: string; // Browser channel (e.g. 'chrome')
|
||||
headless?: boolean; // Run in headless mode
|
||||
executablePath?: string; // Path to browser executable
|
||||
// ... other Playwright launch options
|
||||
};
|
||||
|
||||
// Browser context options
|
||||
// @see https://playwright.dev/docs/api/class-browser#browser-new-context
|
||||
contextOptions?: {
|
||||
viewport?: { width: number, height: number };
|
||||
// ... other Playwright context options
|
||||
};
|
||||
|
||||
// CDP endpoint for connecting to existing browser
|
||||
cdpEndpoint?: string;
|
||||
|
||||
// Remote Playwright server endpoint
|
||||
remoteEndpoint?: string;
|
||||
},
|
||||
|
||||
// Server configuration
|
||||
server?: {
|
||||
port?: number; // Port to listen on
|
||||
host?: string; // Host to bind to (default: localhost)
|
||||
},
|
||||
|
||||
// List of enabled capabilities
|
||||
capabilities?: Array<
|
||||
'core' | // Core browser automation
|
||||
'tabs' | // Tab management
|
||||
'pdf' | // PDF generation
|
||||
'history' | // Browser history
|
||||
'wait' | // Wait utilities
|
||||
'files' | // File handling
|
||||
'install' // Browser installation
|
||||
>;
|
||||
|
||||
// Enable vision mode (screenshots instead of accessibility snapshots)
|
||||
vision?: boolean;
|
||||
|
||||
// Directory for output files
|
||||
outputDir?: string;
|
||||
|
||||
// Tool-specific configurations
|
||||
tools?: {
|
||||
browser_take_screenshot?: {
|
||||
// Disable base64-encoded image responses
|
||||
omitBase64?: boolean;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Running headed browser on Linux w/o DISPLAY
|
||||
You can specify the configuration file using the `--config` command line option:
|
||||
|
||||
```bash
|
||||
npx @playwright/mcp@latest --config path/to/config.json
|
||||
```
|
||||
|
||||
### Running on Linux
|
||||
|
||||
When running headed browser on system w/o display or from worker processes of the IDEs,
|
||||
run the MCP server from environment with the DISPLAY and pass the `--port` flag to enable SSE transport.
|
||||
@@ -128,7 +188,48 @@ And then in MCP client config, set the `url` to the SSE endpoint:
|
||||
}
|
||||
```
|
||||
|
||||
### Tool Modes
|
||||
### Docker
|
||||
|
||||
**NOTE:** The Docker implementation only supports headless chromium at the moment.
|
||||
|
||||
```js
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "docker",
|
||||
"args": ["run", "-i", "--rm", "--init", "mcp/playwright"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can build the Docker image yourself.
|
||||
|
||||
```
|
||||
docker build -t mcp/playwright .
|
||||
```
|
||||
|
||||
### Programmatic usage
|
||||
|
||||
```js
|
||||
import http from 'http';
|
||||
|
||||
import { createServer } from '@playwright/mcp';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
|
||||
http.createServer(async (req, res) => {
|
||||
// ...
|
||||
|
||||
// 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);
|
||||
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
### Tool modes
|
||||
|
||||
The tools are available in two modes:
|
||||
|
||||
@@ -154,41 +255,44 @@ 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.
|
||||
|
||||
### Programmatic usage with custom transports
|
||||
|
||||
```js
|
||||
import { createServer } from '@playwright/mcp';
|
||||
|
||||
// ...
|
||||
|
||||
const server = createServer({
|
||||
launchOptions: { headless: true }
|
||||
});
|
||||
transport = new SSEServerTransport("/messages", res);
|
||||
server.connect(transport);
|
||||
```
|
||||
<!--- Generated by update-readme.js -->
|
||||
|
||||
### Snapshot-based Interactions
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_snapshot**
|
||||
- Description: Capture accessibility snapshot of the current page, this is better than screenshot
|
||||
- Parameters: None
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_click**
|
||||
- Description: Perform click on a web 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
|
||||
|
||||
<!-- 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
|
||||
@@ -199,6 +303,8 @@ server.connect(transport);
|
||||
- `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 an option in a dropdown
|
||||
- Parameters:
|
||||
@@ -206,17 +312,25 @@ server.connect(transport);
|
||||
- `ref` (string): Exact target element reference from the page snapshot
|
||||
- `values` (array): Array of values to select in the dropdown. This can be a single value or multiple values.
|
||||
|
||||
- **browser_snapshot**
|
||||
- Description: Capture accessibility snapshot of the current page, this is better than screenshot
|
||||
- Parameters: None
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **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.
|
||||
- Parameters:
|
||||
- `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.
|
||||
|
||||
### Vision-based Interactions
|
||||
|
||||
<!-- 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 -->
|
||||
|
||||
- **browser_screen_move_mouse**
|
||||
- Description: Move mouse to a given position
|
||||
- Parameters:
|
||||
@@ -224,9 +338,7 @@ server.connect(transport);
|
||||
- `x` (number): X coordinate
|
||||
- `y` (number): Y coordinate
|
||||
|
||||
- **browser_screen_capture**
|
||||
- Description: Take a screenshot of the current page
|
||||
- Parameters: None
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_screen_click**
|
||||
- Description: Click left mouse button
|
||||
@@ -235,6 +347,8 @@ server.connect(transport);
|
||||
- `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:
|
||||
@@ -244,33 +358,38 @@ server.connect(transport);
|
||||
- `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
|
||||
- `text` (string): Text to type into the element
|
||||
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
|
||||
|
||||
- **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`
|
||||
|
||||
### 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:
|
||||
@@ -278,48 +397,92 @@ server.connect(transport);
|
||||
|
||||
### 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
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_navigate_back**
|
||||
- Description: Go back to the previous page
|
||||
- Parameters: None
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_navigate_forward**
|
||||
- Description: Go forward to the next page
|
||||
- Parameters: None
|
||||
|
||||
### Keyboard
|
||||
|
||||
<!-- 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`
|
||||
|
||||
### 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: Choose one or multiple files to 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.
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_pdf_save**
|
||||
- Description: Save page as PDF
|
||||
- Parameters: None
|
||||
|
||||
### Utilities
|
||||
|
||||
- **browser_wait**
|
||||
- Description: Wait for a specified time in seconds
|
||||
- Parameters:
|
||||
- `time` (number): The time to wait in seconds (capped at 10 seconds)
|
||||
<!-- 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 -->
|
||||
|
||||
113
config.d.ts
vendored
Normal file
113
config.d.ts
vendored
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 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 type * as playwright from 'playwright';
|
||||
|
||||
export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install';
|
||||
|
||||
export type Config = {
|
||||
/**
|
||||
* The browser to use.
|
||||
*/
|
||||
browser?: {
|
||||
/**
|
||||
* The type of browser to use.
|
||||
*/
|
||||
browserName?: 'chromium' | 'firefox' | 'webkit';
|
||||
|
||||
/**
|
||||
* Path to a user data directory for browser profile persistence.
|
||||
* Temporary directory is created by default.
|
||||
*/
|
||||
userDataDir?: string;
|
||||
|
||||
/**
|
||||
* Launch options passed to
|
||||
* @see https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context
|
||||
*
|
||||
* This is useful for settings options like `channel`, `headless`, `executablePath`, etc.
|
||||
*/
|
||||
launchOptions?: playwright.BrowserLaunchOptions;
|
||||
|
||||
/**
|
||||
* Context options for the browser context.
|
||||
*
|
||||
* This is useful for settings options like `viewport`.
|
||||
*/
|
||||
contextOptions?: playwright.BrowserContextOptions;
|
||||
|
||||
/**
|
||||
* Chrome DevTools Protocol endpoint to connect to an existing browser instance in case of Chromium family browsers.
|
||||
*/
|
||||
cdpEndpoint?: string;
|
||||
|
||||
/**
|
||||
* Remote endpoint to connect to an existing Playwright server.
|
||||
*/
|
||||
remoteEndpoint?: string;
|
||||
},
|
||||
|
||||
server?: {
|
||||
/**
|
||||
* The port to listen on for SSE or MCP transport.
|
||||
*/
|
||||
port?: number;
|
||||
|
||||
/**
|
||||
* The host to bind the server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.
|
||||
*/
|
||||
host?: string;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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[];
|
||||
|
||||
/**
|
||||
* Run server that uses screenshots (Aria snapshots are used by default).
|
||||
*/
|
||||
vision?: boolean;
|
||||
|
||||
/**
|
||||
* The directory to save output files.
|
||||
*/
|
||||
outputDir?: string;
|
||||
|
||||
/**
|
||||
* Configuration for specific tools.
|
||||
*/
|
||||
tools?: {
|
||||
/**
|
||||
* Configuration for the browser_take_screenshot tool.
|
||||
*/
|
||||
browser_take_screenshot?: {
|
||||
|
||||
/**
|
||||
* Whether to disable base64-encoded image responses to the clients that
|
||||
* don't support binary data or prefer to save on tokens.
|
||||
*/
|
||||
omitBase64?: boolean;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -33,6 +33,7 @@ const plugins = {
|
||||
};
|
||||
|
||||
export const baseRules = {
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
2,
|
||||
{ args: "none", caughtErrors: "none" },
|
||||
@@ -184,6 +185,9 @@ const languageOptions = {
|
||||
parser: tsParser,
|
||||
ecmaVersion: 9,
|
||||
sourceType: "module",
|
||||
parserOptions: {
|
||||
project: path.join(fileURLToPath(import.meta.url), "..", "tsconfig.all.json"),
|
||||
}
|
||||
};
|
||||
|
||||
export default [
|
||||
|
||||
47
examples.md
Normal file
47
examples.md
Normal 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');
|
||||
});
|
||||
```
|
||||
30
index.d.ts
vendored
30
index.d.ts
vendored
@@ -15,33 +15,9 @@
|
||||
* 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';
|
||||
import type { Config } from './config';
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Capabilities to enable.
|
||||
*/
|
||||
capabilities?: ToolCapability[];
|
||||
};
|
||||
|
||||
export function createServer(options?: Options): Server;
|
||||
export declare function createServer(config?: Config): Promise<Server>;
|
||||
export {};
|
||||
|
||||
53
package-lock.json
generated
53
package-lock.json
generated
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.12",
|
||||
"version": "0.0.18",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.12",
|
||||
"version": "0.0.18",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.6.1",
|
||||
"@modelcontextprotocol/sdk": "^1.10.1",
|
||||
"commander": "^13.1.0",
|
||||
"playwright": "^1.52.0-alpha-1743163434000",
|
||||
"playwright": "1.53.0-alpha-2025-04-25",
|
||||
"yaml": "^2.7.1",
|
||||
"zod-to-json-schema": "^3.24.4"
|
||||
},
|
||||
@@ -21,7 +21,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@playwright/test": "^1.52.0-alpha-1743163434000",
|
||||
"@playwright/test": "1.53.0-alpha-2025-04-25",
|
||||
"@stylistic/eslint-plugin": "^3.0.1",
|
||||
"@types/node": "^22.13.10",
|
||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||
@@ -228,17 +228,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.7.0.tgz",
|
||||
"integrity": "sha512-IYPe/FLpvF3IZrd/f5p5ffmWhMc3aEMuM2wGJASDqC2Ge7qatVCdbfPx3n/5xFeb19xN0j/911M2AaFuircsWA==",
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.10.1.tgz",
|
||||
"integrity": "sha512-xNYdFdkJqEfIaTVP1gPKoEvluACHZsHZegIoICX8DM1o6Qf3G5u2BQJHmgd0n4YgRPqqK/u1ujQvrgAxxSJT9w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"content-type": "^1.0.5",
|
||||
"cors": "^2.8.5",
|
||||
"cross-spawn": "^7.0.3",
|
||||
"eventsource": "^3.0.2",
|
||||
"express": "^5.0.1",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"pkce-challenge": "^4.1.0",
|
||||
"pkce-challenge": "^5.0.0",
|
||||
"raw-body": "^3.0.0",
|
||||
"zod": "^3.23.8",
|
||||
"zod-to-json-schema": "^3.24.1"
|
||||
@@ -286,13 +287,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.52.0-alpha-1743163434000",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0-alpha-1743163434000.tgz",
|
||||
"integrity": "sha512-4uBgNlJ6hgPtB8DrwQsgoKuVoe7j+nPqudna7CLXWCmmT3LYPMD5aOjGoBkszr+R9NejtKashq/bOi/ny9hsIA==",
|
||||
"version": "1.53.0-alpha-2025-04-25",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-2025-04-25.tgz",
|
||||
"integrity": "sha512-3y4C2ZjAc2oUpwavC2yG2JzH53TOKgcMZvWb5GmpxnOa6fhuSVXK0kIsiIaImKmdffIVM1agsqNHp8yldeBTHQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.52.0-alpha-1743163434000"
|
||||
"playwright": "1.53.0-alpha-2025-04-25"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -1091,7 +1092,6 @@
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
@@ -2786,7 +2786,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
@@ -3256,7 +3255,6 @@
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -3292,21 +3290,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pkce-challenge": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-4.1.0.tgz",
|
||||
"integrity": "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz",
|
||||
"integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.52.0-alpha-1743163434000",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0-alpha-1743163434000.tgz",
|
||||
"integrity": "sha512-4uYv49ekPjolydfFfTfFQ2z4URF9UZMVUXLy7aXam/tPxEQ5O7+jQC+yzrDMGmhcj5QkMnxjlyk7N2V9a0QLdQ==",
|
||||
"version": "1.53.0-alpha-2025-04-25",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-2025-04-25.tgz",
|
||||
"integrity": "sha512-b5VT4lWgyhhy99zHeCoUBt/FQckPxeQVA5ksvxBv0HeqcEvzZzhuyqrrcZewJyflE+5U+bmvqI+yoU0ks8mE3Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.52.0-alpha-1743163434000"
|
||||
"playwright-core": "1.53.0-alpha-2025-04-25"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -3319,9 +3317,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.52.0-alpha-1743163434000",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0-alpha-1743163434000.tgz",
|
||||
"integrity": "sha512-Tn4u3Ywwjkh847/bYWlXIrNxv5DRJRDgtb+VYMXHvNCKkrxL6yfZ1ApIAYD7IAkkKH/KLTXszGWl3a/Z/KDfQA==",
|
||||
"version": "1.53.0-alpha-2025-04-25",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-2025-04-25.tgz",
|
||||
"integrity": "sha512-gjV01l6A4q/zg+/pwEX50k9lhYWaE9NcDVypSDD331jB3EYrdk0LeDQxqz5XFDOzq/tC/8QTouDs9a/s/p95hA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
@@ -3796,7 +3794,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
@@ -3809,7 +3806,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -4238,7 +4234,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
|
||||
14
package.json
14
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.12",
|
||||
"version": "0.0.18",
|
||||
"description": "Playwright Tools for MCP",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -16,9 +16,13 @@
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"lint": "eslint .",
|
||||
"lint": "npm run update-readme && 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"
|
||||
},
|
||||
@@ -30,16 +34,16 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.6.1",
|
||||
"@modelcontextprotocol/sdk": "^1.10.1",
|
||||
"commander": "^13.1.0",
|
||||
"playwright": "^1.52.0-alpha-1743163434000",
|
||||
"playwright": "1.53.0-alpha-2025-04-25",
|
||||
"yaml": "^2.7.1",
|
||||
"zod-to-json-schema": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@playwright/test": "^1.52.0-alpha-1743163434000",
|
||||
"@playwright/test": "1.53.0-alpha-2025-04-25",
|
||||
"@stylistic/eslint-plugin": "^3.0.1",
|
||||
"@types/node": "^22.13.10",
|
||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||
|
||||
@@ -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[],
|
||||
});
|
||||
|
||||
190
src/config.ts
Normal file
190
src/config.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 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';
|
||||
import net from 'net';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { devices } from 'playwright';
|
||||
|
||||
import { sanitizeForFilePath } from './tools/utils';
|
||||
|
||||
import type { Config, ToolCapability } from '../config';
|
||||
import type { BrowserContextOptions, LaunchOptions } from 'playwright';
|
||||
|
||||
export type CLIOptions = {
|
||||
browser?: string;
|
||||
caps?: string;
|
||||
cdpEndpoint?: string;
|
||||
executablePath?: string;
|
||||
headless?: boolean;
|
||||
device?: string;
|
||||
userDataDir?: string;
|
||||
port?: number;
|
||||
host?: string;
|
||||
vision?: boolean;
|
||||
config?: string;
|
||||
};
|
||||
|
||||
const defaultConfig: Config = {
|
||||
browser: {
|
||||
browserName: 'chromium',
|
||||
userDataDir: os.tmpdir(),
|
||||
launchOptions: {
|
||||
channel: 'chrome',
|
||||
headless: os.platform() === 'linux' && !process.env.DISPLAY,
|
||||
},
|
||||
contextOptions: {
|
||||
viewport: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export async function resolveConfig(cliOptions: CLIOptions): Promise<Config> {
|
||||
const config = await loadConfig(cliOptions.config);
|
||||
const cliOverrides = await configFromCLIOptions(cliOptions);
|
||||
return mergeConfig(defaultConfig, mergeConfig(config, cliOverrides));
|
||||
}
|
||||
|
||||
export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> {
|
||||
let browserName: 'chromium' | 'firefox' | 'webkit';
|
||||
let channel: string | undefined;
|
||||
switch (cliOptions.browser) {
|
||||
case 'chrome':
|
||||
case 'chrome-beta':
|
||||
case 'chrome-canary':
|
||||
case 'chrome-dev':
|
||||
case 'chromium':
|
||||
case 'msedge':
|
||||
case 'msedge-beta':
|
||||
case 'msedge-canary':
|
||||
case 'msedge-dev':
|
||||
browserName = 'chromium';
|
||||
channel = cliOptions.browser;
|
||||
break;
|
||||
case 'firefox':
|
||||
browserName = 'firefox';
|
||||
break;
|
||||
case 'webkit':
|
||||
browserName = 'webkit';
|
||||
break;
|
||||
default:
|
||||
browserName = 'chromium';
|
||||
channel = 'chrome';
|
||||
}
|
||||
|
||||
const launchOptions: LaunchOptions = {
|
||||
channel,
|
||||
executablePath: cliOptions.executablePath,
|
||||
headless: cliOptions.headless,
|
||||
};
|
||||
|
||||
if (browserName === 'chromium')
|
||||
(launchOptions as any).webSocketPort = await findFreePort();
|
||||
|
||||
const contextOptions: BrowserContextOptions | undefined = cliOptions.device ? devices[cliOptions.device] : undefined;
|
||||
|
||||
return {
|
||||
browser: {
|
||||
browserName,
|
||||
userDataDir: cliOptions.userDataDir ?? await createUserDataDir({ browserName, channel }),
|
||||
launchOptions,
|
||||
contextOptions,
|
||||
cdpEndpoint: cliOptions.cdpEndpoint,
|
||||
},
|
||||
server: {
|
||||
port: cliOptions.port,
|
||||
host: cliOptions.host,
|
||||
},
|
||||
capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
|
||||
vision: !!cliOptions.vision,
|
||||
};
|
||||
}
|
||||
|
||||
async function findFreePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.listen(0, () => {
|
||||
const { port } = server.address() as net.AddressInfo;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadConfig(configFile: string | undefined): Promise<Config> {
|
||||
if (!configFile)
|
||||
return {};
|
||||
|
||||
try {
|
||||
return JSON.parse(await fs.promises.readFile(configFile, 'utf8'));
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to load config file: ${configFile}, ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createUserDataDir(options: { browserName: 'chromium' | 'firefox' | 'webkit', channel: string | undefined }) {
|
||||
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-${options.channel ?? options.browserName}-profile`);
|
||||
await fs.promises.mkdir(result, { recursive: true });
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function outputFile(config: Config, name: string): Promise<string> {
|
||||
const result = config.outputDir ?? os.tmpdir();
|
||||
await fs.promises.mkdir(result, { recursive: true });
|
||||
const fileName = sanitizeForFilePath(name);
|
||||
return path.join(result, fileName);
|
||||
}
|
||||
|
||||
function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj ?? {}).filter(([_, v]) => v !== undefined)
|
||||
) as Partial<T>;
|
||||
}
|
||||
|
||||
function mergeConfig(base: Config, overrides: Config): Config {
|
||||
const browser: Config['browser'] = {
|
||||
...pickDefined(base.browser),
|
||||
...pickDefined(overrides.browser),
|
||||
launchOptions: {
|
||||
...pickDefined(base.browser?.launchOptions),
|
||||
...pickDefined(overrides.browser?.launchOptions),
|
||||
...{ assistantMode: true },
|
||||
},
|
||||
contextOptions: {
|
||||
...pickDefined(base.browser?.contextOptions),
|
||||
...pickDefined(overrides.browser?.contextOptions),
|
||||
},
|
||||
};
|
||||
|
||||
if (browser.browserName !== 'chromium')
|
||||
delete browser.launchOptions.channel;
|
||||
|
||||
return {
|
||||
...pickDefined(base),
|
||||
...pickDefined(overrides),
|
||||
browser,
|
||||
};
|
||||
}
|
||||
434
src/context.ts
434
src/context.ts
@@ -15,44 +15,63 @@
|
||||
*/
|
||||
|
||||
import * as playwright from 'playwright';
|
||||
import yaml from 'yaml';
|
||||
|
||||
import { waitForCompletion } from './tools/utils';
|
||||
import { ToolResult } from './tools/tool';
|
||||
import { ManualPromise } from './manualPromise';
|
||||
import { Tab } from './tab';
|
||||
|
||||
export type ContextOptions = {
|
||||
browserName?: 'chromium' | 'firefox' | 'webkit';
|
||||
userDataDir: string;
|
||||
launchOptions?: playwright.LaunchOptions;
|
||||
cdpEndpoint?: string;
|
||||
remoteEndpoint?: string;
|
||||
};
|
||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
|
||||
import type { ModalState, Tool, ToolActionResult } from './tools/tool';
|
||||
import type { Config } from '../config';
|
||||
|
||||
type PageOrFrameLocator = playwright.Page | playwright.FrameLocator;
|
||||
|
||||
type RunOptions = {
|
||||
captureSnapshot?: boolean;
|
||||
waitForCompletion?: boolean;
|
||||
status?: string;
|
||||
noClearFileChooser?: boolean;
|
||||
type PendingAction = {
|
||||
dialogShown: ManualPromise<void>;
|
||||
};
|
||||
|
||||
export class Context {
|
||||
readonly options: ContextOptions;
|
||||
readonly tools: Tool[];
|
||||
readonly config: Config;
|
||||
private _browser: playwright.Browser | undefined;
|
||||
private _browserContext: playwright.BrowserContext | undefined;
|
||||
private _createBrowserContextPromise: Promise<{ browser?: playwright.Browser, 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[], config: Config) {
|
||||
this.tools = tools;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
modalStates(): ModalState[] {
|
||||
return this._modalStates;
|
||||
}
|
||||
|
||||
setModalState(modalState: ModalState, inTab: Tab) {
|
||||
this._modalStates.push({ ...modalState, tab: inTab });
|
||||
}
|
||||
|
||||
clearModalState(modalState: ModalState) {
|
||||
this._modalStates = this._modalStates.filter(state => state !== modalState);
|
||||
}
|
||||
|
||||
modalStatesMarkdown(): string[] {
|
||||
const result: string[] = ['### Modal state'];
|
||||
if (this._modalStates.length === 0)
|
||||
result.push('- There is no modal state present');
|
||||
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;
|
||||
}
|
||||
|
||||
tabs(): Tab[] {
|
||||
return this._tabs;
|
||||
}
|
||||
|
||||
currentTab(): Tab {
|
||||
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;
|
||||
@@ -77,10 +96,10 @@ export class Context {
|
||||
return this._currentTab!;
|
||||
}
|
||||
|
||||
async listTabs(): Promise<string> {
|
||||
async listTabsMarkdown(): Promise<string> {
|
||||
if (!this._tabs.length)
|
||||
return 'No tabs open';
|
||||
const lines: string[] = ['Open tabs:'];
|
||||
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();
|
||||
@@ -92,9 +111,121 @@ export class Context {
|
||||
}
|
||||
|
||||
async closeTab(index: number | undefined) {
|
||||
const tab = index === undefined ? this.currentTab() : this._tabs[index - 1];
|
||||
await tab.page.close();
|
||||
return await this.listTabs();
|
||||
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, tool.schema.inputSchema.parse(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) {
|
||||
@@ -105,6 +236,7 @@ export class Context {
|
||||
}
|
||||
|
||||
private _onPageClosed(tab: Tab) {
|
||||
this._modalStates = this._modalStates.filter(state => state.tab !== tab);
|
||||
const index = this._tabs.indexOf(tab);
|
||||
if (index === -1)
|
||||
return;
|
||||
@@ -112,18 +244,22 @@ export class Context {
|
||||
|
||||
if (this._currentTab === tab)
|
||||
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
|
||||
const browser = this._browser;
|
||||
if (this._browserContext && !this._tabs.length) {
|
||||
void this._browserContext.close().then(() => browser?.close()).catch(() => {});
|
||||
this._browser = undefined;
|
||||
this._browserContext = undefined;
|
||||
}
|
||||
if (this._browserContext && !this._tabs.length)
|
||||
void this.close();
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (!this._browserContext)
|
||||
return;
|
||||
await this._browserContext.close();
|
||||
const browserContext = this._browserContext;
|
||||
const browser = this._browser;
|
||||
this._createBrowserContextPromise = undefined;
|
||||
this._browserContext = undefined;
|
||||
this._browser = undefined;
|
||||
|
||||
await browserContext?.close().then(async () => {
|
||||
await browser?.close();
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
private async _ensureBrowserContext() {
|
||||
@@ -139,223 +275,45 @@ export class Context {
|
||||
}
|
||||
|
||||
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));
|
||||
if (!this._createBrowserContextPromise)
|
||||
this._createBrowserContextPromise = this._innerCreateBrowserContext();
|
||||
return this._createBrowserContextPromise;
|
||||
}
|
||||
|
||||
private async _innerCreateBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
|
||||
if (this.config.browser?.remoteEndpoint) {
|
||||
const url = new URL(this.config.browser?.remoteEndpoint);
|
||||
if (this.config.browser.browserName)
|
||||
url.searchParams.set('browser', this.config.browser.browserName);
|
||||
if (this.config.browser.launchOptions)
|
||||
url.searchParams.set('launch-options', JSON.stringify(this.config.browser.launchOptions));
|
||||
const browser = await playwright[this.config.browser?.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.config.browser?.cdpEndpoint) {
|
||||
const browser = await playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint);
|
||||
const browserContext = browser.contexts()[0];
|
||||
return { browser, browserContext };
|
||||
}
|
||||
|
||||
const browserContext = await this._launchPersistentContext();
|
||||
const browserContext = await launchPersistentContext(this.config.browser);
|
||||
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);
|
||||
} 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 function launchPersistentContext(browserConfig: Config['browser']): Promise<playwright.BrowserContext> {
|
||||
try {
|
||||
const browserType = browserConfig?.browserName ? playwright[browserConfig.browserName] : playwright.chromium;
|
||||
return await browserType.launchPersistentContext(browserConfig?.userDataDir || '', { ...browserConfig?.launchOptions, ...browserConfig?.contextOptions });
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
class Tab {
|
||||
readonly context: Context;
|
||||
readonly page: playwright.Page;
|
||||
private _console: playwright.ConsoleMessage[] = [];
|
||||
private _fileChooser: playwright.FileChooser | undefined;
|
||||
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._fileChooser = chooser);
|
||||
page.setDefaultNavigationTimeout(60000);
|
||||
page.setDefaultTimeout(5000);
|
||||
}
|
||||
|
||||
private _onClose() {
|
||||
this._fileChooser = undefined;
|
||||
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(() => {});
|
||||
}
|
||||
|
||||
async run(callback: (tab: Tab) => Promise<void>, options?: RunOptions): Promise<ToolResult> {
|
||||
try {
|
||||
if (!options?.noClearFileChooser)
|
||||
this._fileChooser = undefined;
|
||||
if (options?.waitForCompletion)
|
||||
await waitForCompletion(this.page, () => callback(this));
|
||||
else
|
||||
await callback(this);
|
||||
} finally {
|
||||
if (options?.captureSnapshot)
|
||||
this._snapshot = await PageSnapshot.create(this.page);
|
||||
}
|
||||
const tabList = this.context.tabs().length > 1 ? await this.context.listTabs() + '\n\nCurrent tab:' + '\n' : '';
|
||||
const snapshot = this._snapshot?.text({ status: options?.status, hasFileChooser: !!this._fileChooser }) ?? options?.status ?? '';
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: tabList + snapshot,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
async runAndWait(callback: (tab: Tab) => Promise<void>, options?: RunOptions): Promise<ToolResult> {
|
||||
return await this.run(callback, {
|
||||
waitForCompletion: true,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
async runAndWaitWithSnapshot(callback: (snapshot: PageSnapshot) => Promise<void>, options?: RunOptions): Promise<ToolResult> {
|
||||
return await this.run(tab => callback(tab.lastSnapshot()), {
|
||||
captureSnapshot: true,
|
||||
waitForCompletion: true,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
lastSnapshot(): PageSnapshot {
|
||||
if (!this._snapshot)
|
||||
throw new Error('No snapshot available');
|
||||
return this._snapshot;
|
||||
}
|
||||
|
||||
async console(): Promise<playwright.ConsoleMessage[]> {
|
||||
return this._console;
|
||||
}
|
||||
|
||||
async submitFileChooser(paths: string[]) {
|
||||
if (!this._fileChooser)
|
||||
throw new Error('No file chooser visible');
|
||||
await this._fileChooser.setFiles(paths);
|
||||
this._fileChooser = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
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(options?: { status?: string, hasFileChooser?: boolean }): string {
|
||||
const results: string[] = [];
|
||||
if (options?.status) {
|
||||
results.push(options.status);
|
||||
results.push('');
|
||||
}
|
||||
if (options?.hasFileChooser) {
|
||||
results.push('- There is a file chooser visible that requires browser_file_upload to be called');
|
||||
results.push('');
|
||||
}
|
||||
results.push(this._text);
|
||||
return results.join('\n');
|
||||
}
|
||||
|
||||
private async _build(page: playwright.Page) {
|
||||
const yamlDocument = await this._snapshotFrame(page);
|
||||
const lines = [];
|
||||
lines.push(
|
||||
`- Page URL: ${page.url()}`,
|
||||
`- Page Title: ${await page.title()}`
|
||||
);
|
||||
lines.push(
|
||||
`- Page Snapshot`,
|
||||
'```yaml',
|
||||
yamlDocument.toString().trim(),
|
||||
'```',
|
||||
''
|
||||
);
|
||||
this._text = lines.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);
|
||||
|
||||
const visit = async (node: any): Promise<unknown> => {
|
||||
if (yaml.isPair(node)) {
|
||||
await Promise.all([
|
||||
visit(node.key).then(k => node.key = k),
|
||||
visit(node.value).then(v => node.value = v)
|
||||
]);
|
||||
} else if (yaml.isSeq(node) || yaml.isMap(node)) {
|
||||
node.items = await Promise.all(node.items.map(visit));
|
||||
} else if (yaml.isScalar(node)) {
|
||||
if (typeof node.value === 'string') {
|
||||
const value = node.value;
|
||||
if (frameIndex > 0)
|
||||
node.value = value.replace('[ref=', `[ref=f${frameIndex}`);
|
||||
if (value.startsWith('iframe ')) {
|
||||
const ref = value.match(/\[ref=(.*)\]/)?.[1];
|
||||
if (ref) {
|
||||
try {
|
||||
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>');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
await visit(snapshot.contents);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
refLocator(ref: string): playwright.Locator {
|
||||
let frame = this._frameLocators[0];
|
||||
const match = ref.match(/^f(\d+)(.*)/);
|
||||
if (match) {
|
||||
const frameIndex = parseInt(match[1], 10);
|
||||
frame = this._frameLocators[frameIndex];
|
||||
ref = match[2];
|
||||
}
|
||||
|
||||
if (!frame)
|
||||
throw new Error(`Frame does not exist. Provide ref from the most current snapshot.`);
|
||||
|
||||
return frame.locator(`aria-ref=${ref}`);
|
||||
}
|
||||
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
||||
return (locator as any)._generateLocatorString();
|
||||
}
|
||||
|
||||
45
src/index.ts
45
src/index.ts
@@ -16,69 +16,58 @@
|
||||
|
||||
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 network from './tools/network';
|
||||
import pdf from './tools/pdf';
|
||||
import snapshot from './tools/snapshot';
|
||||
import tabs from './tools/tabs';
|
||||
import screen from './tools/screen';
|
||||
import { console as consoleResource } from './resources/console';
|
||||
|
||||
import type { Tool, ToolCapability } from './tools/tool';
|
||||
import type { Resource } from './resources/resource';
|
||||
import type { Tool } from './tools/tool';
|
||||
import type { Config } from '../config';
|
||||
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import type { LaunchOptions } from 'playwright';
|
||||
|
||||
const snapshotTools: Tool[] = [
|
||||
const snapshotTools: Tool<any>[] = [
|
||||
...common(true),
|
||||
...console,
|
||||
...dialogs(true),
|
||||
...files(true),
|
||||
...install,
|
||||
...keyboard(true),
|
||||
...navigate(true),
|
||||
...network,
|
||||
...pdf,
|
||||
...snapshot,
|
||||
...tabs(true),
|
||||
];
|
||||
|
||||
const screenshotTools: Tool[] = [
|
||||
const screenshotTools: Tool<any>[] = [
|
||||
...common(false),
|
||||
...console,
|
||||
...dialogs(false),
|
||||
...files(false),
|
||||
...install,
|
||||
...keyboard(false),
|
||||
...navigate(false),
|
||||
...network,
|
||||
...pdf,
|
||||
...screen,
|
||||
...tabs(false),
|
||||
];
|
||||
|
||||
const resources: Resource[] = [
|
||||
consoleResource,
|
||||
];
|
||||
|
||||
type Options = {
|
||||
browserName?: 'chromium' | 'firefox' | 'webkit';
|
||||
userDataDir?: string;
|
||||
launchOptions?: LaunchOptions;
|
||||
cdpEndpoint?: string;
|
||||
vision?: boolean;
|
||||
capabilities?: ToolCapability[];
|
||||
};
|
||||
|
||||
const packageJSON = require('../package.json');
|
||||
|
||||
export function createServer(options?: Options): Server {
|
||||
const allTools = options?.vision ? screenshotTools : snapshotTools;
|
||||
const tools = allTools.filter(tool => !options?.capabilities || tool.capability === 'core' || options.capabilities.includes(tool.capability));
|
||||
export async function createServer(config: Config = {}): Promise<Server> {
|
||||
const allTools = config.vision ? screenshotTools : snapshotTools;
|
||||
const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));
|
||||
return createServerWithTools({
|
||||
name: 'Playwright',
|
||||
version: packageJSON.version,
|
||||
tools,
|
||||
resources,
|
||||
browserName: options?.browserName,
|
||||
userDataDir: options?.userDataDir ?? '',
|
||||
launchOptions: options?.launchOptions,
|
||||
cdpEndpoint: options?.cdpEndpoint,
|
||||
});
|
||||
}, config);
|
||||
}
|
||||
|
||||
53
src/javascript.ts
Normal file
53
src/javascript.ts
Normal 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
127
src/manualPromise.ts
Normal 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');
|
||||
}
|
||||
101
src/pageSnapshot.ts
Normal file
101
src/pageSnapshot.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as playwright from 'playwright';
|
||||
import yaml from 'yaml';
|
||||
|
||||
type PageOrFrameLocator = playwright.Page | playwright.FrameLocator;
|
||||
|
||||
export 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, emitGeneric: true });
|
||||
const snapshot = yaml.parseDocument(snapshotString);
|
||||
|
||||
const visit = async (node: any): Promise<unknown> => {
|
||||
if (yaml.isPair(node)) {
|
||||
await Promise.all([
|
||||
visit(node.key).then(k => node.key = k),
|
||||
visit(node.value).then(v => node.value = v)
|
||||
]);
|
||||
} else if (yaml.isSeq(node) || yaml.isMap(node)) {
|
||||
node.items = await Promise.all(node.items.map(visit));
|
||||
} else if (yaml.isScalar(node)) {
|
||||
if (typeof node.value === 'string') {
|
||||
const value = node.value;
|
||||
if (frameIndex > 0)
|
||||
node.value = value.replace('[ref=', `[ref=f${frameIndex}`);
|
||||
if (value.startsWith('iframe ')) {
|
||||
const ref = value.match(/\[ref=(.*)\]/)?.[1];
|
||||
if (ref) {
|
||||
try {
|
||||
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>');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
await visit(snapshot.contents);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
refLocator(ref: string): playwright.Locator {
|
||||
let frame = this._frameLocators[0];
|
||||
const match = ref.match(/^f(\d+)(.*)/);
|
||||
if (match) {
|
||||
const frameIndex = parseInt(match[1], 10);
|
||||
frame = this._frameLocators[frameIndex];
|
||||
ref = match[2];
|
||||
}
|
||||
|
||||
if (!frame)
|
||||
throw new Error(`Frame does not exist. Provide ref from the most current snapshot.`);
|
||||
|
||||
return frame.locator(`aria-ref=${ref}`);
|
||||
}
|
||||
}
|
||||
152
src/program.ts
152
src/program.ts
@@ -14,22 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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';
|
||||
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';
|
||||
import { startHttpTransport, startStdioTransport } from './transport';
|
||||
|
||||
import { resolveConfig } from './config';
|
||||
|
||||
const packageJSON = require('../package.json');
|
||||
|
||||
@@ -41,62 +33,22 @@ program
|
||||
.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')
|
||||
.option('--port <port>', 'Port to listen on for SSE transport.')
|
||||
.option('--device <device>', 'Device to emulate, for example: "iPhone 15"')
|
||||
.option('--user-data-dir <path>', 'Path to the user data directory')
|
||||
.option('--port <port>', 'Port to listen on for SSE transport.')
|
||||
.option('--host <host>', 'Host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
|
||||
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
|
||||
.option('--config <path>', 'Path to the configuration file.')
|
||||
.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 ?? (os.platform() === 'linux' && !process.env.DISPLAY)),
|
||||
channel,
|
||||
executablePath: options.executablePath,
|
||||
};
|
||||
|
||||
const userDataDir = options.userDataDir ?? await createUserDataDir(browserName);
|
||||
|
||||
const serverList = new ServerList(() => createServer({
|
||||
browserName,
|
||||
userDataDir,
|
||||
launchOptions,
|
||||
vision: !!options.vision,
|
||||
cdpEndpoint: options.cdpEndpoint,
|
||||
capabilities: options.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
|
||||
}));
|
||||
const config = await resolveConfig(options);
|
||||
console.error(config);
|
||||
const serverList = new ServerList(() => createServer(config));
|
||||
setupExitWatchdog(serverList);
|
||||
|
||||
if (options.port) {
|
||||
startSSEServer(+options.port, serverList);
|
||||
} else {
|
||||
const server = await serverList.create();
|
||||
await server.connect(new StdioServerTransport());
|
||||
}
|
||||
if (options.port)
|
||||
startHttpTransport(+options.port, options.host, serverList);
|
||||
else
|
||||
await startStdioTransport(serverList);
|
||||
});
|
||||
|
||||
function setupExitWatchdog(serverList: ServerList) {
|
||||
@@ -112,79 +64,3 @@ function setupExitWatchdog(serverList: ServerList) {
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (req.method === 'POST') {
|
||||
const searchParams = new URL(`http://localhost${req.url}`).searchParams;
|
||||
const sessionId = searchParams.get('sessionId');
|
||||
if (!sessionId) {
|
||||
res.statusCode = 400;
|
||||
res.end('Missing sessionId');
|
||||
return;
|
||||
}
|
||||
const transport = sessions.get(sessionId);
|
||||
if (!transport) {
|
||||
res.statusCode = 404;
|
||||
res.end('Session not found');
|
||||
return;
|
||||
}
|
||||
|
||||
await transport.handlePostMessage(req, res);
|
||||
return;
|
||||
} else if (req.method === 'GET') {
|
||||
const transport = new SSEServerTransport('/sse', res);
|
||||
sessions.set(transport.sessionId, transport);
|
||||
const server = await serverList.create();
|
||||
res.on('close', () => {
|
||||
sessions.delete(transport.sessionId);
|
||||
serverList.close(server).catch(e => console.error(e));
|
||||
});
|
||||
await server.connect(transport);
|
||||
return;
|
||||
} else {
|
||||
res.statusCode = 405;
|
||||
res.end('Method not allowed');
|
||||
}
|
||||
});
|
||||
|
||||
httpServer.listen(port, () => {
|
||||
const address = httpServer.address();
|
||||
assert(address, 'Could not bind server socket');
|
||||
let url: string;
|
||||
if (typeof address === 'string') {
|
||||
url = address;
|
||||
} else {
|
||||
const resolvedPort = address.port;
|
||||
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
|
||||
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
|
||||
resolvedHost = 'localhost';
|
||||
url = `http://${resolvedHost}:${resolvedPort}`;
|
||||
}
|
||||
console.log(`Listening on ${url}`);
|
||||
console.log('Put this in your client config:');
|
||||
console.log(JSON.stringify({
|
||||
'mcpServers': {
|
||||
'playwright': {
|
||||
'url': `${url}/sse`
|
||||
}
|
||||
}
|
||||
}, undefined, 2));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,68 +15,62 @@
|
||||
*/
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
import { Context } from './context';
|
||||
|
||||
import type { Tool } from './tools/tool';
|
||||
import type { Resource } from './resources/resource';
|
||||
import type { ContextOptions } from './context';
|
||||
import type { Config } from '../config';
|
||||
|
||||
type Options = ContextOptions & {
|
||||
type MCPServerOptions = {
|
||||
name: string;
|
||||
version: string;
|
||||
tools: Tool[];
|
||||
resources: Resource[],
|
||||
};
|
||||
|
||||
export function createServerWithTools(options: Options): Server {
|
||||
const { name, version, tools, resources } = options;
|
||||
const context = new Context(options);
|
||||
export function createServerWithTools(serverOptions: MCPServerOptions, config: Config): Server {
|
||||
const { name, version, tools } = serverOptions;
|
||||
const context = new Context(tools, config);
|
||||
const server = new Server({ name, version }, {
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: {},
|
||||
}
|
||||
});
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return { tools: tools.map(tool => tool.schema) };
|
||||
});
|
||||
|
||||
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
||||
return { resources: resources.map(resource => resource.schema) };
|
||||
return {
|
||||
tools: tools.map(tool => ({
|
||||
name: tool.schema.name,
|
||||
description: tool.schema.description,
|
||||
inputSchema: zodToJsonSchema(tool.schema.inputSchema)
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async request => {
|
||||
const errorResult = (...messages: string[]) => ({
|
||||
content: [{ type: 'text', text: messages.join('\n') }],
|
||||
isError: true,
|
||||
});
|
||||
const tool = tools.find(tool => tool.schema.name === request.params.name);
|
||||
if (!tool) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Tool "${request.params.name}" not found` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
if (!tool)
|
||||
return errorResult(`Tool "${request.params.name}" not found`);
|
||||
|
||||
|
||||
const modalStates = context.modalStates().map(state => state.type);
|
||||
if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState))
|
||||
return errorResult(`The tool "${request.params.name}" can only be used when there is related modal state present.`, ...context.modalStatesMarkdown());
|
||||
if (!tool.clearsModalState && modalStates.length)
|
||||
return errorResult(`Tool "${request.params.name}" does not handle the modal state.`, ...context.modalStatesMarkdown());
|
||||
|
||||
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) }],
|
||||
isError: true,
|
||||
};
|
||||
return errorResult(String(error));
|
||||
}
|
||||
});
|
||||
|
||||
server.setRequestHandler(ReadResourceRequestSchema, async request => {
|
||||
const resource = resources.find(resource => resource.schema.uri === request.params.uri);
|
||||
if (!resource)
|
||||
return { contents: [] };
|
||||
|
||||
const contents = await resource.read(context, request.params.uri);
|
||||
return { contents };
|
||||
});
|
||||
|
||||
const oldClose = server.close.bind(server);
|
||||
|
||||
server.close = async () => {
|
||||
@@ -89,14 +83,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;
|
||||
}
|
||||
|
||||
92
src/tab.ts
Normal file
92
src/tab.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as playwright from 'playwright';
|
||||
|
||||
import { PageSnapshot } from './pageSnapshot';
|
||||
|
||||
import type { Context } from './context';
|
||||
|
||||
export class Tab {
|
||||
readonly context: Context;
|
||||
readonly page: playwright.Page;
|
||||
private _console: playwright.ConsoleMessage[] = [];
|
||||
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
|
||||
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('request', request => this._requests.set(request, null));
|
||||
page.on('response', response => this._requests.set(response.request(), response));
|
||||
page.on('framenavigated', frame => {
|
||||
if (!frame.parentFrame())
|
||||
this._clearCollectedArtifacts();
|
||||
});
|
||||
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 _clearCollectedArtifacts() {
|
||||
this._console.length = 0;
|
||||
this._requests.clear();
|
||||
}
|
||||
|
||||
private _onClose() {
|
||||
this._clearCollectedArtifacts();
|
||||
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;
|
||||
}
|
||||
|
||||
console(): playwright.ConsoleMessage[] {
|
||||
return this._console;
|
||||
}
|
||||
|
||||
requests(): Map<playwright.Request, playwright.Response | null> {
|
||||
return this._requests;
|
||||
}
|
||||
|
||||
async captureSnapshot() {
|
||||
this._snapshot = await PageSnapshot.create(this.page);
|
||||
}
|
||||
}
|
||||
@@ -15,81 +15,82 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { defineTool, type ToolFactory } from './tool';
|
||||
|
||||
import type { Tool, ToolFactory } from './tool';
|
||||
|
||||
const waitSchema = z.object({
|
||||
time: z.number().describe('The time to wait in seconds'),
|
||||
});
|
||||
|
||||
const wait: Tool = {
|
||||
const wait: ToolFactory = captureSnapshot => defineTool({
|
||||
capability: 'wait',
|
||||
|
||||
schema: {
|
||||
name: 'browser_wait',
|
||||
description: 'Wait for a specified time in seconds',
|
||||
inputSchema: zodToJsonSchema(waitSchema),
|
||||
inputSchema: z.object({
|
||||
time: z.number().describe('The time to wait in seconds'),
|
||||
}),
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = waitSchema.parse(params);
|
||||
await new Promise(f => setTimeout(f, Math.min(10000, validatedParams.time * 1000)));
|
||||
await new Promise(f => setTimeout(f, Math.min(10000, params.time * 1000)));
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Waited for ${validatedParams.time} seconds`,
|
||||
}],
|
||||
code: [`// Waited for ${params.time} seconds`],
|
||||
captureSnapshot,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const closeSchema = z.object({});
|
||||
|
||||
const close: Tool = {
|
||||
const close = defineTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_close',
|
||||
description: 'Close the page',
|
||||
inputSchema: zodToJsonSchema(closeSchema),
|
||||
inputSchema: z.object({}),
|
||||
},
|
||||
|
||||
handle: async context => {
|
||||
await context.close();
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Page closed`,
|
||||
}],
|
||||
code: [`// Internal to close the page`],
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const resizeSchema = z.object({
|
||||
width: z.number().describe('Width of the browser window'),
|
||||
height: z.number().describe('Height of the browser window'),
|
||||
});
|
||||
|
||||
const resize: ToolFactory = captureSnapshot => ({
|
||||
const resize: ToolFactory = captureSnapshot => defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_resize',
|
||||
description: 'Resize the browser window',
|
||||
inputSchema: zodToJsonSchema(resizeSchema),
|
||||
inputSchema: z.object({
|
||||
width: z.number().describe('Width of the browser window'),
|
||||
height: z.number().describe('Height of the browser window'),
|
||||
}),
|
||||
},
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = resizeSchema.parse(params);
|
||||
|
||||
const tab = context.currentTab();
|
||||
return await tab.run(
|
||||
tab => tab.page.setViewportSize({ width: validatedParams.width, height: validatedParams.height }),
|
||||
{
|
||||
status: `Resized browser window`,
|
||||
captureSnapshot,
|
||||
}
|
||||
);
|
||||
handle: async (context, params) => {
|
||||
const tab = context.currentTabOrDie();
|
||||
|
||||
const code = [
|
||||
`// Resize browser window to ${params.width}x${params.height}`,
|
||||
`await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`
|
||||
];
|
||||
|
||||
const action = async () => {
|
||||
await tab.page.setViewportSize({ width: params.width, height: params.height });
|
||||
};
|
||||
|
||||
return {
|
||||
code,
|
||||
action,
|
||||
captureSnapshot,
|
||||
waitForNetwork: true
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default (captureSnapshot: boolean) => [
|
||||
close,
|
||||
wait,
|
||||
wait(captureSnapshot),
|
||||
resize(captureSnapshot)
|
||||
];
|
||||
|
||||
@@ -14,22 +14,32 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Resource } from './resource';
|
||||
import { z } from 'zod';
|
||||
import { defineTool } from './tool';
|
||||
|
||||
export const console: Resource = {
|
||||
const console = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
uri: 'browser://console',
|
||||
name: 'Page console',
|
||||
mimeType: 'text/plain',
|
||||
name: 'browser_console_messages',
|
||||
description: 'Returns all console messages',
|
||||
inputSchema: z.object({}),
|
||||
},
|
||||
|
||||
read: async (context, uri) => {
|
||||
const messages = await context.currentTab().console();
|
||||
handle: async context => {
|
||||
const messages = context.currentTabOrDie().console();
|
||||
const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n');
|
||||
return [{
|
||||
uri,
|
||||
mimeType: 'text/plain',
|
||||
text: log
|
||||
}];
|
||||
return {
|
||||
code: [`// <internal code to get console messages>`],
|
||||
action: async () => {
|
||||
return {
|
||||
content: [{ type: 'text', text: log }]
|
||||
};
|
||||
},
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default [
|
||||
console,
|
||||
];
|
||||
60
src/tools/dialogs.ts
Normal file
60
src/tools/dialogs.ts
Normal 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 { z } from 'zod';
|
||||
import { defineTool, type ToolFactory } from './tool';
|
||||
|
||||
const handleDialog: ToolFactory = captureSnapshot => defineTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_handle_dialog',
|
||||
description: 'Handle a dialog',
|
||||
inputSchema: 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.'),
|
||||
}),
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const dialogState = context.modalStates().find(state => state.type === 'dialog');
|
||||
if (!dialogState)
|
||||
throw new Error('No dialog visible');
|
||||
|
||||
if (params.accept)
|
||||
await dialogState.dialog.accept(params.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),
|
||||
];
|
||||
@@ -15,32 +15,41 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { defineTool, type ToolFactory } from './tool';
|
||||
|
||||
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 => ({
|
||||
const uploadFile: ToolFactory = captureSnapshot => defineTool({
|
||||
capability: 'files',
|
||||
|
||||
schema: {
|
||||
name: 'browser_file_upload',
|
||||
description: 'Upload one or multiple files',
|
||||
inputSchema: zodToJsonSchema(uploadFileSchema),
|
||||
inputSchema: z.object({
|
||||
paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'),
|
||||
}),
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = uploadFileSchema.parse(params);
|
||||
const tab = context.currentTab();
|
||||
return await tab.runAndWait(async () => {
|
||||
await tab.submitFileChooser(validatedParams.paths);
|
||||
}, {
|
||||
status: `Chose files ${validatedParams.paths.join(', ')}`,
|
||||
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 ${params.paths.join(', ')}`,
|
||||
];
|
||||
|
||||
const action = async () => {
|
||||
await modalState.fileChooser.setFiles(params.paths);
|
||||
context.clearModalState(modalState);
|
||||
};
|
||||
|
||||
return {
|
||||
code,
|
||||
action,
|
||||
captureSnapshot,
|
||||
noClearFileChooser: true,
|
||||
});
|
||||
waitForNetwork: true,
|
||||
};
|
||||
},
|
||||
clearsModalState: 'fileChooser',
|
||||
});
|
||||
|
||||
export default (captureSnapshot: boolean) => [
|
||||
|
||||
@@ -18,20 +18,18 @@ import { fork } from 'child_process';
|
||||
import path from 'path';
|
||||
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { defineTool } from './tool';
|
||||
|
||||
import type { Tool } from './tool';
|
||||
|
||||
const install: Tool = {
|
||||
const install = defineTool({
|
||||
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({})),
|
||||
inputSchema: z.object({}),
|
||||
},
|
||||
|
||||
handle: async context => {
|
||||
const channel = context.options.launchOptions?.channel ?? context.options.browserName ?? 'chrome';
|
||||
const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.launchOptions.browserName ?? 'chrome';
|
||||
const cli = path.join(require.resolve('playwright/package.json'), '..', 'cli.js');
|
||||
const child = fork(cli, ['install', channel], {
|
||||
stdio: 'pipe',
|
||||
@@ -48,13 +46,12 @@ const install: Tool = {
|
||||
});
|
||||
});
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Browser ${channel} installed`,
|
||||
}],
|
||||
code: [`// Browser ${channel} installed`],
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default [
|
||||
install,
|
||||
|
||||
@@ -15,29 +15,35 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import zodToJsonSchema from 'zod-to-json-schema';
|
||||
import { defineTool, type ToolFactory } from './tool';
|
||||
|
||||
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 => ({
|
||||
const pressKey: ToolFactory = captureSnapshot => defineTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_press_key',
|
||||
description: 'Press a key on the keyboard',
|
||||
inputSchema: zodToJsonSchema(pressKeySchema),
|
||||
inputSchema: z.object({
|
||||
key: z.string().describe('Name of the key to press or a character to generate, such as `ArrowLeft` or `a`'),
|
||||
}),
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = pressKeySchema.parse(params);
|
||||
return await context.currentTab().runAndWait(async tab => {
|
||||
await tab.page.keyboard.press(validatedParams.key);
|
||||
}, {
|
||||
status: `Pressed key ${validatedParams.key}`,
|
||||
const tab = context.currentTabOrDie();
|
||||
|
||||
const code = [
|
||||
`// Press ${params.key}`,
|
||||
`await page.keyboard.press('${params.key}');`,
|
||||
];
|
||||
|
||||
const action = () => tab.page.keyboard.press(params.key);
|
||||
|
||||
return {
|
||||
code,
|
||||
action,
|
||||
captureSnapshot,
|
||||
});
|
||||
waitForNetwork: true
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -15,68 +15,79 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { defineTool, type ToolFactory } from './tool';
|
||||
|
||||
import type { ToolFactory } from './tool';
|
||||
|
||||
const navigateSchema = z.object({
|
||||
url: z.string().describe('The URL to navigate to'),
|
||||
});
|
||||
|
||||
const navigate: ToolFactory = captureSnapshot => ({
|
||||
const navigate: ToolFactory = captureSnapshot => defineTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_navigate',
|
||||
description: 'Navigate to a URL',
|
||||
inputSchema: zodToJsonSchema(navigateSchema),
|
||||
inputSchema: z.object({
|
||||
url: z.string().describe('The URL to navigate to'),
|
||||
}),
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = navigateSchema.parse(params);
|
||||
const currentTab = await context.ensureTab();
|
||||
return await currentTab.run(async tab => {
|
||||
await tab.navigate(validatedParams.url);
|
||||
}, {
|
||||
status: `Navigated to ${validatedParams.url}`,
|
||||
const tab = await context.ensureTab();
|
||||
await tab.navigate(params.url);
|
||||
|
||||
const code = [
|
||||
`// Navigate to ${params.url}`,
|
||||
`await page.goto('${params.url}');`,
|
||||
];
|
||||
|
||||
return {
|
||||
code,
|
||||
captureSnapshot,
|
||||
});
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const goBackSchema = z.object({});
|
||||
|
||||
const goBack: ToolFactory = snapshot => ({
|
||||
const goBack: ToolFactory = captureSnapshot => defineTool({
|
||||
capability: 'history',
|
||||
schema: {
|
||||
name: 'browser_navigate_back',
|
||||
description: 'Go back to the previous page',
|
||||
inputSchema: zodToJsonSchema(goBackSchema),
|
||||
inputSchema: z.object({}),
|
||||
},
|
||||
|
||||
handle: async context => {
|
||||
return await context.currentTab().runAndWait(async tab => {
|
||||
await tab.page.goBack();
|
||||
}, {
|
||||
status: 'Navigated back',
|
||||
captureSnapshot: snapshot,
|
||||
});
|
||||
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 = snapshot => ({
|
||||
const goForward: ToolFactory = captureSnapshot => defineTool({
|
||||
capability: 'history',
|
||||
schema: {
|
||||
name: 'browser_navigate_forward',
|
||||
description: 'Go forward to the next page',
|
||||
inputSchema: zodToJsonSchema(goForwardSchema),
|
||||
inputSchema: z.object({}),
|
||||
},
|
||||
handle: async context => {
|
||||
return await context.currentTab().runAndWait(async tab => {
|
||||
await tab.page.goForward();
|
||||
}, {
|
||||
status: 'Navigated forward',
|
||||
captureSnapshot: snapshot,
|
||||
});
|
||||
const tab = context.currentTabOrDie();
|
||||
await tab.page.goForward();
|
||||
const code = [
|
||||
`// Navigate forward`,
|
||||
`await page.goForward();`,
|
||||
];
|
||||
return {
|
||||
code,
|
||||
captureSnapshot,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
57
src/tools/network.ts
Normal file
57
src/tools/network.ts
Normal 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 { defineTool } from './tool';
|
||||
|
||||
import type * as playwright from 'playwright';
|
||||
|
||||
const requests = defineTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_network_requests',
|
||||
description: 'Returns all network requests since loading the page',
|
||||
inputSchema: z.object({}),
|
||||
},
|
||||
|
||||
handle: async context => {
|
||||
const requests = context.currentTabOrDie().requests();
|
||||
const log = [...requests.entries()].map(([request, response]) => renderRequest(request, response)).join('\n');
|
||||
return {
|
||||
code: [`// <internal code to list network requests>`],
|
||||
action: async () => {
|
||||
return {
|
||||
content: [{ type: 'text', text: log }]
|
||||
};
|
||||
},
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function renderRequest(request: playwright.Request, response: playwright.Response | null) {
|
||||
const result: string[] = [];
|
||||
result.push(`[${request.method().toUpperCase()}] ${request.url()}`);
|
||||
if (response)
|
||||
result.push(`=> [${response.status()}] ${response.statusText()}`);
|
||||
return result.join(' ');
|
||||
}
|
||||
|
||||
export default [
|
||||
requests,
|
||||
];
|
||||
@@ -14,37 +14,38 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { defineTool } from './tool';
|
||||
|
||||
import { sanitizeForFilePath } from './utils';
|
||||
import * as javascript from '../javascript';
|
||||
import { outputFile } from '../config';
|
||||
|
||||
import type { Tool } from './tool';
|
||||
|
||||
const pdfSchema = z.object({});
|
||||
|
||||
const pdf: Tool = {
|
||||
const pdf = defineTool({
|
||||
capability: 'pdf',
|
||||
|
||||
schema: {
|
||||
name: 'browser_pdf_save',
|
||||
description: 'Save page as PDF',
|
||||
inputSchema: zodToJsonSchema(pdfSchema),
|
||||
inputSchema: z.object({}),
|
||||
},
|
||||
|
||||
handle: async context => {
|
||||
const tab = context.currentTab();
|
||||
const fileName = path.join(os.tmpdir(), sanitizeForFilePath(`page-${new Date().toISOString()}`)) + '.pdf';
|
||||
await tab.page.pdf({ path: fileName });
|
||||
const tab = context.currentTabOrDie();
|
||||
const fileName = await outputFile(context.config, `page-${new Date().toISOString()}'.pdf'`);
|
||||
|
||||
const code = [
|
||||
`// Save page as ${fileName}`,
|
||||
`await page.pdf(${javascript.formatObject({ path: fileName })});`,
|
||||
];
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Saved as ${fileName}`,
|
||||
}],
|
||||
code,
|
||||
action: async () => tab.page.pdf({ path: fileName }).then(() => {}),
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default [
|
||||
pdf,
|
||||
|
||||
@@ -15,131 +15,186 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { defineTool } from './tool';
|
||||
|
||||
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 screenshot = await tab.page.screenshot({ type: 'jpeg', quality: 50, scale: 'css' });
|
||||
return {
|
||||
content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: 'image/jpeg' }],
|
||||
};
|
||||
},
|
||||
};
|
||||
import * as javascript from '../javascript';
|
||||
|
||||
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 screenshot = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_screen_capture',
|
||||
description: 'Take a screenshot of the current page',
|
||||
inputSchema: 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 moveMouse: Tool = {
|
||||
const moveMouse = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_screen_move_mouse',
|
||||
description: 'Move mouse to a given position',
|
||||
inputSchema: zodToJsonSchema(moveMouseSchema),
|
||||
inputSchema: elementSchema.extend({
|
||||
x: z.number().describe('X coordinate'),
|
||||
y: z.number().describe('Y coordinate'),
|
||||
}),
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = moveMouseSchema.parse(params);
|
||||
const tab = context.currentTab();
|
||||
await tab.page.mouse.move(validatedParams.x, validatedParams.y);
|
||||
const tab = context.currentTabOrDie();
|
||||
const code = [
|
||||
`// Move mouse to (${params.x}, ${params.y})`,
|
||||
`await page.mouse.move(${params.x}, ${params.y});`,
|
||||
];
|
||||
const action = () => tab.page.mouse.move(params.x, params.y);
|
||||
return {
|
||||
content: [{ type: 'text', text: `Moved mouse to (${validatedParams.x}, ${validatedParams.y})` }],
|
||||
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 = {
|
||||
const click = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_screen_click',
|
||||
description: 'Click left mouse button',
|
||||
inputSchema: zodToJsonSchema(clickSchema),
|
||||
inputSchema: elementSchema.extend({
|
||||
x: z.number().describe('X coordinate'),
|
||||
y: z.number().describe('Y coordinate'),
|
||||
}),
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
return await context.currentTab().runAndWait(async tab => {
|
||||
const validatedParams = clickSchema.parse(params);
|
||||
await tab.page.mouse.move(validatedParams.x, validatedParams.y);
|
||||
const tab = context.currentTabOrDie();
|
||||
const code = [
|
||||
`// Click mouse at coordinates (${params.x}, ${params.y})`,
|
||||
`await page.mouse.move(${params.x}, ${params.y});`,
|
||||
`await page.mouse.down();`,
|
||||
`await page.mouse.up();`,
|
||||
];
|
||||
const action = async () => {
|
||||
await tab.page.mouse.move(params.x, params.y);
|
||||
await tab.page.mouse.down();
|
||||
await tab.page.mouse.up();
|
||||
}, {
|
||||
status: 'Clicked mouse',
|
||||
});
|
||||
};
|
||||
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 = {
|
||||
const drag = defineTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_screen_drag',
|
||||
description: 'Drag left mouse button',
|
||||
inputSchema: zodToJsonSchema(dragSchema),
|
||||
inputSchema: 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'),
|
||||
}),
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = dragSchema.parse(params);
|
||||
return await context.currentTab().runAndWait(async tab => {
|
||||
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();
|
||||
}, {
|
||||
status: `Dragged mouse from (${validatedParams.startX}, ${validatedParams.startY}) to (${validatedParams.endX}, ${validatedParams.endY})`,
|
||||
});
|
||||
},
|
||||
};
|
||||
const tab = context.currentTabOrDie();
|
||||
|
||||
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 code = [
|
||||
`// Drag mouse from (${params.startX}, ${params.startY}) to (${params.endX}, ${params.endY})`,
|
||||
`await page.mouse.move(${params.startX}, ${params.startY});`,
|
||||
`await page.mouse.down();`,
|
||||
`await page.mouse.move(${params.endX}, ${params.endY});`,
|
||||
`await page.mouse.up();`,
|
||||
];
|
||||
|
||||
const action = async () => {
|
||||
await tab.page.mouse.move(params.startX, params.startY);
|
||||
await tab.page.mouse.down();
|
||||
await tab.page.mouse.move(params.endX, params.endY);
|
||||
await tab.page.mouse.up();
|
||||
};
|
||||
|
||||
return {
|
||||
code,
|
||||
action,
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const type: Tool = {
|
||||
const type = defineTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_screen_type',
|
||||
description: 'Type text',
|
||||
inputSchema: zodToJsonSchema(typeSchema),
|
||||
inputSchema: 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)'),
|
||||
}),
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = typeSchema.parse(params);
|
||||
return await context.currentTab().runAndWait(async tab => {
|
||||
await tab.page.keyboard.type(validatedParams.text);
|
||||
if (validatedParams.submit)
|
||||
const tab = context.currentTabOrDie();
|
||||
|
||||
const code = [
|
||||
`// Type ${params.text}`,
|
||||
`await page.keyboard.type('${params.text}');`,
|
||||
];
|
||||
|
||||
const action = async () => {
|
||||
await tab.page.keyboard.type(params.text);
|
||||
if (params.submit)
|
||||
await tab.page.keyboard.press('Enter');
|
||||
}, {
|
||||
status: `Typed text "${validatedParams.text}"`,
|
||||
});
|
||||
};
|
||||
|
||||
if (params.submit) {
|
||||
code.push(`// Submit text`);
|
||||
code.push(`await page.keyboard.press('Enter');`);
|
||||
}
|
||||
|
||||
return {
|
||||
code,
|
||||
action,
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default [
|
||||
screenshot,
|
||||
|
||||
@@ -15,94 +15,120 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import zodToJsonSchema from 'zod-to-json-schema';
|
||||
|
||||
import { defineTool } from './tool';
|
||||
import * as javascript from '../javascript';
|
||||
import { outputFile } from '../config';
|
||||
|
||||
import type * as playwright from 'playwright';
|
||||
import type { Tool } from './tool';
|
||||
|
||||
const snapshot: Tool = {
|
||||
const snapshot = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_snapshot',
|
||||
description: 'Capture accessibility snapshot of the current page, this is better than screenshot',
|
||||
inputSchema: zodToJsonSchema(z.object({})),
|
||||
inputSchema: z.object({}),
|
||||
},
|
||||
|
||||
handle: async context => {
|
||||
const tab = await context.ensureTab();
|
||||
return await tab.run(async () => {}, { captureSnapshot: true });
|
||||
await context.ensureTab();
|
||||
|
||||
return {
|
||||
code: [`// <internal code to capture accessibility snapshot>`],
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const elementSchema = z.object({
|
||||
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
|
||||
ref: z.string().describe('Exact target element reference from the page snapshot'),
|
||||
});
|
||||
|
||||
const click: Tool = {
|
||||
const click = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_click',
|
||||
description: 'Perform click on a web page',
|
||||
inputSchema: zodToJsonSchema(elementSchema),
|
||||
inputSchema: elementSchema,
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = elementSchema.parse(params);
|
||||
return await context.currentTab().runAndWaitWithSnapshot(async snapshot => {
|
||||
const locator = snapshot.refLocator(validatedParams.ref);
|
||||
await locator.click();
|
||||
}, {
|
||||
status: `Clicked "${validatedParams.element}"`,
|
||||
});
|
||||
},
|
||||
};
|
||||
const tab = context.currentTabOrDie();
|
||||
const locator = tab.snapshotOrDie().refLocator(params.ref);
|
||||
|
||||
const dragSchema = z.object({
|
||||
startElement: z.string().describe('Human-readable source element description used to obtain the permission to interact with the element'),
|
||||
startRef: z.string().describe('Exact source element reference from the page snapshot'),
|
||||
endElement: z.string().describe('Human-readable target element description used to obtain the permission to interact with the element'),
|
||||
endRef: z.string().describe('Exact target element reference from the page snapshot'),
|
||||
const code = [
|
||||
`// Click ${params.element}`,
|
||||
`await page.${await generateLocator(locator)}.click();`
|
||||
];
|
||||
|
||||
return {
|
||||
code,
|
||||
action: () => locator.click(),
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const drag: Tool = {
|
||||
const drag = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_drag',
|
||||
description: 'Perform drag and drop between two elements',
|
||||
inputSchema: zodToJsonSchema(dragSchema),
|
||||
inputSchema: z.object({
|
||||
startElement: z.string().describe('Human-readable source element description used to obtain the permission to interact with the element'),
|
||||
startRef: z.string().describe('Exact source element reference from the page snapshot'),
|
||||
endElement: z.string().describe('Human-readable target element description used to obtain the permission to interact with the element'),
|
||||
endRef: z.string().describe('Exact target element reference from the page snapshot'),
|
||||
}),
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = dragSchema.parse(params);
|
||||
return await context.currentTab().runAndWaitWithSnapshot(async snapshot => {
|
||||
const startLocator = snapshot.refLocator(validatedParams.startRef);
|
||||
const endLocator = snapshot.refLocator(validatedParams.endRef);
|
||||
await startLocator.dragTo(endLocator);
|
||||
}, {
|
||||
status: `Dragged "${validatedParams.startElement}" to "${validatedParams.endElement}"`,
|
||||
});
|
||||
},
|
||||
};
|
||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||
const startLocator = snapshot.refLocator(params.startRef);
|
||||
const endLocator = snapshot.refLocator(params.endRef);
|
||||
|
||||
const hover: Tool = {
|
||||
const code = [
|
||||
`// Drag ${params.startElement} to ${params.endElement}`,
|
||||
`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`
|
||||
];
|
||||
|
||||
return {
|
||||
code,
|
||||
action: () => startLocator.dragTo(endLocator),
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const hover = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_hover',
|
||||
description: 'Hover over element on page',
|
||||
inputSchema: zodToJsonSchema(elementSchema),
|
||||
inputSchema: elementSchema,
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = elementSchema.parse(params);
|
||||
return await context.currentTab().runAndWaitWithSnapshot(async snapshot => {
|
||||
const locator = snapshot.refLocator(validatedParams.ref);
|
||||
await locator.hover();
|
||||
}, {
|
||||
status: `Hovered over "${validatedParams.element}"`,
|
||||
});
|
||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||
const locator = snapshot.refLocator(params.ref);
|
||||
|
||||
const code = [
|
||||
`// Hover over ${params.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'),
|
||||
@@ -110,75 +136,138 @@ const typeSchema = elementSchema.extend({
|
||||
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.'),
|
||||
});
|
||||
|
||||
const type: Tool = {
|
||||
const type = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_type',
|
||||
description: 'Type text into editable element',
|
||||
inputSchema: zodToJsonSchema(typeSchema),
|
||||
inputSchema: typeSchema,
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = typeSchema.parse(params);
|
||||
return await context.currentTab().runAndWaitWithSnapshot(async snapshot => {
|
||||
const locator = snapshot.refLocator(validatedParams.ref);
|
||||
if (validatedParams.slowly)
|
||||
await locator.pressSequentially(validatedParams.text);
|
||||
else
|
||||
await locator.fill(validatedParams.text);
|
||||
if (validatedParams.submit)
|
||||
await locator.press('Enter');
|
||||
}, {
|
||||
status: `Typed "${validatedParams.text}" into "${validatedParams.element}"`,
|
||||
});
|
||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||
const locator = snapshot.refLocator(params.ref);
|
||||
|
||||
const code: string[] = [];
|
||||
const steps: (() => Promise<void>)[] = [];
|
||||
|
||||
if (params.slowly) {
|
||||
code.push(`// Press "${params.text}" sequentially into "${params.element}"`);
|
||||
code.push(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`);
|
||||
steps.push(() => locator.pressSequentially(params.text));
|
||||
} else {
|
||||
code.push(`// Fill "${params.text}" into "${params.element}"`);
|
||||
code.push(`await page.${await generateLocator(locator)}.fill(${javascript.quote(params.text)});`);
|
||||
steps.push(() => locator.fill(params.text));
|
||||
}
|
||||
|
||||
if (params.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,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
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.'),
|
||||
});
|
||||
|
||||
const selectOption: Tool = {
|
||||
const selectOption = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_select_option',
|
||||
description: 'Select an option in a dropdown',
|
||||
inputSchema: zodToJsonSchema(selectOptionSchema),
|
||||
inputSchema: selectOptionSchema,
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = selectOptionSchema.parse(params);
|
||||
return await context.currentTab().runAndWaitWithSnapshot(async snapshot => {
|
||||
const locator = snapshot.refLocator(validatedParams.ref);
|
||||
await locator.selectOption(validatedParams.values);
|
||||
}, {
|
||||
status: `Selected option in "${validatedParams.element}"`,
|
||||
});
|
||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||
const locator = snapshot.refLocator(params.ref);
|
||||
|
||||
const code = [
|
||||
`// Select options [${params.values.join(', ')}] in ${params.element}`,
|
||||
`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`
|
||||
];
|
||||
|
||||
return {
|
||||
code,
|
||||
action: () => locator.selectOption(params.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']
|
||||
});
|
||||
|
||||
const screenshot: Tool = {
|
||||
const screenshot = defineTool({
|
||||
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.`,
|
||||
inputSchema: zodToJsonSchema(screenshotSchema),
|
||||
inputSchema: screenshotSchema,
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = screenshotSchema.parse(params);
|
||||
const tab = context.currentTab();
|
||||
const options: playwright.PageScreenshotOptions = validatedParams.raw ? { type: 'png', scale: 'css' } : { type: 'jpeg', quality: 50, scale: 'css' };
|
||||
const screenshot = await tab.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 = params.raw ? 'png' : 'jpeg';
|
||||
const fileName = await outputFile(context.config, `page-${new Date().toISOString()}.${fileType}`);
|
||||
const options: playwright.PageScreenshotOptions = { type: fileType, quality: fileType === 'png' ? undefined : 50, scale: 'css', path: fileName };
|
||||
const isElementScreenshot = params.element && params.ref;
|
||||
|
||||
const code = [
|
||||
`// Screenshot ${isElementScreenshot ? params.element : 'viewport'} and save it as ${fileName}`,
|
||||
];
|
||||
|
||||
const locator = params.ref ? snapshot.refLocator(params.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 includeBase64 = !context.config.tools?.browser_take_screenshot?.omitBase64;
|
||||
const action = async () => {
|
||||
const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
|
||||
return {
|
||||
content: includeBase64 ? [{
|
||||
type: 'image' as 'image',
|
||||
data: screenshot.toString('base64'),
|
||||
mimeType: fileType === 'png' ? 'image/png' : 'image/jpeg',
|
||||
}] : []
|
||||
};
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
code,
|
||||
action,
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
||||
return (locator as any)._generateLocatorString();
|
||||
}
|
||||
|
||||
export default [
|
||||
snapshot,
|
||||
|
||||
@@ -15,95 +15,112 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { defineTool, type ToolFactory } from './tool';
|
||||
|
||||
import type { ToolFactory, Tool } from './tool';
|
||||
|
||||
const listTabs: Tool = {
|
||||
const listTabs = defineTool({
|
||||
capability: 'tabs',
|
||||
|
||||
schema: {
|
||||
name: 'browser_tab_list',
|
||||
description: 'List browser tabs',
|
||||
inputSchema: zodToJsonSchema(z.object({})),
|
||||
inputSchema: z.object({}),
|
||||
},
|
||||
|
||||
handle: async context => {
|
||||
await context.ensureTab();
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: await context.listTabs(),
|
||||
}],
|
||||
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 => ({
|
||||
const selectTab: ToolFactory = captureSnapshot => defineTool({
|
||||
capability: 'tabs',
|
||||
|
||||
schema: {
|
||||
name: 'browser_tab_select',
|
||||
description: 'Select a tab by index',
|
||||
inputSchema: zodToJsonSchema(selectTabSchema),
|
||||
inputSchema: z.object({
|
||||
index: z.number().describe('The index of the tab to select'),
|
||||
}),
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = selectTabSchema.parse(params);
|
||||
await context.selectTab(validatedParams.index);
|
||||
const currentTab = await context.ensureTab();
|
||||
return await currentTab.run(async () => {}, { captureSnapshot });
|
||||
await context.selectTab(params.index);
|
||||
const code = [
|
||||
`// <internal code to select tab ${params.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: Tool = {
|
||||
const newTab: ToolFactory = captureSnapshot => defineTool({
|
||||
capability: 'tabs',
|
||||
|
||||
schema: {
|
||||
name: 'browser_tab_new',
|
||||
description: 'Open a new tab',
|
||||
inputSchema: zodToJsonSchema(newTabSchema),
|
||||
inputSchema: 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.'),
|
||||
}),
|
||||
},
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = newTabSchema.parse(params);
|
||||
await context.newTab();
|
||||
if (validatedParams.url)
|
||||
await context.currentTab().navigate(validatedParams.url);
|
||||
return await context.currentTab().run(async () => {}, { captureSnapshot: true });
|
||||
},
|
||||
};
|
||||
|
||||
const closeTabSchema = z.object({
|
||||
index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
|
||||
handle: async (context, params) => {
|
||||
await context.newTab();
|
||||
if (params.url)
|
||||
await context.currentTabOrDie().navigate(params.url);
|
||||
|
||||
const code = [
|
||||
`// <internal code to open a new tab>`,
|
||||
];
|
||||
return {
|
||||
code,
|
||||
captureSnapshot,
|
||||
waitForNetwork: false
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const closeTab: ToolFactory = captureSnapshot => ({
|
||||
const closeTab: ToolFactory = captureSnapshot => defineTool({
|
||||
capability: 'tabs',
|
||||
|
||||
schema: {
|
||||
name: 'browser_tab_close',
|
||||
description: 'Close a tab',
|
||||
inputSchema: zodToJsonSchema(closeTabSchema),
|
||||
inputSchema: z.object({
|
||||
index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
|
||||
}),
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = closeTabSchema.parse(params);
|
||||
await context.closeTab(validatedParams.index);
|
||||
const currentTab = context.currentTab();
|
||||
if (currentTab)
|
||||
return await currentTab.run(async () => {}, { captureSnapshot });
|
||||
await context.closeTab(params.index);
|
||||
const code = [
|
||||
`// <internal code to close tab ${params.index}>`,
|
||||
];
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: await context.listTabs(),
|
||||
}],
|
||||
code,
|
||||
captureSnapshot,
|
||||
waitForNetwork: false
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default (captureSnapshot: boolean) => [
|
||||
listTabs,
|
||||
newTab,
|
||||
newTab(captureSnapshot),
|
||||
selectTab(captureSnapshot),
|
||||
closeTab(captureSnapshot),
|
||||
];
|
||||
|
||||
@@ -15,26 +15,52 @@
|
||||
*/
|
||||
|
||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
|
||||
import type { JsonSchema7Type } from 'zod-to-json-schema';
|
||||
import type { z } from 'zod';
|
||||
import type { Context } from '../context';
|
||||
import type * as playwright from 'playwright';
|
||||
import type { ToolCapability } from '../../config';
|
||||
|
||||
export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install';
|
||||
|
||||
export type ToolSchema = {
|
||||
export type ToolSchema<Input extends InputType> = {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: JsonSchema7Type;
|
||||
inputSchema: Input;
|
||||
};
|
||||
|
||||
type InputType = z.Schema;
|
||||
|
||||
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 = {
|
||||
export type Tool<Input extends InputType = InputType> = {
|
||||
capability: ToolCapability;
|
||||
schema: ToolSchema;
|
||||
handle: (context: Context, params?: Record<string, any>) => Promise<ToolResult>;
|
||||
schema: ToolSchema<Input>;
|
||||
clearsModalState?: ModalState['type'];
|
||||
handle: (context: Context, params: z.output<Input>) => Promise<ToolResult>;
|
||||
};
|
||||
|
||||
export type ToolFactory = (snapshot: boolean) => Tool;
|
||||
export type ToolFactory = (snapshot: boolean) => Tool<any>;
|
||||
|
||||
export function defineTool<Input extends InputType>(tool: Tool<Input>): Tool<Input> {
|
||||
return tool;
|
||||
}
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
*/
|
||||
|
||||
import type * as playwright from 'playwright';
|
||||
import type { Context } from '../context';
|
||||
|
||||
export 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 = () => {};
|
||||
@@ -62,7 +63,7 @@ export async function waitForCompletion<R>(page: playwright.Page, callback: () =
|
||||
if (!requests.size && !frameNavigated)
|
||||
waitCallback();
|
||||
await waitBarrier;
|
||||
await page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
|
||||
await context.waitForTimeout(1000);
|
||||
return result;
|
||||
} finally {
|
||||
dispose();
|
||||
|
||||
127
src/transport.ts
Normal file
127
src/transport.ts
Normal 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.
|
||||
*/
|
||||
|
||||
import http from 'node:http';
|
||||
import assert from 'node:assert';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import { ServerList } from './server';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
|
||||
export async function startStdioTransport(serverList: ServerList) {
|
||||
const server = await serverList.create();
|
||||
await server.connect(new StdioServerTransport());
|
||||
}
|
||||
|
||||
async function handleSSE(req: http.IncomingMessage, res: http.ServerResponse, url: URL, serverList: ServerList, sessions: Map<string, SSEServerTransport>) {
|
||||
if (req.method === 'POST') {
|
||||
const sessionId = url.searchParams.get('sessionId');
|
||||
if (!sessionId) {
|
||||
res.statusCode = 400;
|
||||
return res.end('Missing sessionId');
|
||||
}
|
||||
|
||||
const transport = sessions.get(sessionId);
|
||||
if (!transport) {
|
||||
res.statusCode = 404;
|
||||
return res.end('Session not found');
|
||||
}
|
||||
|
||||
return await transport.handlePostMessage(req, res);
|
||||
} else if (req.method === 'GET') {
|
||||
const transport = new SSEServerTransport('/sse', res);
|
||||
sessions.set(transport.sessionId, transport);
|
||||
const server = await serverList.create();
|
||||
res.on('close', () => {
|
||||
sessions.delete(transport.sessionId);
|
||||
serverList.close(server).catch(e => console.error(e));
|
||||
});
|
||||
return await server.connect(transport);
|
||||
}
|
||||
|
||||
res.statusCode = 405;
|
||||
res.end('Method not allowed');
|
||||
}
|
||||
|
||||
async function handleStreamable(req: http.IncomingMessage, res: http.ServerResponse, serverList: ServerList, sessions: Map<string, StreamableHTTPServerTransport>) {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (sessionId) {
|
||||
const transport = sessions.get(sessionId);
|
||||
if (!transport) {
|
||||
res.statusCode = 404;
|
||||
res.end('Session not found');
|
||||
return;
|
||||
}
|
||||
return await transport.handleRequest(req, res);
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => crypto.randomUUID(),
|
||||
onsessioninitialized: sessionId => {
|
||||
sessions.set(sessionId, transport);
|
||||
}
|
||||
});
|
||||
transport.onclose = () => {
|
||||
if (transport.sessionId)
|
||||
sessions.delete(transport.sessionId);
|
||||
};
|
||||
const server = await serverList.create();
|
||||
await server.connect(transport);
|
||||
return await transport.handleRequest(req, res);
|
||||
}
|
||||
|
||||
res.statusCode = 400;
|
||||
res.end('Invalid request');
|
||||
}
|
||||
|
||||
export function startHttpTransport(port: number, hostname: string | undefined, serverList: ServerList) {
|
||||
const sseSessions = new Map<string, SSEServerTransport>();
|
||||
const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
|
||||
const httpServer = http.createServer(async (req, res) => {
|
||||
const url = new URL(`http://localhost${req.url}`);
|
||||
if (url.pathname.startsWith('/mcp'))
|
||||
await handleStreamable(req, res, serverList, streamableSessions);
|
||||
else
|
||||
await handleSSE(req, res, url, serverList, sseSessions);
|
||||
});
|
||||
httpServer.listen(port, hostname, () => {
|
||||
const address = httpServer.address();
|
||||
assert(address, 'Could not bind server socket');
|
||||
let url: string;
|
||||
if (typeof address === 'string') {
|
||||
url = address;
|
||||
} else {
|
||||
const resolvedPort = address.port;
|
||||
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
|
||||
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
|
||||
resolvedHost = 'localhost';
|
||||
url = `http://${resolvedHost}:${resolvedPort}`;
|
||||
}
|
||||
console.log(`Listening on ${url}`);
|
||||
console.log('Put this in your client config:');
|
||||
console.log(JSON.stringify({
|
||||
'mcpServers': {
|
||||
'playwright': {
|
||||
'url': `${url}/sse`
|
||||
}
|
||||
}
|
||||
}, undefined, 2));
|
||||
console.log('If your client supports streamable HTTP, you can use the /mcp endpoint instead.');
|
||||
});
|
||||
}
|
||||
@@ -20,8 +20,10 @@ 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',
|
||||
@@ -30,6 +32,7 @@ test('test snapshot tool list', async ({ client }) => {
|
||||
'browser_navigate_back',
|
||||
'browser_navigate_forward',
|
||||
'browser_navigate',
|
||||
'browser_network_requests',
|
||||
'browser_pdf_save',
|
||||
'browser_press_key',
|
||||
'browser_resize',
|
||||
@@ -47,11 +50,14 @@ 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_network_requests',
|
||||
'browser_pdf_save',
|
||||
'browser_press_key',
|
||||
'browser_resize',
|
||||
@@ -68,16 +74,6 @@ test('test vision tool list', async ({ visionClient }) => {
|
||||
]));
|
||||
});
|
||||
|
||||
test('test resources list', async ({ client }) => {
|
||||
const { resources } = await client.listResources();
|
||||
expect(resources).toEqual([
|
||||
expect.objectContaining({
|
||||
uri: 'browser://console',
|
||||
mimeType: 'text/plain',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('test capabilities', async ({ startClient }) => {
|
||||
const client = await startClient({
|
||||
args: ['--caps="core"'],
|
||||
|
||||
@@ -23,17 +23,7 @@ test('cdp server', async ({ cdpEndpoint, startClient }) => {
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
})).toHaveTextContent(`
|
||||
Navigated to 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!
|
||||
\`\`\`
|
||||
`
|
||||
);
|
||||
})).toContainTextContent(`- text: Hello, world!`);
|
||||
});
|
||||
|
||||
test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
|
||||
@@ -51,6 +41,11 @@ test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
|
||||
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
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('browser://console', async ({ client }) => {
|
||||
test('browser_console_messages', async ({ client }) => {
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
@@ -24,12 +24,12 @@ test('browser://console', async ({ client }) => {
|
||||
},
|
||||
});
|
||||
|
||||
const resource = await client.readResource({
|
||||
uri: 'browser://console',
|
||||
const resource = await client.callTool({
|
||||
name: 'browser_console_messages',
|
||||
arguments: {},
|
||||
});
|
||||
expect(resource.contents).toEqual([{
|
||||
uri: 'browser://console',
|
||||
mimeType: 'text/plain',
|
||||
text: '[LOG] Hello, world!\n[ERROR] Error',
|
||||
}]);
|
||||
expect(resource).toHaveTextContent([
|
||||
'[LOG] Hello, world!',
|
||||
'[ERROR] Error',
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('browser_navigate', async ({ client }) => {
|
||||
@@ -24,7 +23,11 @@ test('browser_navigate', async ({ client }) => {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
})).toHaveTextContent(`
|
||||
Navigated to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
||||
- 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
|
||||
@@ -50,7 +53,12 @@ test('browser_click', async ({ client }) => {
|
||||
element: 'Submit button',
|
||||
ref: 's1e3',
|
||||
},
|
||||
})).toHaveTextContent(`Clicked "Submit button"
|
||||
})).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
|
||||
@@ -61,7 +69,6 @@ test('browser_click', async ({ client }) => {
|
||||
`);
|
||||
});
|
||||
|
||||
|
||||
test('browser_select_option', async ({ client }) => {
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
@@ -77,15 +84,20 @@ test('browser_select_option', async ({ client }) => {
|
||||
ref: 's1e3',
|
||||
values: ['bar'],
|
||||
},
|
||||
})).toHaveTextContent(`Selected option in "Select"
|
||||
})).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]
|
||||
- option "Foo"
|
||||
- option "Bar" [selected]
|
||||
\`\`\`
|
||||
`);
|
||||
});
|
||||
@@ -105,77 +117,25 @@ test('browser_select_option (multiple)', async ({ client }) => {
|
||||
ref: 's1e3',
|
||||
values: ['bar', 'baz'],
|
||||
},
|
||||
})).toHaveTextContent(`Selected option in "Select"
|
||||
})).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]
|
||||
- option "Foo" [ref=s2e4]
|
||||
- option "Bar" [selected] [ref=s2e5]
|
||||
- option "Baz" [selected] [ref=s2e6]
|
||||
\`\`\`
|
||||
`);
|
||||
});
|
||||
|
||||
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('There is a file chooser visible that requires browser_file_upload to be called');
|
||||
|
||||
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('There is a file chooser visible that requires browser_file_upload 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_file_upload 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_file_upload dismisses file chooser').not.toContainTextContent('There is a file chooser visible that requires browser_file_upload to be called');
|
||||
}
|
||||
});
|
||||
|
||||
test('browser_type', async ({ client }) => {
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
@@ -192,14 +152,10 @@ test('browser_type', async ({ client }) => {
|
||||
submit: true,
|
||||
},
|
||||
});
|
||||
const resource = await client.readResource({
|
||||
uri: 'browser://console',
|
||||
});
|
||||
expect(resource.contents).toEqual([{
|
||||
uri: 'browser://console',
|
||||
mimeType: 'text/plain',
|
||||
text: '[LOG] Key pressed: Enter , Text: Hi!',
|
||||
}]);
|
||||
expect(await client.callTool({
|
||||
name: 'browser_console_messages',
|
||||
arguments: {},
|
||||
})).toHaveTextContent('[LOG] Key pressed: Enter , Text: Hi!');
|
||||
});
|
||||
|
||||
test('browser_type (slowly)', async ({ client }) => {
|
||||
@@ -219,19 +175,15 @@ test('browser_type (slowly)', async ({ client }) => {
|
||||
slowly: true,
|
||||
},
|
||||
});
|
||||
const resource = await client.readResource({
|
||||
uri: 'browser://console',
|
||||
});
|
||||
expect(resource.contents).toEqual([{
|
||||
uri: 'browser://console',
|
||||
mimeType: 'text/plain',
|
||||
text: [
|
||||
'[LOG] Key pressed: H Text: ',
|
||||
'[LOG] Key pressed: i Text: H',
|
||||
'[LOG] Key pressed: ! Text: Hi',
|
||||
'[LOG] Key pressed: Enter Text: Hi!',
|
||||
].join('\n'),
|
||||
}]);
|
||||
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 }) => {
|
||||
@@ -249,6 +201,10 @@ test('browser_resize', async ({ client }) => {
|
||||
height: 780,
|
||||
},
|
||||
});
|
||||
expect(response).toContainTextContent('Resized browser window');
|
||||
expect(response).toContainTextContent('Window size: 390x780');
|
||||
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', arguments: {} })).toContainTextContent('Window size: 390x780');
|
||||
});
|
||||
43
tests/device.spec.ts
Normal file
43
tests/device.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 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 ({ startClient, server }) => {
|
||||
const client = await startClient({
|
||||
args: ['--device', 'iPhone 15'],
|
||||
});
|
||||
|
||||
server.route('/', (req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<body></body>
|
||||
<script>
|
||||
document.body.textContent = window.innerWidth + "x" + window.innerHeight;
|
||||
</script>
|
||||
`);
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: server.PREFIX,
|
||||
},
|
||||
})).toContainTextContent(`393x659`);
|
||||
});
|
||||
192
tests/dialogs.spec.ts
Normal file
192
tests/dialogs.spec.ts
Normal 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
|
||||
\`\`\``);
|
||||
});
|
||||
98
tests/files.spec.ts
Normal file
98
tests/files.spec.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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(`
|
||||
\`\`\`yaml
|
||||
- generic [ref=s1e2]:
|
||||
- button "Choose File" [ref=s1e3]
|
||||
- button "Button" [ref=s1e4]
|
||||
\`\`\``);
|
||||
|
||||
{
|
||||
expect(await client.callTool({
|
||||
name: 'browser_file_upload',
|
||||
arguments: { paths: [] },
|
||||
})).toHaveTextContent(`
|
||||
The tool "browser_file_upload" can only be used when there is related modal state present.
|
||||
### Modal state
|
||||
- There is no modal state present
|
||||
`.trim());
|
||||
}
|
||||
|
||||
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(`
|
||||
\`\`\`yaml
|
||||
- generic [ref=s3e2]:
|
||||
- button "Choose File" [ref=s3e3]
|
||||
- button "Button" [ref=s3e4]
|
||||
\`\`\``);
|
||||
}
|
||||
|
||||
{
|
||||
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`);
|
||||
}
|
||||
});
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
@@ -21,16 +22,27 @@ 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';
|
||||
import { TestServer } from './testserver';
|
||||
|
||||
type Fixtures = {
|
||||
import type { Config } from '../config';
|
||||
|
||||
type TestFixtures = {
|
||||
client: Client;
|
||||
visionClient: Client;
|
||||
startClient: (options?: { args?: string[] }) => Promise<Client>;
|
||||
startClient: (options?: { args?: string[], config?: Config }) => Promise<Client>;
|
||||
wsEndpoint: string;
|
||||
cdpEndpoint: string;
|
||||
server: TestServer;
|
||||
httpsServer: TestServer;
|
||||
mcpHeadless: boolean;
|
||||
mcpBrowser: string | undefined;
|
||||
};
|
||||
|
||||
export const test = baseTest.extend<Fixtures>({
|
||||
type WorkerFixtures = {
|
||||
_workerServers: { server: TestServer, httpsServer: TestServer };
|
||||
};
|
||||
|
||||
export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
|
||||
|
||||
client: async ({ startClient }, use) => {
|
||||
await use(await startClient());
|
||||
@@ -40,19 +52,28 @@ export const test = baseTest.extend<Fixtures>({
|
||||
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;
|
||||
let client: Client | undefined;
|
||||
|
||||
use(async options => {
|
||||
const args = ['--headless', '--user-data-dir', userDataDir];
|
||||
await use(async options => {
|
||||
const args = ['--user-data-dir', userDataDir];
|
||||
if (mcpHeadless)
|
||||
args.push('--headless');
|
||||
if (mcpBrowser)
|
||||
args.push(`--browser=${mcpBrowser}`);
|
||||
if (options?.args)
|
||||
args.push(...options.args);
|
||||
if (options?.config) {
|
||||
const configFile = testInfo.outputPath('config.json');
|
||||
await fs.promises.writeFile(configFile, JSON.stringify(options.config, null, 2));
|
||||
args.push(`--config=${configFile}`);
|
||||
}
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
args: [path.join(__dirname, '../cli.js'), ...args],
|
||||
});
|
||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||
client = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
await client.ping();
|
||||
return client;
|
||||
@@ -76,6 +97,7 @@ export const test = baseTest.extend<Fixtures>({
|
||||
`--no-first-run`,
|
||||
`--no-sandbox`,
|
||||
`--headless`,
|
||||
'--use-mock-keychain',
|
||||
`data:text/html,hello world`,
|
||||
], {
|
||||
stdio: 'pipe',
|
||||
@@ -89,6 +111,37 @@ export const test = baseTest.extend<Fixtures>({
|
||||
await use(`http://localhost:${port}`);
|
||||
browserProcess.kill();
|
||||
},
|
||||
|
||||
mcpHeadless: async ({ headless }, use) => {
|
||||
await use(headless);
|
||||
},
|
||||
|
||||
mcpBrowser: ['chrome', { option: true }],
|
||||
|
||||
_workerServers: [async ({}, use, workerInfo) => {
|
||||
const port = 8907 + workerInfo.workerIndex * 4;
|
||||
const server = await TestServer.create(port);
|
||||
|
||||
const httpsPort = port + 1;
|
||||
const httpsServer = await TestServer.createHTTPS(httpsPort);
|
||||
|
||||
await use({ server, httpsServer });
|
||||
|
||||
await Promise.all([
|
||||
server.stop(),
|
||||
httpsServer.stop(),
|
||||
]);
|
||||
}, { scope: 'worker' }],
|
||||
|
||||
server: async ({ _workerServers }, use) => {
|
||||
_workerServers.server.reset();
|
||||
await use(_workerServers.server);
|
||||
},
|
||||
|
||||
httpsServer: async ({ _workerServers }, use) => {
|
||||
_workerServers.httpsServer.reset();
|
||||
await use(_workerServers.httpsServer);
|
||||
},
|
||||
});
|
||||
|
||||
type Response = Awaited<ReturnType<Client['callTool']>>;
|
||||
|
||||
49
tests/headed.spec.ts
Normal file
49
tests/headed.spec.ts
Normal 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 { test, expect } from './fixtures';
|
||||
|
||||
for (const mcpHeadless of [false, true]) {
|
||||
test.describe(`mcpHeadless: ${mcpHeadless}`, () => {
|
||||
test.use({ mcpHeadless });
|
||||
test.skip(process.platform === 'linux', 'Auto-detection wont let this test run on linux');
|
||||
test('browser', async ({ client, server, mcpBrowser }) => {
|
||||
test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser ?? ''), 'Only chrome is supported for this test');
|
||||
server.route('/', (req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<body></body>
|
||||
<script>
|
||||
document.body.textContent = navigator.userAgent;
|
||||
</script>
|
||||
`);
|
||||
});
|
||||
|
||||
const response = await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: server.PREFIX,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response).toContainTextContent(`Mozilla/5.0`);
|
||||
if (mcpHeadless)
|
||||
expect(response).toContainTextContent(`HeadlessChrome`);
|
||||
else
|
||||
expect(response).not.toContainTextContent(`HeadlessChrome`);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -24,14 +24,15 @@ test('stitched aria frames', async ({ client }) => {
|
||||
},
|
||||
})).toContainTextContent(`
|
||||
\`\`\`yaml
|
||||
- heading "Hello" [level=1] [ref=s1e3]
|
||||
- iframe [ref=s1e4]:
|
||||
- button "World" [ref=f1s1e3]
|
||||
- main [ref=f1s1e4]:
|
||||
- generic [ref=s1e2]:
|
||||
- heading "Hello" [level=1] [ref=s1e3]
|
||||
- iframe [ref=s1e4]:
|
||||
- generic [ref=f1s1e2]:
|
||||
- button "World" [ref=f1s1e3]
|
||||
- main [ref=f1s1e4]:
|
||||
- iframe [ref=f1s1e5]:
|
||||
- paragraph [ref=f2s1e3]: Nested
|
||||
\`\`\`
|
||||
`);
|
||||
- paragraph [ref=f2s1e3]: Nested
|
||||
\`\`\``);
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
@@ -39,5 +40,5 @@ test('stitched aria frames', async ({ client }) => {
|
||||
element: 'World',
|
||||
ref: 'f1s1e3',
|
||||
},
|
||||
})).toContainTextContent('Clicked "World"');
|
||||
})).toContainTextContent(`// Click World`);
|
||||
});
|
||||
|
||||
@@ -26,23 +26,15 @@ test('test reopen browser', async ({ client }) => {
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_close',
|
||||
})).toHaveTextContent('Page closed');
|
||||
arguments: {},
|
||||
})).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>',
|
||||
},
|
||||
})).toHaveTextContent(`
|
||||
Navigated to 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!
|
||||
\`\`\`
|
||||
`);
|
||||
})).toContainTextContent(`- text: Hello, world!`);
|
||||
});
|
||||
|
||||
test('executable path', async ({ startClient }) => {
|
||||
|
||||
49
tests/network.spec.ts
Normal file
49
tests/network.spec.ts
Normal 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 { test, expect } from './fixtures';
|
||||
|
||||
test('browser_network_requests', async ({ client, server }) => {
|
||||
server.route('/', (req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(`<button onclick="fetch('/json')">Click me</button>`);
|
||||
});
|
||||
|
||||
server.route('/json', (req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ name: 'John Doe' }));
|
||||
});
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: server.PREFIX,
|
||||
},
|
||||
});
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Click me button',
|
||||
ref: 's1e3',
|
||||
},
|
||||
});
|
||||
|
||||
await expect.poll(() => client.callTool({
|
||||
name: 'browser_network_requests',
|
||||
arguments: {},
|
||||
})).toHaveTextContent(`[GET] http://localhost:${server.PORT}/json => [200] OK`);
|
||||
});
|
||||
@@ -30,26 +30,18 @@ test('save as pdf unavailable', async ({ startClient }) => {
|
||||
})).toHaveTextContent(/Tool \"browser_pdf_save\" not found/);
|
||||
});
|
||||
|
||||
test('save as pdf', async ({ client }) => {
|
||||
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>',
|
||||
},
|
||||
})).toHaveTextContent(`
|
||||
Navigated to 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!
|
||||
\`\`\`
|
||||
`
|
||||
);
|
||||
})).toContainTextContent(`- text: Hello, world!`);
|
||||
|
||||
const response = await client.callTool({
|
||||
name: 'browser_pdf_save',
|
||||
arguments: {},
|
||||
});
|
||||
expect(response).toHaveTextContent(/^Saved as.*page-[^:]+.pdf$/);
|
||||
expect(response).toHaveTextContent(/Save page as.*page-[^:]+.pdf/);
|
||||
});
|
||||
|
||||
131
tests/screenshot.spec.ts
Normal file
131
tests/screenshot.spec.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('browser_take_screenshot (outputDir)', async ({ startClient }, testInfo) => {
|
||||
const outputDir = testInfo.outputPath('output');
|
||||
const client = await startClient({
|
||||
config: { outputDir },
|
||||
});
|
||||
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`);
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_take_screenshot',
|
||||
arguments: {},
|
||||
});
|
||||
|
||||
expect(fs.existsSync(outputDir)).toBeTruthy();
|
||||
expect([...fs.readdirSync(outputDir)]).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('browser_take_screenshot (omitBase64)', async ({ startClient }) => {
|
||||
const client = await startClient({
|
||||
config: {
|
||||
tools: {
|
||||
browser_take_screenshot: {
|
||||
omitBase64: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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`);
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_take_screenshot',
|
||||
arguments: {},
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_take_screenshot',
|
||||
arguments: {},
|
||||
})).toEqual({
|
||||
content: [
|
||||
{
|
||||
text: expect.stringContaining(`Screenshot viewport and save it as`),
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -16,27 +16,45 @@
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { test } from './fixtures';
|
||||
import { test as baseTest } from './fixtures';
|
||||
import { expect } from 'playwright/test';
|
||||
|
||||
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]);
|
||||
}));
|
||||
const test = baseTest.extend<{ serverEndpoint: string }>({
|
||||
serverEndpoint: async ({}, use) => {
|
||||
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();
|
||||
}
|
||||
await use(url);
|
||||
} finally {
|
||||
cp.kill();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
test('sse transport', async ({ serverEndpoint }) => {
|
||||
// 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(serverEndpoint));
|
||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
await client.ping();
|
||||
});
|
||||
|
||||
test('streamable http transport', async ({ serverEndpoint }) => {
|
||||
// need dynamic import b/c of some ESM nonsense
|
||||
const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/sdk/client/streamableHttp.js');
|
||||
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
|
||||
const transport = new StreamableHTTPClientTransport(new URL('/mcp', serverEndpoint));
|
||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
await client.ping();
|
||||
expect(transport.sessionId, 'has session support').toBeDefined();
|
||||
});
|
||||
|
||||
@@ -29,13 +29,36 @@ async function createTab(client: Client, title: string, body: string) {
|
||||
});
|
||||
}
|
||||
|
||||
test('list initial tabs', async ({ client }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_tab_list',
|
||||
arguments: {},
|
||||
})).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',
|
||||
arguments: {},
|
||||
})).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(`
|
||||
Open tabs:
|
||||
- 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:
|
||||
### Current tab
|
||||
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
||||
- Page Title: Tab one
|
||||
- Page Snapshot
|
||||
@@ -44,12 +67,17 @@ Current tab:
|
||||
\`\`\``);
|
||||
|
||||
expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(`
|
||||
Open tabs:
|
||||
- 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:
|
||||
### Current tab
|
||||
- Page URL: data:text/html,<title>Tab two</title><body>Body two</body>
|
||||
- Page Title: Tab two
|
||||
- Page Snapshot
|
||||
@@ -67,12 +95,17 @@ test('select tab', async ({ client }) => {
|
||||
index: 2,
|
||||
},
|
||||
})).toHaveTextContent(`
|
||||
Open tabs:
|
||||
- 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:
|
||||
### Current tab
|
||||
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
||||
- Page Title: Tab one
|
||||
- Page Snapshot
|
||||
@@ -90,11 +123,16 @@ test('close tab', async ({ client }) => {
|
||||
index: 3,
|
||||
},
|
||||
})).toHaveTextContent(`
|
||||
Open tabs:
|
||||
- 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:
|
||||
### Current tab
|
||||
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
||||
- Page Title: Tab one
|
||||
- Page Snapshot
|
||||
|
||||
29
tests/testserver/cert.pem
Normal file
29
tests/testserver/cert.pem
Normal file
@@ -0,0 +1,29 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFCjCCAvKgAwIBAgIULU/gkDm8IqC7PG8u3RID0AYyP6gwDQYJKoZIhvcNAQEL
|
||||
BQAwGjEYMBYGA1UEAwwPcGxheXdyaWdodC10ZXN0MB4XDTIzMDgxMDIyNTc1MFoX
|
||||
DTMzMDgwNzIyNTc1MFowGjEYMBYGA1UEAwwPcGxheXdyaWdodC10ZXN0MIICIjAN
|
||||
BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArbS99qjKcnHr5G0Zc2xhDaOZnjQv
|
||||
Fbiqxf/nbXt/7WaqryzpVKu7AT1ainBvuPEo7If9DhVnfF//2pGl0gbU31OU4/mr
|
||||
ymQmczGEyZvOBDsZhtCif54o5OoO0BjhODNT8OWec9RT87n6RkH58MHlOi8xsPxQ
|
||||
9n5U1CN/h2DyQF3aRKunEFCgtwPKWSjG+J/TAI9i0aSENXPiR8wjTrjg79s8Ehuj
|
||||
NN8Wk6rKLU3sepG3GIMID5vLsVa2t9xqn562sP95Ee+Xp2YX3z7oYK99QCJdzacw
|
||||
alhMHob1GCEKjDyxsD2IFRi7Dysiutfyzy3pMo6NALxFrwKVhWX0L4zVFIsI6JlV
|
||||
dK8dHmDk0MRSqgB9sWXvEfSTXADEe8rncFSFpFz4Z8RNLmn5YSzQJzokNn41DUCP
|
||||
dZTlTkcGTqvn5NqoY4sOV8rkFbgmTcqyijV/sebPjxCbJNcNmaSWa9FJ5IjRTpzM
|
||||
38wLmxn+eKGK68n2JB3P7JP6LtsBShQEpXAF3rFfyNsP1bjquvGZVSjV8w/UwPE4
|
||||
kV5eq3j3D4913Zfxvzjp6PEmhStG0EQtIXvx/TRoYpaNWypIgZdbkZQp1HUIQL15
|
||||
D2Web4nazP3so1FC3ZgbrJZ2ozoadjLMp49NcSFdh+WRyVKuo0DIqR0zaiAzzf2D
|
||||
G1q7TLKimM3XBMUCAwEAAaNIMEYwCQYDVR0TBAIwADALBgNVHQ8EBAMCBeAwLAYD
|
||||
VR0RBCUwI4IJbG9jYWxob3N0hwR/AAABhxAAAAAAAAAAAAAAAAAAAAABMA0GCSqG
|
||||
SIb3DQEBCwUAA4ICAQAvC5M1JFc21WVSLPvE2iVbt4HmirO3EENdDqs+rTYG5VJG
|
||||
iE5ZuI6h/LjS5ptTfKovXQKaMr3pwp1pLMd/9q+6ZR1Hs9Z2wF6OZan4sb0uT32Y
|
||||
1KGlj86QMiiSLdrJ/1Z9JHskHYNCep1ZTsUhGk0qqiNv+G3K2y7ZpvrT/xlnYMth
|
||||
KLTuSVUwM8BBEPrCRLoXuaEy0LnvMvMVepIfP8tnMIL6zqmj3hXMPe4r4OFV/C5o
|
||||
XX25bC7GyuPWIRYn2OWP92J1CODZD1rGRoDtmvqrQpHdeX9RYcKH0ZLZoIf5L3Hf
|
||||
pPUtVkw3QGtjvKeG3b9usxaV9Od2Z08vKKk1PRkXFe8gqaeyicK7YVIOMTSuspAf
|
||||
JeJEHns6Hg61Exbo7GwdX76xlmQ/Z43E9BPHKgLyZ9WuJ0cysqN4aCyvS9yws9to
|
||||
ki7iMZqJUsmE2o09n9VaEsX6uQANZtLjI9wf+IgJuueDTNrkzQkhU7pbaPMsSG40
|
||||
AgGY/y4BR0H8sbhNnhqtZH7RcXV9VCJoPBAe+YiuXRiXyZHWxwBRyBE3e7g4MKHg
|
||||
hrWtaWUAs7gbavHwjqgU63iVItDSk7t4fCiEyObjK09AaNf2DjjaSGf8YGza4bNy
|
||||
BjYinYJ6/eX//gp+abqfocFbBP7D9zRDgMIbVmX/Ey6TghKiLkZOdbzcpO4Wgg==
|
||||
-----END CERTIFICATE-----
|
||||
145
tests/testserver/index.ts
Normal file
145
tests/testserver/index.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications 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';
|
||||
import http from 'http';
|
||||
import https from 'https';
|
||||
import path from 'path';
|
||||
|
||||
const fulfillSymbol = Symbol('fulfil callback');
|
||||
const rejectSymbol = Symbol('reject callback');
|
||||
|
||||
export class TestServer {
|
||||
private _server: http.Server;
|
||||
readonly debugServer: any;
|
||||
private _routes = new Map<string, (request: http.IncomingMessage, response: http.ServerResponse) => any>();
|
||||
private _csp = new Map<string, string>();
|
||||
private _extraHeaders = new Map<string, object>();
|
||||
private _requestSubscribers = new Map<string, Promise<any>>();
|
||||
readonly PORT: number;
|
||||
readonly PREFIX: string;
|
||||
readonly CROSS_PROCESS_PREFIX: string;
|
||||
|
||||
static async create(port: number): Promise<TestServer> {
|
||||
const server = new TestServer(port);
|
||||
await new Promise(x => server._server.once('listening', x));
|
||||
return server;
|
||||
}
|
||||
|
||||
static async createHTTPS(port: number): Promise<TestServer> {
|
||||
const server = new TestServer(port, {
|
||||
key: await fs.promises.readFile(path.join(__dirname, 'key.pem')),
|
||||
cert: await fs.promises.readFile(path.join(__dirname, 'cert.pem')),
|
||||
passphrase: 'aaaa',
|
||||
});
|
||||
await new Promise(x => server._server.once('listening', x));
|
||||
return server;
|
||||
}
|
||||
|
||||
constructor(port: number, sslOptions?: object) {
|
||||
if (sslOptions)
|
||||
this._server = https.createServer(sslOptions, this._onRequest.bind(this));
|
||||
else
|
||||
this._server = http.createServer(this._onRequest.bind(this));
|
||||
this._server.listen(port);
|
||||
this.debugServer = require('debug')('pw:testserver');
|
||||
|
||||
const cross_origin = '127.0.0.1';
|
||||
const same_origin = 'localhost';
|
||||
const protocol = sslOptions ? 'https' : 'http';
|
||||
this.PORT = port;
|
||||
this.PREFIX = `${protocol}://${same_origin}:${port}`;
|
||||
this.CROSS_PROCESS_PREFIX = `${protocol}://${cross_origin}:${port}`;
|
||||
}
|
||||
|
||||
setCSP(path: string, csp: string) {
|
||||
this._csp.set(path, csp);
|
||||
}
|
||||
|
||||
setExtraHeaders(path: string, object: Record<string, string>) {
|
||||
this._extraHeaders.set(path, object);
|
||||
}
|
||||
|
||||
async stop() {
|
||||
this.reset();
|
||||
await new Promise(x => this._server.close(x));
|
||||
}
|
||||
|
||||
route(path: string, handler: (request: http.IncomingMessage, response: http.ServerResponse) => any) {
|
||||
this._routes.set(path, handler);
|
||||
}
|
||||
|
||||
redirect(from: string, to: string) {
|
||||
this.route(from, (req, res) => {
|
||||
const headers = this._extraHeaders.get(req.url!) || {};
|
||||
res.writeHead(302, { ...headers, location: to });
|
||||
res.end();
|
||||
});
|
||||
}
|
||||
|
||||
waitForRequest(path: string): Promise<http.IncomingMessage> {
|
||||
let promise = this._requestSubscribers.get(path);
|
||||
if (promise)
|
||||
return promise;
|
||||
let fulfill, reject;
|
||||
promise = new Promise((f, r) => {
|
||||
fulfill = f;
|
||||
reject = r;
|
||||
});
|
||||
promise[fulfillSymbol] = fulfill;
|
||||
promise[rejectSymbol] = reject;
|
||||
this._requestSubscribers.set(path, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this._routes.clear();
|
||||
this._csp.clear();
|
||||
this._extraHeaders.clear();
|
||||
this._server.closeAllConnections();
|
||||
const error = new Error('Static Server has been reset');
|
||||
for (const subscriber of this._requestSubscribers.values())
|
||||
subscriber[rejectSymbol].call(null, error);
|
||||
this._requestSubscribers.clear();
|
||||
}
|
||||
|
||||
_onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
|
||||
request.on('error', error => {
|
||||
if ((error as any).code === 'ECONNRESET')
|
||||
response.end();
|
||||
else
|
||||
throw error;
|
||||
});
|
||||
(request as any).postBody = new Promise(resolve => {
|
||||
const chunks: Buffer[] = [];
|
||||
request.on('data', chunk => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
request.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
const path = request.url || '/';
|
||||
this.debugServer(`request ${request.method} ${path}`);
|
||||
// Notify request subscriber.
|
||||
if (this._requestSubscribers.has(path)) {
|
||||
this._requestSubscribers.get(path)![fulfillSymbol].call(null, request);
|
||||
this._requestSubscribers.delete(path);
|
||||
}
|
||||
const handler = this._routes.get(path);
|
||||
if (handler)
|
||||
handler.call(null, request, response);
|
||||
}
|
||||
}
|
||||
52
tests/testserver/key.pem
Normal file
52
tests/testserver/key.pem
Normal file
@@ -0,0 +1,52 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCttL32qMpycevk
|
||||
bRlzbGENo5meNC8VuKrF/+dte3/tZqqvLOlUq7sBPVqKcG+48Sjsh/0OFWd8X//a
|
||||
kaXSBtTfU5Tj+avKZCZzMYTJm84EOxmG0KJ/nijk6g7QGOE4M1Pw5Z5z1FPzufpG
|
||||
QfnwweU6LzGw/FD2flTUI3+HYPJAXdpEq6cQUKC3A8pZKMb4n9MAj2LRpIQ1c+JH
|
||||
zCNOuODv2zwSG6M03xaTqsotTex6kbcYgwgPm8uxVra33Gqfnraw/3kR75enZhff
|
||||
Puhgr31AIl3NpzBqWEwehvUYIQqMPLGwPYgVGLsPKyK61/LPLekyjo0AvEWvApWF
|
||||
ZfQvjNUUiwjomVV0rx0eYOTQxFKqAH2xZe8R9JNcAMR7yudwVIWkXPhnxE0uaflh
|
||||
LNAnOiQ2fjUNQI91lOVORwZOq+fk2qhjiw5XyuQVuCZNyrKKNX+x5s+PEJsk1w2Z
|
||||
pJZr0UnkiNFOnMzfzAubGf54oYrryfYkHc/sk/ou2wFKFASlcAXesV/I2w/VuOq6
|
||||
8ZlVKNXzD9TA8TiRXl6rePcPj3Xdl/G/OOno8SaFK0bQRC0he/H9NGhilo1bKkiB
|
||||
l1uRlCnUdQhAvXkPZZ5vidrM/eyjUULdmBuslnajOhp2Msynj01xIV2H5ZHJUq6j
|
||||
QMipHTNqIDPN/YMbWrtMsqKYzdcExQIDAQABAoICAGqXttpdyZ1g+vg5WpzRrNzJ
|
||||
v8KtExepMmI+Hq24U1BC6AqG7MfgeejQ1XaOeIBsvEgpSsgRqmdQIZjmN3Mibg59
|
||||
I6ih1SFlQ5L8mBd/XHSML6Xi8VSOoVmXp29bVRk/pgr1XL6HVN0DCumCIvXyhc+m
|
||||
lj+dFbGs5DEpd2CDxSRqcz4gd2wzjevAj7MWqsJ2kOyPEHzFD7wdWIXmZuQv3xhQ
|
||||
2BPkkcon+5qx+07BupOcR1brUU8Cs4QnSgiZYXSB2GnU215+P/mhVJTR7ZcnGRz5
|
||||
+cXxCmy3sj4pYs1juS1FMWSM3azUeDVeqvks+vrXmXpEr5H79mbmlwo8/hMPwNDO
|
||||
07HRZwa8T01aT9EYVm0lIOYjMF/2f6j6cu2apJtjXICOksR2HefRBVXQirOxRHma
|
||||
9XAYfNkZ/2164ZbgFmJv9khFnegPEuth9tLVdFIeGSmsG0aX9tH63zGT2NROyyLc
|
||||
QXPqsDl2CxCYPRs2oiGkM9dnfP1wAOp96sq42GIuN7ykfqfRnwAIvvnLKvyCq1vR
|
||||
pIno3CIX6vnzt+1/Hrmv13b0L6pJPitpXwKWHv9zJKBTpN8HEzP3Qmth2Ef60/7/
|
||||
CBo1PVTd1A6zcU7816flg7SCY+Vk+OxVHV3dGBIIqN9SfrQ8BPcOl6FNV5Anbrnv
|
||||
CpSw+LzH9n5xympDnk0BAoIBAQDjenvDfCnrNVeqx8+sYaYey4/WPVLXOQhREvRY
|
||||
oOtX9eqlNSi20+Wl+iuXmyj8wdHrDET7rfjCbpDQ7u105yzLw4gy4qIRDKZ1nE45
|
||||
YX+tm8mZgBqRnTp0DoGOArqmp3IKXJtUYmpbTz9tOfY7Usb1o1epb4winEB+Pl+8
|
||||
mgXOEo8xvWBzKeRA7tE73V64Mwbvbo9Ff2EguhXweQP29yBkEjT4iViayuHUmyPt
|
||||
hOVSMj2oFQuQGPdhAk7nUXojSGK/Zas/AGpH9CHH9De0h4m08vd3oM4vj0HwzgjU
|
||||
Co9aRa9SAH7EiaocOTcjDRPxWdZPHhxmrVRIYlF0MNmOAkXJAoIBAQDDfEqu4sNi
|
||||
pq74VXVatQqhzCILZo+o48bdgEjF7mF99mqPj8rwIDrEoEriDK861kenLc3vWKRY
|
||||
5wh1iX3S896re9kUMoxx6p4heYTcsOJ9BbkcpT8bJPZx9gBJb4jJENeVf1exf6sG
|
||||
RhFnulpzReRRaUjX2yAkyUPfc8YcUt+Nalrg+2W0fzeLCUpABCAcj2B1Vv7qRZHj
|
||||
oEtlCV5Nz+iMhrwIa16g9c8wGt5DZb4PI+VIJ6EYkdsjhgqIF0T/wDq9/habGBPo
|
||||
mHN+/DX3hCJWN2QgoVGJskHGt0zDMgiEgXfLZ2Grl02vQtq+mW2O2vGVeUd9Y5Ew
|
||||
RUiY4bSRTrUdAoIBAHxL1wiP9c/By+9TUtScXssA681ioLtdPIAgXUd4VmAvzVEM
|
||||
ZPzRd/BjbCJg89p4hZ1rjN4Ax6ZmB9dCVpnEH6QPaYJ0d53dTa+CAvQzpDJWp6eq
|
||||
adobEW+M5ZmVQCwD3rpus6k+RWMzQDMMstDjgDeEU0gP3YCj5FGW/3TsrDNXzMqe
|
||||
8e67ey9Hzyho43K+3xFBViPhYE8jnw1Q8quliRtlH3CWi8W5CgDD7LPCJBPvw+Tt
|
||||
6u2H1tQ5EKgwyw4wZVSz1wiLz4cVjMfXWADa9pHbGQFS6pbuLlfIHObQBliLLysd
|
||||
ficiGcNmOAx8/uKn9gQxLc+k8iLDJkLY1mdUMpECggEAJLl87k37ltTpmg2z9k58
|
||||
qNjIrIugAYKJIaOwCD84YYmhi0bgQSxM3hOe/ciUQuFupKGeRpDIj0sX87zYvoDC
|
||||
HEUwCvNUHzKMco15wFwasJIarJ7+tALFqbMlaqZhdCSN27AIsXfikVMogewoge9n
|
||||
bUPyQ1sPNtn4vknptfh7tv18BTg1aytbK+ua31vnDHaDEIg/a5OWTMUYZOrVpJii
|
||||
f4PwX0SMioCjY84oY1EB26ZKtLt9MDh2ir3rzJVSiRl776WEaa6kTtYVHI4VNWLF
|
||||
cJ0HWnnz74JliQd2jFUh9IK+FqBdYPcTyREuNxBr3KKVMBeQrqW96OubL913JrU6
|
||||
oQKCAQEA0yzORUouT0yleWs7RmzBlT9OLD/3cBYJMf/r1F8z8OQjB8fU1jKbO1Cs
|
||||
q4l+o9FmI+eHkgc3xbEG0hahOFWm/hTTli9vzksxurgdawZELThRkK33uTU9pKla
|
||||
Okqx3Ru/iMOW2+DQUx9UB+jK+hSAgq4gGqLeJVyaBerIdLQLlvqxrwSxjvvj+wJC
|
||||
Y66mgRzdCi6VDF1vV0knCrQHK6tRwcPozu/k4zjJzvdbMJnKEy2S7Vh6vO8lEPJm
|
||||
MQtaHPpmz+F4z14b9unNIiSbHO60Q4O+BwIBCzxApQQbFg63vBLYYwEMRd7hh92s
|
||||
ZkZVSOEp+sYBf/tmptlKr49nO+dTjQ==
|
||||
-----END PRIVATE KEY-----
|
||||
19
tests/testserver/san.cnf
Normal file
19
tests/testserver/san.cnf
Normal file
@@ -0,0 +1,19 @@
|
||||
# openssl req -new -x509 -days 3650 -key key.pem -out cert.pem -config san.cnf -extensions v3_req
|
||||
|
||||
[req]
|
||||
distinguished_name = req_distinguished_name
|
||||
req_extensions = v3_req
|
||||
prompt = no
|
||||
|
||||
[req_distinguished_name]
|
||||
CN = playwright-test
|
||||
|
||||
[v3_req]
|
||||
basicConstraints = CA:FALSE
|
||||
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
|
||||
subjectAltName = @alt_names
|
||||
|
||||
[alt_names]
|
||||
DNS.1 = localhost
|
||||
IP.1 = 127.0.0.1
|
||||
IP.2 = ::1
|
||||
38
tests/webdriver.spec.ts
Normal file
38
tests/webdriver.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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('do not falsely advertise user agent as a test driver', async ({ client, server, mcpBrowser }) => {
|
||||
test.skip(mcpBrowser === 'firefox');
|
||||
test.skip(mcpBrowser === 'webkit');
|
||||
server.route('/', (req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<body></body>
|
||||
<script>
|
||||
document.body.textContent = 'webdriver: ' + navigator.webdriver;
|
||||
</script>
|
||||
`);
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: server.PREFIX,
|
||||
},
|
||||
})).toContainTextContent('webdriver: false');
|
||||
});
|
||||
4
tsconfig.all.json
Normal file
4
tsconfig.all.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["**/*.ts", "**/*.js"],
|
||||
}
|
||||
6
utils/generate_links.js
Normal file
6
utils/generate_links.js
Normal file
@@ -0,0 +1,6 @@
|
||||
const config = JSON.stringify({ name: 'playwright', command: 'npx', args: ["@playwright/mcp@latest"] });
|
||||
const urlForWebsites = `vscode:mcp/install?${encodeURIComponent(config)}`;
|
||||
// Github markdown does not allow linking to `vscode:` directly, so you can use our redirect:
|
||||
const urlForGithub = `https://insiders.vscode.dev/redirect?url=${encodeURIComponent(urlForWebsites)}`;
|
||||
|
||||
console.log(urlForGithub);
|
||||
179
utils/update-readme.js
Normal file
179
utils/update-readme.js
Normal file
@@ -0,0 +1,179 @@
|
||||
#!/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 zodToJsonSchema = require('zod-to-json-schema').default;
|
||||
|
||||
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<any>} schema
|
||||
* @returns {ParsedToolSchema}
|
||||
*/
|
||||
function processToolSchema(schema) {
|
||||
const inputSchema = /** @type {import('zod-to-json-schema').JsonSchema7ObjectType} */ (zodToJsonSchema(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);
|
||||
});
|
||||
Reference in New Issue
Block a user