Compare commits
65 Commits
v0.0.25
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13257ce625 | ||
|
|
046958e7d9 | ||
|
|
8d7f1fa231 | ||
|
|
9ca9e82006 | ||
|
|
3b9397dc80 | ||
|
|
64f950ae42 | ||
|
|
5bfff0a059 | ||
|
|
c97bc6e2ae | ||
|
|
fe0c0ffffe | ||
|
|
9526910864 | ||
|
|
95454735bf | ||
|
|
e9f6433241 | ||
|
|
d61aa16fee | ||
|
|
012c906500 | ||
|
|
825a97d66e | ||
|
|
3061d9aa56 | ||
|
|
da818d113a | ||
|
|
a5a57df105 | ||
|
|
be8adb1866 | ||
|
|
c5a2324aaf | ||
|
|
128474b4aa | ||
|
|
7fca8f50f8 | ||
|
|
841bb417d1 | ||
|
|
59f1d67a4e | ||
|
|
1600ba6645 | ||
|
|
127c996e86 | ||
|
|
4bd39c07e9 | ||
|
|
f5b68dc590 | ||
|
|
875bd3b6ec | ||
|
|
137b74750c | ||
|
|
ded00dc422 | ||
|
|
5df6c2431b | ||
|
|
9066988098 | ||
|
|
1dc4977ff9 | ||
|
|
96e234012d | ||
|
|
6c3f3b6576 | ||
|
|
0df6d7a441 | ||
|
|
4ea7041ba9 | ||
|
|
7dae68de78 | ||
|
|
60495ed9b0 | ||
|
|
0aaef661b1 | ||
|
|
abbe7858a2 | ||
|
|
767af21e02 | ||
|
|
27c498e0e7 | ||
|
|
0fb9646c4d | ||
|
|
9728527900 | ||
|
|
675b083db3 | ||
|
|
0b74cdaaf8 | ||
|
|
f31ef598bc | ||
|
|
656779531c | ||
|
|
eec177d3ac | ||
|
|
54ed7c3200 | ||
|
|
3cd74a824a | ||
|
|
177b008328 | ||
|
|
9429463951 | ||
|
|
45f493da6c | ||
|
|
9e5ffd2ccf | ||
|
|
1051ea810a | ||
|
|
f20ae22ec6 | ||
|
|
13cd1b4bd9 | ||
|
|
c318f13895 | ||
|
|
1318e39fac | ||
|
|
c2b7fb29de | ||
|
|
aa6ac51f92 | ||
|
|
fea50e6840 |
15
.github/workflows/publish.yml
vendored
15
.github/workflows/publish.yml
vendored
@@ -44,6 +44,7 @@ jobs:
|
||||
- name: Login to ACR
|
||||
run: az acr login --name playwright
|
||||
- name: Build and push Docker image
|
||||
id: build-push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
@@ -53,3 +54,17 @@ jobs:
|
||||
tags: |
|
||||
playwright.azurecr.io/public/playwright/mcp:${{ github.event.release.tag_name }}
|
||||
playwright.azurecr.io/public/playwright/mcp:latest
|
||||
- uses: oras-project/setup-oras@v1
|
||||
- name: Set oras tags
|
||||
run: |
|
||||
attach_eol_manifest() {
|
||||
local image="$1"
|
||||
local today=$(date -u +'%Y-%m-%d')
|
||||
# oras is re-using Docker credentials, so we don't need to login.
|
||||
# Following the advice in https://portal.microsofticm.com/imp/v3/incidents/incident/476783820/summary
|
||||
oras attach --artifact-type application/vnd.microsoft.artifact.lifecycle --annotation "vnd.microsoft.artifact.lifecycle.end-of-life.date=$today" $image
|
||||
}
|
||||
# for each tag, attach the eol manifest
|
||||
for tag in $(echo ${{ steps.build-push.outputs.metadata['image.name'] }} | tr ',' '\n'); do
|
||||
attach_eol_manifest $tag
|
||||
done
|
||||
|
||||
509
README.md
509
README.md
@@ -10,7 +10,7 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
|
||||
|
||||
### Requirements
|
||||
- Node.js 18 or newer
|
||||
- VS Code, Cursor, Windsurf, Claude Desktop or any other MCP client
|
||||
- VS Code, Cursor, Windsurf, Claude Desktop, Goose or any other MCP client
|
||||
|
||||
<!--
|
||||
// Generate using:
|
||||
@@ -19,7 +19,9 @@ node utils/generate-links.js
|
||||
|
||||
### Getting started
|
||||
|
||||
First, install the Playwright MCP server with your client. A typical configuration looks like this:
|
||||
First, install the Playwright MCP server with your client.
|
||||
|
||||
**Standard config** works in most of the tools:
|
||||
|
||||
```js
|
||||
{
|
||||
@@ -37,7 +39,65 @@ First, install the Playwright MCP server with your client. A typical configurati
|
||||
[<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)
|
||||
|
||||
|
||||
<details><summary><b>Install in VS Code</b></summary>
|
||||
<details>
|
||||
<summary>Claude Code</summary>
|
||||
|
||||
Use the Claude Code CLI to add the Playwright MCP server:
|
||||
|
||||
```bash
|
||||
claude mcp add playwright npx @playwright/mcp@latest
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Claude Desktop</summary>
|
||||
|
||||
Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user), use the standard config above.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Cursor</summary>
|
||||
|
||||
#### Click the button to install:
|
||||
|
||||
[](https://cursor.com/install-mcp?name=playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
|
||||
|
||||
#### Or install manually:
|
||||
|
||||
Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, use `command` type with the command `npx @playwright/mcp`. You can also verify config or add command like arguments via clicking `Edit`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Gemini CLI</summary>
|
||||
|
||||
Follow the MCP install [guide](https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md#configure-the-mcp-server-in-settingsjson), use the standard config above.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Goose</summary>
|
||||
|
||||
#### Click the button to install:
|
||||
|
||||
[](https://block.github.io/goose/extension?cmd=npx&arg=%40playwright%2Fmcp%40latest&id=playwright&name=Playwright&description=Interact%20with%20web%20pages%20through%20structured%20accessibility%20snapshots%20using%20Playwright)
|
||||
|
||||
#### Or install manually:
|
||||
|
||||
Go to `Advanced settings` -> `Extensions` -> `Add custom extension`. Name to your liking, use type `STDIO`, and set the `command` to `npx @playwright/mcp`. Click "Add Extension".
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Qodo Gen</summary>
|
||||
|
||||
Open [Qodo Gen](https://docs.qodo.ai/qodo-documentation/qodo-gen) chat panel in VSCode or IntelliJ → Connect more tools → + Add new MCP → Paste the standard config above.
|
||||
|
||||
Click <code>Save</code>.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>VS Code</summary>
|
||||
|
||||
You can also install the Playwright MCP server using the VS Code CLI:
|
||||
|
||||
@@ -50,60 +110,10 @@ After installation, the Playwright MCP server will be available for use with you
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Install in Cursor</b></summary>
|
||||
<summary>Windsurf</summary>
|
||||
|
||||
Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, use `command` type with the command `npx @playwright/mcp`. You can also verify config or add command like arguments via clicking `Edit`.
|
||||
Follow Windsurf MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp). Use the standard config above.
|
||||
|
||||
```js
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Install in Windsurf</b></summary>
|
||||
|
||||
Follow Windsuff MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp). Use following configuration:
|
||||
|
||||
```js
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Install in Claude Desktop</b></summary>
|
||||
|
||||
Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user), use following configuration:
|
||||
|
||||
```js
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
### Configuration
|
||||
@@ -124,9 +134,8 @@ Playwright MCP server supports following arguments. They can be provided in the
|
||||
--block-service-workers block service workers
|
||||
--browser <browser> browser or chrome channel to use, possible
|
||||
values: chrome, firefox, webkit, msedge.
|
||||
--caps <caps> comma-separated list of capabilities to enable,
|
||||
possible values: tabs, pdf, history, wait, files,
|
||||
install. Default is all.
|
||||
--caps <caps> comma-separated list of additional capabilities
|
||||
to enable, possible values: vision, pdf.
|
||||
--cdp-endpoint <endpoint> CDP endpoint to connect to.
|
||||
--config <path> path to the configuration file.
|
||||
--device <device> device to emulate, for example: "iPhone 15"
|
||||
@@ -137,7 +146,8 @@ Playwright MCP server supports following arguments. They can be provided in the
|
||||
--ignore-https-errors ignore https errors
|
||||
--isolated keep the browser profile in memory, do not save
|
||||
it to disk.
|
||||
--no-image-responses do not send image responses to the client.
|
||||
--image-responses <mode> whether to send image responses to the client.
|
||||
Can be "allow" or "omit", Defaults to "allow".
|
||||
--no-sandbox disable the sandbox for all process types that
|
||||
are normally sandboxed.
|
||||
--output-dir <path> path to the directory for output files.
|
||||
@@ -146,6 +156,8 @@ Playwright MCP server supports following arguments. They can be provided in the
|
||||
example ".com,chromium.org,.domain.com"
|
||||
--proxy-server <proxy> specify proxy server, for example
|
||||
"http://myproxy:3128" or "socks5://myproxy:8080"
|
||||
--save-trace Whether to save the Playwright Trace of the
|
||||
session into the output directory.
|
||||
--storage-state <path> path to the storage state file for isolated
|
||||
sessions.
|
||||
--user-agent <ua string> specify user agent string
|
||||
@@ -153,8 +165,6 @@ Playwright MCP server supports following arguments. They can be provided in the
|
||||
specified, a temporary directory will be created.
|
||||
--viewport-size <size> specify browser viewport size in pixels, for
|
||||
example "1280, 720"
|
||||
--vision Run server that uses screenshots (Aria snapshots
|
||||
are used by default)
|
||||
```
|
||||
|
||||
<!--- End of options generated section -->
|
||||
@@ -194,7 +204,7 @@ state [here](https://playwright.dev/docs/auth).
|
||||
"args": [
|
||||
"@playwright/mcp@latest",
|
||||
"--isolated",
|
||||
"--storage-state={path/to/storage.json}
|
||||
"--storage-state={path/to/storage.json}"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -255,21 +265,14 @@ npx @playwright/mcp@latest --config path/to/config.json
|
||||
host?: string; // Host to bind to (default: localhost)
|
||||
},
|
||||
|
||||
// List of enabled capabilities
|
||||
// List of additional 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
|
||||
'testing' // Testing
|
||||
'pdf' | // PDF generation
|
||||
'vision' | // Coordinate-based interactions
|
||||
>;
|
||||
|
||||
// Enable vision mode (screenshots instead of accessibility snapshots)
|
||||
vision?: boolean;
|
||||
|
||||
// Directory for output files
|
||||
outputDir?: string;
|
||||
|
||||
@@ -283,9 +286,10 @@ npx @playwright/mcp@latest --config path/to/config.json
|
||||
};
|
||||
|
||||
/**
|
||||
* Do not send image responses to the client.
|
||||
* Whether to send image responses to the client. Can be "allow" or "omit".
|
||||
* Defaults to "allow".
|
||||
*/
|
||||
noImageResponses?: boolean;
|
||||
imageResponses?: 'allow' | 'omit';
|
||||
}
|
||||
```
|
||||
</details>
|
||||
@@ -293,24 +297,26 @@ npx @playwright/mcp@latest --config path/to/config.json
|
||||
### Standalone MCP server
|
||||
|
||||
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.
|
||||
run the MCP server from environment with the DISPLAY and pass the `--port` flag to enable HTTP transport.
|
||||
|
||||
```bash
|
||||
npx @playwright/mcp@latest --port 8931
|
||||
```
|
||||
|
||||
And then in MCP client config, set the `url` to the SSE endpoint:
|
||||
And then in MCP client config, set the `url` to the MCP endpoint:
|
||||
|
||||
```js
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"url": "http://localhost:8931/sse"
|
||||
"url": "http://localhost:8931/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For legacy SSE transport support, you can use `/sse` instead of `/mcp` in the URL.
|
||||
|
||||
<details>
|
||||
<summary><b>Docker</b></summary>
|
||||
|
||||
@@ -349,7 +355,7 @@ http.createServer(async (req, res) => {
|
||||
// Creates a headless Playwright MCP server with SSE transport
|
||||
const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
|
||||
const transport = new SSEServerTransport('/messages', res);
|
||||
await connection.connect(transport);
|
||||
await connection.sever.connect(transport);
|
||||
|
||||
// ...
|
||||
});
|
||||
@@ -358,42 +364,10 @@ http.createServer(async (req, res) => {
|
||||
|
||||
### Tools
|
||||
|
||||
The tools are available in two modes:
|
||||
|
||||
1. **Snapshot Mode** (default): Uses accessibility snapshots for better performance and reliability
|
||||
2. **Vision Mode**: Uses screenshots for visual-based interactions
|
||||
|
||||
To use Vision Mode, add the `--vision` flag when starting the server:
|
||||
|
||||
```js
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest",
|
||||
"--vision"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
<!--- Tools generated by update-readme.js -->
|
||||
|
||||
<details>
|
||||
<summary><b>Interactions</b></summary>
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_snapshot**
|
||||
- Title: Page snapshot
|
||||
- Description: Capture accessibility snapshot of the current page, this is better than screenshot
|
||||
- Parameters: None
|
||||
- Read-only: **true**
|
||||
<summary><b>Core automation</b></summary>
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
@@ -403,10 +377,28 @@ X Y coordinate space, based on the provided screenshot.
|
||||
- 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
|
||||
- `doubleClick` (boolean, optional): Whether to perform a double click instead of a single click
|
||||
- `button` (string, optional): Button to click, defaults to left
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_close**
|
||||
- Title: Close browser
|
||||
- Description: Close the page
|
||||
- Parameters: None
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_console_messages**
|
||||
- Title: Get console messages
|
||||
- Description: Returns all console messages
|
||||
- Parameters: None
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_drag**
|
||||
- Title: Drag mouse
|
||||
- Description: Perform drag and drop between two elements
|
||||
@@ -419,60 +411,17 @@ X Y coordinate space, based on the provided screenshot.
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_hover**
|
||||
- Title: Hover mouse
|
||||
- Description: Hover over element on page
|
||||
- **browser_evaluate**
|
||||
- Title: Evaluate JavaScript
|
||||
- Description: Evaluate JavaScript expression on page or element
|
||||
- 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
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_type**
|
||||
- Title: Type text
|
||||
- Description: Type text into editable element
|
||||
- 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
|
||||
- `text` (string): Text to type into the element
|
||||
- `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.
|
||||
- `function` (string): () => { /* code */ } or (element) => { /* code */ } when element is provided
|
||||
- `element` (string, optional): Human-readable element description used to obtain permission to interact with the element
|
||||
- `ref` (string, optional): Exact target element reference from the page snapshot
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_select_option**
|
||||
- Title: Select option
|
||||
- Description: Select an option in a dropdown
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
- `ref` (string): Exact target element reference from the page snapshot
|
||||
- `values` (array): Array of values to select in the dropdown. This can be a single value or multiple values.
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_press_key**
|
||||
- Title: Press a 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`
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_wait_for**
|
||||
- Title: Wait for
|
||||
- Description: Wait for text to appear or disappear or a specified time to pass
|
||||
- Parameters:
|
||||
- `time` (number, optional): The time to wait in seconds
|
||||
- `text` (string, optional): The text to wait for
|
||||
- `textGone` (string, optional): The text to wait for to disappear
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_file_upload**
|
||||
- Title: Upload files
|
||||
- Description: Upload one or multiple files
|
||||
@@ -490,10 +439,15 @@ X Y coordinate space, based on the provided screenshot.
|
||||
- `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
|
||||
- Read-only: **false**
|
||||
|
||||
</details>
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
<details>
|
||||
<summary><b>Navigation</b></summary>
|
||||
- **browser_hover**
|
||||
- Title: Hover mouse
|
||||
- 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
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
@@ -520,10 +474,51 @@ X Y coordinate space, based on the provided screenshot.
|
||||
- Parameters: None
|
||||
- Read-only: **true**
|
||||
|
||||
</details>
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
<details>
|
||||
<summary><b>Resources</b></summary>
|
||||
- **browser_network_requests**
|
||||
- Title: List network requests
|
||||
- Description: Returns all network requests since loading the page
|
||||
- Parameters: None
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_press_key**
|
||||
- Title: Press a 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`
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_resize**
|
||||
- Title: Resize browser window
|
||||
- Description: Resize the browser window
|
||||
- Parameters:
|
||||
- `width` (number): Width of the browser window
|
||||
- `height` (number): Height of the browser window
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_select_option**
|
||||
- Title: Select option
|
||||
- Description: Select an option in a dropdown
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
- `ref` (string): Exact target element reference from the page snapshot
|
||||
- `values` (array): Array of values to select in the dropdown. This can be a single value or multiple values.
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_snapshot**
|
||||
- Title: Page snapshot
|
||||
- Description: Capture accessibility snapshot of the current page, this is better than screenshot
|
||||
- Parameters: None
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
@@ -539,64 +534,41 @@ X Y coordinate space, based on the provided screenshot.
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_pdf_save**
|
||||
- Title: Save as PDF
|
||||
- Description: Save page as PDF
|
||||
- **browser_type**
|
||||
- Title: Type text
|
||||
- Description: Type text into editable element
|
||||
- Parameters:
|
||||
- `filename` (string, optional): File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_network_requests**
|
||||
- Title: List network requests
|
||||
- Description: Returns all network requests since loading the page
|
||||
- Parameters: None
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_console_messages**
|
||||
- Title: Get console messages
|
||||
- Description: Returns all console messages
|
||||
- Parameters: None
|
||||
- Read-only: **true**
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Utilities</b></summary>
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_install**
|
||||
- Title: Install the browser specified in the config
|
||||
- Description: Install the browser specified in the config. Call this if you get an error about the browser not being installed.
|
||||
- Parameters: None
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
- `ref` (string): Exact target element reference from the page snapshot
|
||||
- `text` (string): Text to type into the element
|
||||
- `submit` (boolean, 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.
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_close**
|
||||
- Title: Close browser
|
||||
- Description: Close the page
|
||||
- Parameters: None
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_resize**
|
||||
- Title: Resize browser window
|
||||
- Description: Resize the browser window
|
||||
- **browser_wait_for**
|
||||
- Title: Wait for
|
||||
- Description: Wait for text to appear or disappear or a specified time to pass
|
||||
- Parameters:
|
||||
- `width` (number): Width of the browser window
|
||||
- `height` (number): Height of the browser window
|
||||
- `time` (number, optional): The time to wait in seconds
|
||||
- `text` (string, optional): The text to wait for
|
||||
- `textGone` (string, optional): The text to wait for to disappear
|
||||
- Read-only: **true**
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Tabs</b></summary>
|
||||
<summary><b>Tab management</b></summary>
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_tab_close**
|
||||
- Title: Close a tab
|
||||
- Description: Close a tab
|
||||
- Parameters:
|
||||
- `index` (number, optional): The index of the tab to close. Closes current tab if not provided.
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
@@ -624,60 +596,29 @@ X Y coordinate space, based on the provided screenshot.
|
||||
- `index` (number): The index of the tab to select
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_tab_close**
|
||||
- Title: Close a tab
|
||||
- Description: Close a tab
|
||||
- Parameters:
|
||||
- `index` (number, optional): The index of the tab to close. Closes current tab if not provided.
|
||||
- Read-only: **false**
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Testing</b></summary>
|
||||
<summary><b>Browser installation</b></summary>
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_generate_playwright_test**
|
||||
- Title: Generate a Playwright test
|
||||
- Description: Generate a Playwright test for given scenario
|
||||
- Parameters:
|
||||
- `name` (string): The name of the test
|
||||
- `description` (string): The description of the test
|
||||
- `steps` (array): The steps of the test
|
||||
- Read-only: **true**
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Vision mode</b></summary>
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_screen_capture**
|
||||
- Title: Take a screenshot
|
||||
- Description: Take a screenshot of the current page
|
||||
- **browser_install**
|
||||
- Title: Install the browser specified in the config
|
||||
- Description: Install the browser specified in the config. Call this if you get an error about the browser not being installed.
|
||||
- Parameters: None
|
||||
- Read-only: **true**
|
||||
- Read-only: **false**
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Coordinate-based (opt-in via --caps=vision)</b></summary>
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_screen_move_mouse**
|
||||
- Title: Move mouse
|
||||
- Description: Move mouse to a given position
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
- `x` (number): X coordinate
|
||||
- `y` (number): Y coordinate
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_screen_click**
|
||||
- **browser_mouse_click_xy**
|
||||
- Title: Click
|
||||
- Description: Click left mouse button
|
||||
- Description: Click left mouse button at a given position
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
- `x` (number): X coordinate
|
||||
@@ -686,9 +627,9 @@ X Y coordinate space, based on the provided screenshot.
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_screen_drag**
|
||||
- **browser_mouse_drag_xy**
|
||||
- Title: Drag mouse
|
||||
- Description: Drag left mouse button
|
||||
- Description: Drag left mouse button to a given position
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
- `startX` (number): Start X coordinate
|
||||
@@ -699,52 +640,28 @@ X Y coordinate space, based on the provided screenshot.
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_screen_type**
|
||||
- Title: Type text
|
||||
- Description: Type text
|
||||
- **browser_mouse_move_xy**
|
||||
- Title: Move mouse
|
||||
- Description: Move mouse to a given position
|
||||
- Parameters:
|
||||
- `text` (string): Text to type into the element
|
||||
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_press_key**
|
||||
- Title: Press a 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`
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_wait_for**
|
||||
- Title: Wait for
|
||||
- Description: Wait for text to appear or disappear or a specified time to pass
|
||||
- Parameters:
|
||||
- `time` (number, optional): The time to wait in seconds
|
||||
- `text` (string, optional): The text to wait for
|
||||
- `textGone` (string, optional): The text to wait for to disappear
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
- `x` (number): X coordinate
|
||||
- `y` (number): Y coordinate
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
</details>
|
||||
|
||||
- **browser_file_upload**
|
||||
- Title: Upload files
|
||||
- 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.
|
||||
- Read-only: **false**
|
||||
<details>
|
||||
<summary><b>PDF generation (opt-in via --caps=pdf)</b></summary>
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_handle_dialog**
|
||||
- Title: Handle a dialog
|
||||
- Description: Handle a dialog
|
||||
- **browser_pdf_save**
|
||||
- Title: Save as PDF
|
||||
- Description: Save page as PDF
|
||||
- Parameters:
|
||||
- `accept` (boolean): Whether to accept the dialog.
|
||||
- `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
|
||||
- Read-only: **false**
|
||||
- `filename` (string, optional): File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.
|
||||
- Read-only: **true**
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
16
config.d.ts
vendored
16
config.d.ts
vendored
@@ -16,7 +16,7 @@
|
||||
|
||||
import type * as playwright from 'playwright';
|
||||
|
||||
export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install' | 'testing';
|
||||
export type ToolCapability = 'core' | 'core-tabs' | 'core-install' | 'vision' | 'pdf';
|
||||
|
||||
export type Config = {
|
||||
/**
|
||||
@@ -80,19 +80,15 @@ export type Config = {
|
||||
/**
|
||||
* 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.
|
||||
* - 'vision': Coordinate-based interactions.
|
||||
*/
|
||||
capabilities?: ToolCapability[];
|
||||
|
||||
/**
|
||||
* Run server that uses screenshots (Aria snapshots are used by default).
|
||||
* Whether to save the Playwright trace of the session into the output directory.
|
||||
*/
|
||||
vision?: boolean;
|
||||
saveTrace?: boolean;
|
||||
|
||||
/**
|
||||
* The directory to save output files.
|
||||
@@ -112,7 +108,7 @@ export type Config = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Do not send image responses to the client.
|
||||
* Whether to send image responses to the client. Can be "allow", "omit", or "auto". Defaults to "auto", which sends images if the client can display them.
|
||||
*/
|
||||
noImageResponses?: boolean;
|
||||
imageResponses?: 'allow' | 'omit';
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Generate test for scenario:
|
||||
Use Playwright tools to generate test for scenario:
|
||||
|
||||
## GitHub PR Checks Navigation Checklist
|
||||
|
||||
|
||||
7
index.d.ts
vendored
7
index.d.ts
vendored
@@ -16,14 +16,13 @@
|
||||
*/
|
||||
|
||||
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import type { Config } from './config';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import type { Config } from './config.js';
|
||||
import type { BrowserContext } from 'playwright';
|
||||
|
||||
export type Connection = {
|
||||
server: Server;
|
||||
connect(transport: Transport): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
export declare function createConnection(config?: Config): Promise<Connection>;
|
||||
export declare function createConnection(config?: Config, contextGetter?: () => Promise<BrowserContext>): Promise<Connection>;
|
||||
export {};
|
||||
|
||||
2
index.js
2
index.js
@@ -15,5 +15,5 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createConnection } from './lib/index';
|
||||
import { createConnection } from './lib/index.js';
|
||||
export { createConnection };
|
||||
|
||||
408
package-lock.json
generated
408
package-lock.json
generated
@@ -1,17 +1,21 @@
|
||||
{
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.25",
|
||||
"version": "0.0.31",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.25",
|
||||
"version": "0.0.31",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"commander": "^13.1.0",
|
||||
"playwright": "1.53.0-alpha-1746832516000",
|
||||
"debug": "^4.4.1",
|
||||
"mime": "^4.0.7",
|
||||
"playwright": "1.55.0-alpha-1752701791000",
|
||||
"playwright-core": "1.55.0-alpha-1752701791000",
|
||||
"ws": "^8.18.1",
|
||||
"zod-to-json-schema": "^3.24.4"
|
||||
},
|
||||
"bin": {
|
||||
@@ -20,9 +24,12 @@
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@playwright/test": "1.53.0-alpha-1746832516000",
|
||||
"@playwright/test": "1.55.0-alpha-1752701791000",
|
||||
"@stylistic/eslint-plugin": "^3.0.1",
|
||||
"@types/chrome": "^0.0.315",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||
"@typescript-eslint/parser": "^8.26.1",
|
||||
"@typescript-eslint/utils": "^8.26.1",
|
||||
@@ -286,13 +293,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.53.0-alpha-1746832516000",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-1746832516000.tgz",
|
||||
"integrity": "sha512-Sec+6uzpA4MfwmQqJFBFVazffynqVwLO5swDxG7WoqgpUdn9gQX4K4tDG64SV6f4nOpwdM5LKTasPSXu02nn/Q==",
|
||||
"version": "1.55.0-alpha-1752701791000",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0-alpha-1752701791000.tgz",
|
||||
"integrity": "sha512-mnitdsjXKPyKTjQQDJ78Or1xZSGcaoDzZVD/0BWFCvygn3nyNmGmiias/Mlfvzvgz9UWBbPeZYxU/bd2Lu+OrQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.53.0-alpha-1746832516000"
|
||||
"playwright": "1.55.0-alpha-1752701791000"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -354,6 +361,27 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chrome": {
|
||||
"version": "0.0.315",
|
||||
"resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.315.tgz",
|
||||
"integrity": "sha512-Oy1dYWkr6BCmgwBtOngLByCHstQ3whltZg7/7lubgIZEYvKobDneqplgc6LKERNRBwckFviV4UU5AZZNUFrJ4A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/filesystem": "*",
|
||||
"@types/har-format": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||
"integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/ms": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
||||
@@ -361,6 +389,30 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/filesystem": {
|
||||
"version": "0.0.36",
|
||||
"resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz",
|
||||
"integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/filewriter": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/filewriter": {
|
||||
"version": "0.0.33",
|
||||
"resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz",
|
||||
"integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/har-format": {
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
|
||||
"integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -375,6 +427,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.13.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
|
||||
@@ -385,6 +444,16 @@
|
||||
"undici-types": "~6.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.27.0.tgz",
|
||||
@@ -834,16 +903,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.1.0.tgz",
|
||||
"integrity": "sha512-/hPxh61E+ll0Ujp24Ilm64cykicul1ypfwjVttduAiEdtnJFvLePSrIPk+HMImtNv5270wOGCb1Tns2rybMkoQ==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
|
||||
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^1.0.5",
|
||||
"debug": "^4.4.0",
|
||||
"http-errors": "^2.0.0",
|
||||
"iconv-lite": "^0.5.2",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.14.0",
|
||||
"raw-body": "^3.0.0",
|
||||
@@ -853,44 +922,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/debug": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser/node_modules/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
@@ -1156,12 +1187,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
|
||||
"integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==",
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.1.2"
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
@@ -1224,16 +1255,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||
@@ -1769,46 +1790,45 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.0.1.tgz",
|
||||
"integrity": "sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
|
||||
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.0.1",
|
||||
"body-parser": "^2.2.0",
|
||||
"content-disposition": "^1.0.0",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "0.7.1",
|
||||
"content-type": "^1.0.5",
|
||||
"cookie": "^0.7.1",
|
||||
"cookie-signature": "^1.2.1",
|
||||
"debug": "4.3.6",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "^2.0.0",
|
||||
"fresh": "2.0.0",
|
||||
"http-errors": "2.0.0",
|
||||
"debug": "^4.4.0",
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"etag": "^1.8.1",
|
||||
"finalhandler": "^2.1.0",
|
||||
"fresh": "^2.0.0",
|
||||
"http-errors": "^2.0.0",
|
||||
"merge-descriptors": "^2.0.0",
|
||||
"methods": "~1.1.2",
|
||||
"mime-types": "^3.0.0",
|
||||
"on-finished": "2.4.1",
|
||||
"once": "1.4.0",
|
||||
"parseurl": "~1.3.3",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "6.13.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"router": "^2.0.0",
|
||||
"safe-buffer": "5.2.1",
|
||||
"on-finished": "^2.4.1",
|
||||
"once": "^1.4.0",
|
||||
"parseurl": "^1.3.3",
|
||||
"proxy-addr": "^2.0.7",
|
||||
"qs": "^6.14.0",
|
||||
"range-parser": "^1.2.1",
|
||||
"router": "^2.2.0",
|
||||
"send": "^1.1.0",
|
||||
"serve-static": "^2.1.0",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"type-is": "^2.0.0",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
"serve-static": "^2.2.0",
|
||||
"statuses": "^2.0.1",
|
||||
"type-is": "^2.0.1",
|
||||
"vary": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
@@ -1930,29 +1950,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler/node_modules/debug": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/find-root": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
|
||||
@@ -2312,12 +2309,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.5.2.tgz",
|
||||
"integrity": "sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==",
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -2928,15 +2925,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/metric-lcs": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/metric-lcs/-/metric-lcs-0.1.2.tgz",
|
||||
@@ -2958,6 +2946,21 @@
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-4.0.7.tgz",
|
||||
"integrity": "sha512-2OfDPL+e03E0LrXaGYOtTFIYhiuzep94NSsuhrNULq+stylcJedcHdzHtz0atMUuGwJfFYs0YL5xeC/Ca2x0eQ==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "bin/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.54.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
@@ -2968,12 +2971,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.0.tgz",
|
||||
"integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==",
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
|
||||
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "^1.53.0"
|
||||
"mime-db": "^1.54.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
@@ -3003,9 +3006,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/natural-compare": {
|
||||
@@ -3298,12 +3301,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.53.0-alpha-1746832516000",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-1746832516000.tgz",
|
||||
"integrity": "sha512-kcC1B2XJr4VaDAcVzi61SbYGkodq1QIqQXuPieXsNgZZ7cEKWzO2sI42yp2yie6wlCx0oLkSS2Q6jWSRVRLeaw==",
|
||||
"version": "1.55.0-alpha-1752701791000",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0-alpha-1752701791000.tgz",
|
||||
"integrity": "sha512-PA3TvDz7uQ+Pde0uaii5/WpU5vntRJsYFsaSPoBzywIqzYFO1ugk1ZZ0q6z4/xHq0ha1UClvsv3P77B+u1fi+w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.53.0-alpha-1746832516000"
|
||||
"playwright-core": "1.55.0-alpha-1752701791000"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -3316,9 +3319,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.53.0-alpha-1746832516000",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-1746832516000.tgz",
|
||||
"integrity": "sha512-4O98y4zV0rOP6CepMLC/VGuzqGaR1sS9AVh+i0CghWMQHM/8bxPJI8W38QndO0JU0V5nBD6j7DQeNt1mJ+CZ+g==",
|
||||
"version": "1.55.0-alpha-1752701791000",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0-alpha-1752701791000.tgz",
|
||||
"integrity": "sha512-mQhzhjJMiqnGNnYZv7M4yk1OcNTt1E72jrTLO7EqZuoeat4+qpcU0/mbK+RcTEass5a9YheoVFh6OIhruFMGVg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
@@ -3371,12 +3374,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.13.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.0.6"
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
@@ -3430,18 +3433,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -3529,11 +3520,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/router": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/router/-/router-2.1.0.tgz",
|
||||
"integrity": "sha512-/m/NSLxeYEgWNtyC+WtNHCF7jbGxOibVWKnn+1Psff4dJGOfoXP+MuC/f2CwSmyiHdOIzYnYFp4W6GxWfekaLA==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"depd": "^2.0.0",
|
||||
"is-promise": "^4.0.0",
|
||||
"parseurl": "^1.3.3",
|
||||
"path-to-regexp": "^8.0.0"
|
||||
@@ -3661,19 +3654,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.1.0.tgz",
|
||||
"integrity": "sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
|
||||
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.5",
|
||||
"destroy": "^1.2.0",
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"etag": "^1.8.1",
|
||||
"fresh": "^0.5.2",
|
||||
"fresh": "^2.0.0",
|
||||
"http-errors": "^2.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"mime-types": "^3.0.1",
|
||||
"ms": "^2.1.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"range-parser": "^1.2.1",
|
||||
@@ -3683,52 +3675,16 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.1.0.tgz",
|
||||
"integrity": "sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
|
||||
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"parseurl": "^1.3.3",
|
||||
"send": "^1.0.0"
|
||||
"send": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
@@ -4061,9 +4017,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.0.tgz",
|
||||
"integrity": "sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"content-type": "^1.0.5",
|
||||
@@ -4211,15 +4167,6 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
@@ -4349,6 +4296,27 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
14
package.json
14
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.25",
|
||||
"version": "0.0.31",
|
||||
"description": "Playwright Tools for MCP",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
@@ -24,6 +24,7 @@
|
||||
"ctest": "playwright test --project=chrome",
|
||||
"ftest": "playwright test --project=firefox",
|
||||
"wtest": "playwright test --project=webkit",
|
||||
"run-server": "node lib/browserServer.js",
|
||||
"clean": "rm -rf lib",
|
||||
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
|
||||
},
|
||||
@@ -37,15 +38,22 @@
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"commander": "^13.1.0",
|
||||
"playwright": "1.53.0-alpha-1746832516000",
|
||||
"debug": "^4.4.1",
|
||||
"mime": "^4.0.7",
|
||||
"playwright": "1.55.0-alpha-1752701791000",
|
||||
"playwright-core": "1.55.0-alpha-1752701791000",
|
||||
"ws": "^8.18.1",
|
||||
"zod-to-json-schema": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@playwright/test": "1.53.0-alpha-1746832516000",
|
||||
"@playwright/test": "1.55.0-alpha-1752701791000",
|
||||
"@stylistic/eslint-plugin": "^3.0.1",
|
||||
"@types/chrome": "^0.0.315",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||
"@typescript-eslint/parser": "^8.26.1",
|
||||
"@typescript-eslint/utils": "^8.26.1",
|
||||
|
||||
@@ -29,7 +29,14 @@ export default defineConfig<TestOptions>({
|
||||
{ name: 'chrome' },
|
||||
{ name: 'msedge', use: { mcpBrowser: 'msedge' } },
|
||||
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
||||
...process.env.MCP_IN_DOCKER ? [{ name: 'chromium-docker', use: { mcpBrowser: 'chromium', mcpMode: 'docker' as const } }] : [],
|
||||
...process.env.MCP_IN_DOCKER ? [{
|
||||
name: 'chromium-docker',
|
||||
grep: /browser_navigate|browser_click/,
|
||||
use: {
|
||||
mcpBrowser: 'chromium',
|
||||
mcpMode: 'docker' as const
|
||||
}
|
||||
}] : [],
|
||||
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
||||
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
||||
],
|
||||
|
||||
229
src/browserContextFactory.ts
Normal file
229
src/browserContextFactory.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* 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 'node:fs';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
import * as playwright from 'playwright';
|
||||
|
||||
import { logUnhandledError, testDebug } from './log.js';
|
||||
|
||||
import type { FullConfig } from './config.js';
|
||||
|
||||
export function contextFactory(browserConfig: FullConfig['browser']): BrowserContextFactory {
|
||||
if (browserConfig.remoteEndpoint)
|
||||
return new RemoteContextFactory(browserConfig);
|
||||
if (browserConfig.cdpEndpoint)
|
||||
return new CdpContextFactory(browserConfig);
|
||||
if (browserConfig.isolated)
|
||||
return new IsolatedContextFactory(browserConfig);
|
||||
return new PersistentContextFactory(browserConfig);
|
||||
}
|
||||
|
||||
export interface BrowserContextFactory {
|
||||
createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
|
||||
}
|
||||
|
||||
class BaseContextFactory implements BrowserContextFactory {
|
||||
readonly browserConfig: FullConfig['browser'];
|
||||
protected _browserPromise: Promise<playwright.Browser> | undefined;
|
||||
readonly name: string;
|
||||
|
||||
constructor(name: string, browserConfig: FullConfig['browser']) {
|
||||
this.name = name;
|
||||
this.browserConfig = browserConfig;
|
||||
}
|
||||
|
||||
protected async _obtainBrowser(): Promise<playwright.Browser> {
|
||||
if (this._browserPromise)
|
||||
return this._browserPromise;
|
||||
testDebug(`obtain browser (${this.name})`);
|
||||
this._browserPromise = this._doObtainBrowser();
|
||||
void this._browserPromise.then(browser => {
|
||||
browser.on('disconnected', () => {
|
||||
this._browserPromise = undefined;
|
||||
});
|
||||
}).catch(() => {
|
||||
this._browserPromise = undefined;
|
||||
});
|
||||
return this._browserPromise;
|
||||
}
|
||||
|
||||
protected async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||
testDebug(`create browser context (${this.name})`);
|
||||
const browser = await this._obtainBrowser();
|
||||
const browserContext = await this._doCreateContext(browser);
|
||||
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
|
||||
}
|
||||
|
||||
protected async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser) {
|
||||
testDebug(`close browser context (${this.name})`);
|
||||
if (browser.contexts().length === 1)
|
||||
this._browserPromise = undefined;
|
||||
await browserContext.close().catch(logUnhandledError);
|
||||
if (browser.contexts().length === 0) {
|
||||
testDebug(`close browser (${this.name})`);
|
||||
await browser.close().catch(logUnhandledError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class IsolatedContextFactory extends BaseContextFactory {
|
||||
constructor(browserConfig: FullConfig['browser']) {
|
||||
super('isolated', browserConfig);
|
||||
}
|
||||
|
||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||
await injectCdpPort(this.browserConfig);
|
||||
const browserType = playwright[this.browserConfig.browserName];
|
||||
return browserType.launch({
|
||||
...this.browserConfig.launchOptions,
|
||||
handleSIGINT: false,
|
||||
handleSIGTERM: false,
|
||||
}).catch(error => {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||
return browser.newContext(this.browserConfig.contextOptions);
|
||||
}
|
||||
}
|
||||
|
||||
class CdpContextFactory extends BaseContextFactory {
|
||||
constructor(browserConfig: FullConfig['browser']) {
|
||||
super('cdp', browserConfig);
|
||||
}
|
||||
|
||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||
return playwright.chromium.connectOverCDP(this.browserConfig.cdpEndpoint!);
|
||||
}
|
||||
|
||||
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||
return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteContextFactory extends BaseContextFactory {
|
||||
constructor(browserConfig: FullConfig['browser']) {
|
||||
super('remote', browserConfig);
|
||||
}
|
||||
|
||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||
const url = new URL(this.browserConfig.remoteEndpoint!);
|
||||
url.searchParams.set('browser', this.browserConfig.browserName);
|
||||
if (this.browserConfig.launchOptions)
|
||||
url.searchParams.set('launch-options', JSON.stringify(this.browserConfig.launchOptions));
|
||||
return playwright[this.browserConfig.browserName].connect(String(url));
|
||||
}
|
||||
|
||||
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||
return browser.newContext();
|
||||
}
|
||||
}
|
||||
|
||||
class PersistentContextFactory implements BrowserContextFactory {
|
||||
readonly browserConfig: FullConfig['browser'];
|
||||
private _userDataDirs = new Set<string>();
|
||||
|
||||
constructor(browserConfig: FullConfig['browser']) {
|
||||
this.browserConfig = browserConfig;
|
||||
}
|
||||
|
||||
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||
await injectCdpPort(this.browserConfig);
|
||||
testDebug('create browser context (persistent)');
|
||||
const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir();
|
||||
|
||||
this._userDataDirs.add(userDataDir);
|
||||
testDebug('lock user data dir', userDataDir);
|
||||
|
||||
const browserType = playwright[this.browserConfig.browserName];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
try {
|
||||
const browserContext = await browserType.launchPersistentContext(userDataDir, {
|
||||
...this.browserConfig.launchOptions,
|
||||
...this.browserConfig.contextOptions,
|
||||
handleSIGINT: false,
|
||||
handleSIGTERM: false,
|
||||
});
|
||||
const close = () => this._closeBrowserContext(browserContext, userDataDir);
|
||||
return { browserContext, close };
|
||||
} 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.`);
|
||||
if (error.message.includes('ProcessSingleton') || error.message.includes('Invalid URL')) {
|
||||
// User data directory is already in use, try again.
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`);
|
||||
}
|
||||
|
||||
private async _closeBrowserContext(browserContext: playwright.BrowserContext, userDataDir: string) {
|
||||
testDebug('close browser context (persistent)');
|
||||
testDebug('release user data dir', userDataDir);
|
||||
await browserContext.close().catch(() => {});
|
||||
this._userDataDirs.delete(userDataDir);
|
||||
testDebug('close browser context complete (persistent)');
|
||||
}
|
||||
|
||||
private async _createUserDataDir() {
|
||||
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-${this.browserConfig.launchOptions?.channel ?? this.browserConfig?.browserName}-profile`);
|
||||
await fs.promises.mkdir(result, { recursive: true });
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
async function injectCdpPort(browserConfig: FullConfig['browser']) {
|
||||
if (browserConfig.browserName === 'chromium')
|
||||
(browserConfig.launchOptions as any).cdpPort = await findFreePort();
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
162
src/config.ts
162
src/config.ts
@@ -15,7 +15,6 @@
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import net from 'net';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { devices } from 'playwright';
|
||||
@@ -29,7 +28,7 @@ export type CLIOptions = {
|
||||
blockedOrigins?: string[];
|
||||
blockServiceWorkers?: boolean;
|
||||
browser?: string;
|
||||
caps?: string;
|
||||
caps?: string[];
|
||||
cdpEndpoint?: string;
|
||||
config?: string;
|
||||
device?: string;
|
||||
@@ -38,20 +37,20 @@ export type CLIOptions = {
|
||||
host?: string;
|
||||
ignoreHttpsErrors?: boolean;
|
||||
isolated?: boolean;
|
||||
imageResponses: boolean;
|
||||
sandbox: boolean;
|
||||
imageResponses?: 'allow' | 'omit';
|
||||
sandbox?: boolean;
|
||||
outputDir?: string;
|
||||
port?: number;
|
||||
proxyBypass?: string;
|
||||
proxyServer?: string;
|
||||
saveTrace?: boolean;
|
||||
storageState?: string;
|
||||
userAgent?: string;
|
||||
userDataDir?: string;
|
||||
viewportSize?: string;
|
||||
vision?: boolean;
|
||||
};
|
||||
|
||||
const defaultConfig: Config = {
|
||||
const defaultConfig: FullConfig = {
|
||||
browser: {
|
||||
browserName: 'chromium',
|
||||
launchOptions: {
|
||||
@@ -67,16 +66,43 @@ const defaultConfig: Config = {
|
||||
allowedOrigins: undefined,
|
||||
blockedOrigins: undefined,
|
||||
},
|
||||
server: {},
|
||||
outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())),
|
||||
};
|
||||
|
||||
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));
|
||||
type BrowserUserConfig = NonNullable<Config['browser']>;
|
||||
|
||||
export type FullConfig = Config & {
|
||||
browser: Omit<BrowserUserConfig, 'browserName'> & {
|
||||
browserName: 'chromium' | 'firefox' | 'webkit';
|
||||
launchOptions: NonNullable<BrowserUserConfig['launchOptions']>;
|
||||
contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
|
||||
},
|
||||
network: NonNullable<Config['network']>,
|
||||
outputDir: string;
|
||||
server: NonNullable<Config['server']>,
|
||||
};
|
||||
|
||||
export async function resolveConfig(config: Config): Promise<FullConfig> {
|
||||
return mergeConfig(defaultConfig, config);
|
||||
}
|
||||
|
||||
export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> {
|
||||
let browserName: 'chromium' | 'firefox' | 'webkit';
|
||||
export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConfig> {
|
||||
const configInFile = await loadConfig(cliOptions.config);
|
||||
const envOverrides = configFromEnv();
|
||||
const cliOverrides = configFromCLIOptions(cliOptions);
|
||||
let result = defaultConfig;
|
||||
result = mergeConfig(result, configInFile);
|
||||
result = mergeConfig(result, envOverrides);
|
||||
result = mergeConfig(result, cliOverrides);
|
||||
// Derive artifact output directory from config.outputDir
|
||||
if (result.saveTrace)
|
||||
result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
|
||||
return result;
|
||||
}
|
||||
|
||||
export function configFromCLIOptions(cliOptions: CLIOptions): Config {
|
||||
let browserName: 'chromium' | 'firefox' | 'webkit' | undefined;
|
||||
let channel: string | undefined;
|
||||
switch (cliOptions.browser) {
|
||||
case 'chrome':
|
||||
@@ -97,9 +123,6 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
||||
case 'webkit':
|
||||
browserName = 'webkit';
|
||||
break;
|
||||
default:
|
||||
browserName = 'chromium';
|
||||
channel = 'chrome';
|
||||
}
|
||||
|
||||
// Launch options
|
||||
@@ -109,13 +132,9 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
||||
headless: cliOptions.headless,
|
||||
};
|
||||
|
||||
if (browserName === 'chromium') {
|
||||
(launchOptions as any).cdpPort = await findFreePort();
|
||||
if (!cliOptions.sandbox) {
|
||||
// --no-sandbox was passed, disable the sandbox
|
||||
launchOptions.chromiumSandbox = false;
|
||||
}
|
||||
}
|
||||
// --no-sandbox was passed, disable the sandbox
|
||||
if (!cliOptions.sandbox)
|
||||
launchOptions.chromiumSandbox = false;
|
||||
|
||||
if (cliOptions.proxyServer) {
|
||||
launchOptions.proxy = {
|
||||
@@ -125,6 +144,9 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
||||
launchOptions.proxy.bypass = cliOptions.proxyBypass;
|
||||
}
|
||||
|
||||
if (cliOptions.device && cliOptions.cdpEndpoint)
|
||||
throw new Error('Device emulation is not supported with cdpEndpoint.');
|
||||
|
||||
// Context options
|
||||
const contextOptions: BrowserContextOptions = cliOptions.device ? devices[cliOptions.device] : {};
|
||||
if (cliOptions.storageState)
|
||||
@@ -163,32 +185,47 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
||||
port: cliOptions.port,
|
||||
host: cliOptions.host,
|
||||
},
|
||||
capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
|
||||
vision: !!cliOptions.vision,
|
||||
capabilities: cliOptions.caps as ToolCapability[],
|
||||
network: {
|
||||
allowedOrigins: cliOptions.allowedOrigins,
|
||||
blockedOrigins: cliOptions.blockedOrigins,
|
||||
},
|
||||
saveTrace: cliOptions.saveTrace,
|
||||
outputDir: cliOptions.outputDir,
|
||||
imageResponses: cliOptions.imageResponses,
|
||||
};
|
||||
|
||||
if (!cliOptions.imageResponses) {
|
||||
// --no-image-responses was passed, disable image responses
|
||||
result.noImageResponses = true;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
function configFromEnv(): Config {
|
||||
const options: CLIOptions = {};
|
||||
options.allowedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_ALLOWED_ORIGINS);
|
||||
options.blockedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_BLOCKED_ORIGINS);
|
||||
options.blockServiceWorkers = envToBoolean(process.env.PLAYWRIGHT_MCP_BLOCK_SERVICE_WORKERS);
|
||||
options.browser = envToString(process.env.PLAYWRIGHT_MCP_BROWSER);
|
||||
options.caps = commaSeparatedList(process.env.PLAYWRIGHT_MCP_CAPS);
|
||||
options.cdpEndpoint = envToString(process.env.PLAYWRIGHT_MCP_CDP_ENDPOINT);
|
||||
options.config = envToString(process.env.PLAYWRIGHT_MCP_CONFIG);
|
||||
options.device = envToString(process.env.PLAYWRIGHT_MCP_DEVICE);
|
||||
options.executablePath = envToString(process.env.PLAYWRIGHT_MCP_EXECUTABLE_PATH);
|
||||
options.headless = envToBoolean(process.env.PLAYWRIGHT_MCP_HEADLESS);
|
||||
options.host = envToString(process.env.PLAYWRIGHT_MCP_HOST);
|
||||
options.ignoreHttpsErrors = envToBoolean(process.env.PLAYWRIGHT_MCP_IGNORE_HTTPS_ERRORS);
|
||||
options.isolated = envToBoolean(process.env.PLAYWRIGHT_MCP_ISOLATED);
|
||||
if (process.env.PLAYWRIGHT_MCP_IMAGE_RESPONSES === 'omit')
|
||||
options.imageResponses = 'omit';
|
||||
options.sandbox = envToBoolean(process.env.PLAYWRIGHT_MCP_SANDBOX);
|
||||
options.outputDir = envToString(process.env.PLAYWRIGHT_MCP_OUTPUT_DIR);
|
||||
options.port = envToNumber(process.env.PLAYWRIGHT_MCP_PORT);
|
||||
options.proxyBypass = envToString(process.env.PLAYWRIGHT_MCP_PROXY_BYPASS);
|
||||
options.proxyServer = envToString(process.env.PLAYWRIGHT_MCP_PROXY_SERVER);
|
||||
options.saveTrace = envToBoolean(process.env.PLAYWRIGHT_MCP_SAVE_TRACE);
|
||||
options.storageState = envToString(process.env.PLAYWRIGHT_MCP_STORAGE_STATE);
|
||||
options.userAgent = envToString(process.env.PLAYWRIGHT_MCP_USER_AGENT);
|
||||
options.userDataDir = envToString(process.env.PLAYWRIGHT_MCP_USER_DATA_DIR);
|
||||
options.viewportSize = envToString(process.env.PLAYWRIGHT_MCP_VIEWPORT_SIZE);
|
||||
return configFromCLIOptions(options);
|
||||
}
|
||||
|
||||
async function loadConfig(configFile: string | undefined): Promise<Config> {
|
||||
@@ -202,11 +239,10 @@ async function loadConfig(configFile: string | undefined): Promise<Config> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function outputFile(config: Config, name: string): Promise<string> {
|
||||
const result = config.outputDir ?? os.tmpdir();
|
||||
await fs.promises.mkdir(result, { recursive: true });
|
||||
export async function outputFile(config: FullConfig, name: string): Promise<string> {
|
||||
await fs.promises.mkdir(config.outputDir, { recursive: true });
|
||||
const fileName = sanitizeForFilePath(name);
|
||||
return path.join(result, fileName);
|
||||
return path.join(config.outputDir, fileName);
|
||||
}
|
||||
|
||||
function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
|
||||
@@ -215,10 +251,12 @@ function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
|
||||
) as Partial<T>;
|
||||
}
|
||||
|
||||
function mergeConfig(base: Config, overrides: Config): Config {
|
||||
const browser: Config['browser'] = {
|
||||
function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
|
||||
const browser: FullConfig['browser'] = {
|
||||
...pickDefined(base.browser),
|
||||
...pickDefined(overrides.browser),
|
||||
browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium',
|
||||
isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false,
|
||||
launchOptions: {
|
||||
...pickDefined(base.browser?.launchOptions),
|
||||
...pickDefined(overrides.browser?.launchOptions),
|
||||
@@ -241,5 +279,39 @@ function mergeConfig(base: Config, overrides: Config): Config {
|
||||
...pickDefined(base.network),
|
||||
...pickDefined(overrides.network),
|
||||
},
|
||||
};
|
||||
server: {
|
||||
...pickDefined(base.server),
|
||||
...pickDefined(overrides.server),
|
||||
},
|
||||
} as FullConfig;
|
||||
}
|
||||
|
||||
export function semicolonSeparatedList(value: string | undefined): string[] | undefined {
|
||||
if (!value)
|
||||
return undefined;
|
||||
return value.split(';').map(v => v.trim());
|
||||
}
|
||||
|
||||
export function commaSeparatedList(value: string | undefined): string[] | undefined {
|
||||
if (!value)
|
||||
return undefined;
|
||||
return value.split(',').map(v => v.trim());
|
||||
}
|
||||
|
||||
function envToNumber(value: string | undefined): number | undefined {
|
||||
if (!value)
|
||||
return undefined;
|
||||
return +value;
|
||||
}
|
||||
|
||||
function envToBoolean(value: string | undefined): boolean | undefined {
|
||||
if (value === 'true' || value === '1')
|
||||
return true;
|
||||
if (value === 'false' || value === '0')
|
||||
return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function envToString(value: string | undefined): string | undefined {
|
||||
return value ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
@@ -14,22 +14,22 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { Server as McpServer } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema, Tool as McpTool } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
import { Context, packageJSON } from './context.js';
|
||||
import { snapshotTools, visionTools } from './tools.js';
|
||||
import { Context } from './context.js';
|
||||
import { allTools } from './tools.js';
|
||||
import { packageJSON } from './package.js';
|
||||
|
||||
import type { Config } from '../config.js';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import { FullConfig } from './config.js';
|
||||
|
||||
export async function createConnection(config: Config): Promise<Connection> {
|
||||
const allTools = config.vision ? visionTools : snapshotTools;
|
||||
const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));
|
||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||
|
||||
const context = new Context(tools, config);
|
||||
const server = new Server({ name: 'Playwright', version: packageJSON.version }, {
|
||||
export function createConnection(config: FullConfig, browserContextFactory: BrowserContextFactory): Connection {
|
||||
const tools = allTools.filter(tool => tool.capability.startsWith('core') || config.capabilities?.includes(tool.capability));
|
||||
const context = new Context(tools, config, browserContextFactory);
|
||||
const server = new McpServer({ name: 'Playwright', version: packageJSON.version }, {
|
||||
capabilities: {
|
||||
tools: {},
|
||||
}
|
||||
@@ -74,26 +74,19 @@ export async function createConnection(config: Config): Promise<Connection> {
|
||||
}
|
||||
});
|
||||
|
||||
const connection = new Connection(server, context);
|
||||
return connection;
|
||||
return new Connection(server, context);
|
||||
}
|
||||
|
||||
export class Connection {
|
||||
readonly server: Server;
|
||||
readonly server: McpServer;
|
||||
readonly context: Context;
|
||||
|
||||
constructor(server: Server, context: Context) {
|
||||
constructor(server: McpServer, context: Context) {
|
||||
this.server = server;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
async connect(transport: Transport) {
|
||||
await this.server.connect(transport);
|
||||
await new Promise<void>(resolve => {
|
||||
this.server.oninitialized = () => resolve();
|
||||
});
|
||||
if (this.server.getClientVersion()?.name.includes('cursor'))
|
||||
this.context.config.noImageResponses = true;
|
||||
this.server.oninitialized = () => {
|
||||
this.context.clientVersion = this.server.getClientVersion();
|
||||
};
|
||||
}
|
||||
|
||||
async close() {
|
||||
|
||||
215
src/context.ts
215
src/context.ts
@@ -14,44 +14,47 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import url from 'node:url';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import debug from 'debug';
|
||||
import * as playwright from 'playwright';
|
||||
|
||||
import { waitForCompletion } from './tools/utils.js';
|
||||
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
|
||||
import { ManualPromise } from './manualPromise.js';
|
||||
import { Tab } from './tab.js';
|
||||
|
||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { ModalState, Tool, ToolActionResult } from './tools/tool.js';
|
||||
import type { Config } from '../config.js';
|
||||
import { outputFile } from './config.js';
|
||||
|
||||
import type { ModalState, Tool, ToolActionResult } from './tools/tool.js';
|
||||
import type { FullConfig } from './config.js';
|
||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||
|
||||
type PendingAction = {
|
||||
dialogShown: ManualPromise<void>;
|
||||
};
|
||||
|
||||
type BrowserContextAndBrowser = {
|
||||
browser?: playwright.Browser;
|
||||
browserContext: playwright.BrowserContext;
|
||||
};
|
||||
const testDebug = debug('pw:mcp:test');
|
||||
|
||||
export class Context {
|
||||
readonly tools: Tool[];
|
||||
readonly config: Config;
|
||||
private _browserContextPromise: Promise<BrowserContextAndBrowser> | undefined;
|
||||
readonly config: FullConfig;
|
||||
private _browserContextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> | undefined;
|
||||
private _browserContextFactory: BrowserContextFactory;
|
||||
private _tabs: Tab[] = [];
|
||||
private _currentTab: Tab | undefined;
|
||||
private _modalStates: (ModalState & { tab: Tab })[] = [];
|
||||
private _pendingAction: PendingAction | undefined;
|
||||
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
|
||||
clientVersion: { name: string; version: string; } | undefined;
|
||||
|
||||
constructor(tools: Tool[], config: Config) {
|
||||
constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) {
|
||||
this.tools = tools;
|
||||
this.config = config;
|
||||
this._browserContextFactory = browserContextFactory;
|
||||
testDebug('create context');
|
||||
}
|
||||
|
||||
clientSupportsImages(): boolean {
|
||||
if (this.config.imageResponses === 'omit')
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
modalStates(): ModalState[] {
|
||||
@@ -83,7 +86,7 @@ export class Context {
|
||||
|
||||
currentTabOrDie(): Tab {
|
||||
if (!this._currentTab)
|
||||
throw new Error('No current snapshot available. Capture a snapshot of navigate to a new location first.');
|
||||
throw new Error('No current snapshot available. Capture a snapshot or navigate to a new location first.');
|
||||
return this._currentTab;
|
||||
}
|
||||
|
||||
@@ -95,7 +98,7 @@ export class Context {
|
||||
}
|
||||
|
||||
async selectTab(index: number) {
|
||||
this._currentTab = this._tabs[index - 1];
|
||||
this._currentTab = this._tabs[index];
|
||||
await this._currentTab.page.bringToFront();
|
||||
}
|
||||
|
||||
@@ -112,16 +115,16 @@ export class Context {
|
||||
const lines: string[] = ['### Open tabs'];
|
||||
for (let i = 0; i < this._tabs.length; i++) {
|
||||
const tab = this._tabs[i];
|
||||
const title = await tab.page.title();
|
||||
const title = await tab.title();
|
||||
const url = tab.page.url();
|
||||
const current = tab === this._currentTab ? ' (current)' : '';
|
||||
lines.push(`- ${i + 1}:${current} [${title}] (${url})`);
|
||||
lines.push(`- ${i}:${current} [${title}] (${url})`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async closeTab(index: number | undefined) {
|
||||
const tab = index === undefined ? this._currentTab : this._tabs[index - 1];
|
||||
const tab = index === undefined ? this._currentTab : this._tabs[index];
|
||||
await tab?.page.close();
|
||||
return await this.listTabsMarkdown();
|
||||
}
|
||||
@@ -130,7 +133,6 @@ export class Context {
|
||||
// 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;
|
||||
@@ -146,26 +148,26 @@ export class Context {
|
||||
|
||||
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 actionResult = await this._raceAgainstModalDialogs(async () => {
|
||||
try {
|
||||
if (waitForNetwork)
|
||||
return await waitForCompletion(this, tab, async () => action?.()) ?? undefined;
|
||||
else
|
||||
return await action?.() ?? undefined;
|
||||
} finally {
|
||||
if (captureSnapshot && !this._javaScriptBlocked())
|
||||
await tab.captureSnapshot();
|
||||
}
|
||||
});
|
||||
|
||||
const result: string[] = [];
|
||||
result.push(`- Ran Playwright code:
|
||||
result.push(`### Ran Playwright code
|
||||
\`\`\`js
|
||||
${code.join('\n')}
|
||||
\`\`\`
|
||||
`);
|
||||
\`\`\``);
|
||||
|
||||
if (this.modalStates().length) {
|
||||
result.push(...this.modalStatesMarkdown());
|
||||
result.push('', ...this.modalStatesMarkdown());
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
@@ -174,6 +176,13 @@ ${code.join('\n')}
|
||||
};
|
||||
}
|
||||
|
||||
const messages = tab.takeRecentConsoleMessages();
|
||||
if (messages.length) {
|
||||
result.push('', `### New console messages`);
|
||||
for (const message of messages)
|
||||
result.push(`- ${trim(message.toString(), 100)}`);
|
||||
}
|
||||
|
||||
if (this._downloads.length) {
|
||||
result.push('', '### Downloads');
|
||||
for (const entry of this._downloads) {
|
||||
@@ -182,22 +191,23 @@ ${code.join('\n')}
|
||||
else
|
||||
result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
|
||||
}
|
||||
result.push('');
|
||||
}
|
||||
|
||||
if (this.tabs().length > 1)
|
||||
result.push(await this.listTabsMarkdown(), '');
|
||||
if (captureSnapshot && tab.hasSnapshot()) {
|
||||
if (this.tabs().length > 1)
|
||||
result.push('', await this.listTabsMarkdown());
|
||||
|
||||
if (this.tabs().length > 1)
|
||||
result.push('### Current tab');
|
||||
if (this.tabs().length > 1)
|
||||
result.push('', '### Current tab');
|
||||
else
|
||||
result.push('', '### Page state');
|
||||
|
||||
result.push(
|
||||
`- Page URL: ${tab.page.url()}`,
|
||||
`- Page Title: ${await tab.page.title()}`
|
||||
);
|
||||
|
||||
if (captureSnapshot && tab.hasSnapshot())
|
||||
result.push(
|
||||
`- Page URL: ${tab.page.url()}`,
|
||||
`- Page Title: ${await tab.title()}`
|
||||
);
|
||||
result.push(tab.snapshotOrDie().text());
|
||||
}
|
||||
|
||||
const content = actionResult?.content ?? [];
|
||||
|
||||
@@ -213,10 +223,14 @@ ${code.join('\n')}
|
||||
}
|
||||
|
||||
async waitForTimeout(time: number) {
|
||||
if (this._currentTab && !this._javaScriptBlocked())
|
||||
await this._currentTab.page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
|
||||
else
|
||||
if (!this._currentTab || this._javaScriptBlocked()) {
|
||||
await new Promise(f => setTimeout(f, time));
|
||||
return;
|
||||
}
|
||||
|
||||
await callOnPageNoTrace(this._currentTab.page, page => {
|
||||
return page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
|
||||
});
|
||||
}
|
||||
|
||||
private async _raceAgainstModalDialogs(action: () => Promise<ToolActionResult>): Promise<ToolActionResult> {
|
||||
@@ -284,13 +298,15 @@ ${code.join('\n')}
|
||||
if (!this._browserContextPromise)
|
||||
return;
|
||||
|
||||
testDebug('close context');
|
||||
|
||||
const promise = this._browserContextPromise;
|
||||
this._browserContextPromise = undefined;
|
||||
|
||||
await promise.then(async ({ browserContext, browser }) => {
|
||||
await browserContext.close().then(async () => {
|
||||
await browser?.close();
|
||||
}).catch(() => {});
|
||||
await promise.then(async ({ browserContext, close }) => {
|
||||
if (this.config.saveTrace)
|
||||
await browserContext.tracing.stop();
|
||||
await close();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -318,85 +334,28 @@ ${code.join('\n')}
|
||||
return this._browserContextPromise;
|
||||
}
|
||||
|
||||
private async _setupBrowserContext(): Promise<BrowserContextAndBrowser> {
|
||||
const { browser, browserContext } = await this._createBrowserContext();
|
||||
private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||
// TODO: move to the browser context factory to make it based on isolation mode.
|
||||
const result = await this._browserContextFactory.createContext();
|
||||
const { browserContext } = result;
|
||||
await this._setupRequestInterception(browserContext);
|
||||
for (const page of browserContext.pages())
|
||||
this._onPageCreated(page);
|
||||
browserContext.on('page', page => this._onPageCreated(page));
|
||||
return { browser, browserContext };
|
||||
}
|
||||
|
||||
private async _createBrowserContext(): Promise<BrowserContextAndBrowser> {
|
||||
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.config.saveTrace) {
|
||||
await browserContext.tracing.start({
|
||||
name: 'trace',
|
||||
screenshots: false,
|
||||
snapshots: true,
|
||||
sources: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.config.browser?.cdpEndpoint) {
|
||||
const browser = await playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint);
|
||||
const browserContext = this.config.browser.isolated ? await browser.newContext() : browser.contexts()[0];
|
||||
return { browser, browserContext };
|
||||
}
|
||||
|
||||
return this.config.browser?.isolated ?
|
||||
await createIsolatedContext(this.config.browser) :
|
||||
await launchPersistentContext(this.config.browser);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
async function createIsolatedContext(browserConfig: Config['browser']): Promise<BrowserContextAndBrowser> {
|
||||
try {
|
||||
const browserName = browserConfig?.browserName ?? 'chromium';
|
||||
const browserType = playwright[browserName];
|
||||
const browser = await browserType.launch(browserConfig?.launchOptions);
|
||||
const browserContext = await browser.newContext(browserConfig?.contextOptions);
|
||||
return { browser, browserContext };
|
||||
} 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;
|
||||
}
|
||||
function trim(text: string, maxLength: number) {
|
||||
if (text.length <= maxLength)
|
||||
return text;
|
||||
return text.slice(0, maxLength) + '...';
|
||||
}
|
||||
|
||||
async function launchPersistentContext(browserConfig: Config['browser']): Promise<BrowserContextAndBrowser> {
|
||||
try {
|
||||
const browserName = browserConfig?.browserName ?? 'chromium';
|
||||
const userDataDir = browserConfig?.userDataDir ?? await createUserDataDir({ ...browserConfig, browserName });
|
||||
const browserType = playwright[browserName];
|
||||
const browserContext = await browserType.launchPersistentContext(userDataDir, { ...browserConfig?.launchOptions, ...browserConfig?.contextOptions });
|
||||
return { browserContext };
|
||||
} 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 createUserDataDir(browserConfig: Config['browser']) {
|
||||
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-${browserConfig?.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
|
||||
await fs.promises.mkdir(result, { recursive: true });
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
||||
return (locator as any)._generateLocatorString();
|
||||
}
|
||||
|
||||
const __filename = url.fileURLToPath(import.meta.url);
|
||||
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8'));
|
||||
|
||||
37
src/fileUtils.ts
Normal file
37
src/fileUtils.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { FullConfig } from './config.js';
|
||||
|
||||
export function cacheDir() {
|
||||
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);
|
||||
return path.join(cacheDirectory, 'ms-playwright');
|
||||
}
|
||||
|
||||
export async function userDataDir(browserConfig: FullConfig['browser']) {
|
||||
return path.join(cacheDir(), 'ms-playwright', `mcp-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
|
||||
}
|
||||
232
src/httpServer.ts
Normal file
232
src/httpServer.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* 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 path from 'path';
|
||||
import http from 'http';
|
||||
import net from 'net';
|
||||
|
||||
import mime from 'mime';
|
||||
|
||||
import { ManualPromise } from './manualPromise.js';
|
||||
|
||||
|
||||
export type ServerRouteHandler = (request: http.IncomingMessage, response: http.ServerResponse) => void;
|
||||
|
||||
export type Transport = {
|
||||
sendEvent?: (method: string, params: any) => void;
|
||||
close?: () => void;
|
||||
onconnect: () => void;
|
||||
dispatch: (method: string, params: any) => Promise<any>;
|
||||
onclose: () => void;
|
||||
};
|
||||
|
||||
export class HttpServer {
|
||||
private _server: http.Server;
|
||||
private _urlPrefixPrecise: string = '';
|
||||
private _urlPrefixHumanReadable: string = '';
|
||||
private _port: number = 0;
|
||||
private _routes: { prefix?: string, exact?: string, handler: ServerRouteHandler }[] = [];
|
||||
|
||||
constructor() {
|
||||
this._server = http.createServer(this._onRequest.bind(this));
|
||||
decorateServer(this._server);
|
||||
}
|
||||
|
||||
server() {
|
||||
return this._server;
|
||||
}
|
||||
|
||||
routePrefix(prefix: string, handler: ServerRouteHandler) {
|
||||
this._routes.push({ prefix, handler });
|
||||
}
|
||||
|
||||
routePath(path: string, handler: ServerRouteHandler) {
|
||||
this._routes.push({ exact: path, handler });
|
||||
}
|
||||
|
||||
port(): number {
|
||||
return this._port;
|
||||
}
|
||||
|
||||
private async _tryStart(port: number | undefined, host: string) {
|
||||
const errorPromise = new ManualPromise();
|
||||
const errorListener = (error: Error) => errorPromise.reject(error);
|
||||
this._server.on('error', errorListener);
|
||||
|
||||
try {
|
||||
this._server.listen(port, host);
|
||||
await Promise.race([
|
||||
new Promise(cb => this._server!.once('listening', cb)),
|
||||
errorPromise,
|
||||
]);
|
||||
} finally {
|
||||
this._server.removeListener('error', errorListener);
|
||||
}
|
||||
}
|
||||
|
||||
async start(options: { port?: number, preferredPort?: number, host?: string } = {}): Promise<void> {
|
||||
const host = options.host || 'localhost';
|
||||
if (options.preferredPort) {
|
||||
try {
|
||||
await this._tryStart(options.preferredPort, host);
|
||||
} catch (e: any) {
|
||||
if (!e || !e.message || !e.message.includes('EADDRINUSE'))
|
||||
throw e;
|
||||
await this._tryStart(undefined, host);
|
||||
}
|
||||
} else {
|
||||
await this._tryStart(options.port, host);
|
||||
}
|
||||
|
||||
const address = this._server.address();
|
||||
if (typeof address === 'string') {
|
||||
this._urlPrefixPrecise = address;
|
||||
this._urlPrefixHumanReadable = address;
|
||||
} else {
|
||||
this._port = address!.port;
|
||||
const resolvedHost = address!.family === 'IPv4' ? address!.address : `[${address!.address}]`;
|
||||
this._urlPrefixPrecise = `http://${resolvedHost}:${address!.port}`;
|
||||
this._urlPrefixHumanReadable = `http://${host}:${address!.port}`;
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
await new Promise(cb => this._server!.close(cb));
|
||||
}
|
||||
|
||||
urlPrefix(purpose: 'human-readable' | 'precise'): string {
|
||||
return purpose === 'human-readable' ? this._urlPrefixHumanReadable : this._urlPrefixPrecise;
|
||||
}
|
||||
|
||||
serveFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string, headers?: { [name: string]: string }): boolean {
|
||||
try {
|
||||
for (const [name, value] of Object.entries(headers || {}))
|
||||
response.setHeader(name, value);
|
||||
if (request.headers.range)
|
||||
this._serveRangeFile(request, response, absoluteFilePath);
|
||||
else
|
||||
this._serveFile(response, absoluteFilePath);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_serveFile(response: http.ServerResponse, absoluteFilePath: string) {
|
||||
const content = fs.readFileSync(absoluteFilePath);
|
||||
response.statusCode = 200;
|
||||
const contentType = mime.getType(path.extname(absoluteFilePath)) || 'application/octet-stream';
|
||||
response.setHeader('Content-Type', contentType);
|
||||
response.setHeader('Content-Length', content.byteLength);
|
||||
response.end(content);
|
||||
}
|
||||
|
||||
_serveRangeFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string) {
|
||||
const range = request.headers.range;
|
||||
if (!range || !range.startsWith('bytes=') || range.includes(', ') || [...range].filter(char => char === '-').length !== 1) {
|
||||
response.statusCode = 400;
|
||||
return response.end('Bad request');
|
||||
}
|
||||
|
||||
// Parse the range header: https://datatracker.ietf.org/doc/html/rfc7233#section-2.1
|
||||
const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
|
||||
|
||||
// Both start and end (when passing to fs.createReadStream) and the range header are inclusive and start counting at 0.
|
||||
let start: number;
|
||||
let end: number;
|
||||
const size = fs.statSync(absoluteFilePath).size;
|
||||
if (startStr !== '' && endStr === '') {
|
||||
// No end specified: use the whole file
|
||||
start = +startStr;
|
||||
end = size - 1;
|
||||
} else if (startStr === '' && endStr !== '') {
|
||||
// No start specified: calculate start manually
|
||||
start = size - +endStr;
|
||||
end = size - 1;
|
||||
} else {
|
||||
start = +startStr;
|
||||
end = +endStr;
|
||||
}
|
||||
|
||||
// Handle unavailable range request
|
||||
if (Number.isNaN(start) || Number.isNaN(end) || start >= size || end >= size || start > end) {
|
||||
// Return the 416 Range Not Satisfiable: https://datatracker.ietf.org/doc/html/rfc7233#section-4.4
|
||||
response.writeHead(416, {
|
||||
'Content-Range': `bytes */${size}`
|
||||
});
|
||||
return response.end();
|
||||
}
|
||||
|
||||
// Sending Partial Content: https://datatracker.ietf.org/doc/html/rfc7233#section-4.1
|
||||
response.writeHead(206, {
|
||||
'Content-Range': `bytes ${start}-${end}/${size}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': end - start + 1,
|
||||
'Content-Type': mime.getType(path.extname(absoluteFilePath))!,
|
||||
});
|
||||
|
||||
const readable = fs.createReadStream(absoluteFilePath, { start, end });
|
||||
readable.pipe(response);
|
||||
}
|
||||
|
||||
private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
|
||||
if (request.method === 'OPTIONS') {
|
||||
response.writeHead(200);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
request.on('error', () => response.end());
|
||||
try {
|
||||
if (!request.url) {
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const url = new URL('http://localhost' + request.url);
|
||||
for (const route of this._routes) {
|
||||
if (route.exact && url.pathname === route.exact) {
|
||||
route.handler(request, response);
|
||||
return;
|
||||
}
|
||||
if (route.prefix && url.pathname.startsWith(route.prefix)) {
|
||||
route.handler(request, response);
|
||||
return;
|
||||
}
|
||||
}
|
||||
response.statusCode = 404;
|
||||
response.end();
|
||||
} catch (e) {
|
||||
response.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function decorateServer(server: net.Server) {
|
||||
const sockets = new Set<net.Socket>();
|
||||
server.on('connection', socket => {
|
||||
sockets.add(socket);
|
||||
socket.once('close', () => sockets.delete(socket));
|
||||
});
|
||||
|
||||
const close = server.close;
|
||||
server.close = (callback?: (err?: Error) => void) => {
|
||||
for (const socket of sockets)
|
||||
socket.destroy();
|
||||
sockets.clear();
|
||||
return close.call(server, callback);
|
||||
};
|
||||
}
|
||||
29
src/index.ts
29
src/index.ts
@@ -14,10 +14,33 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Connection, createConnection as createConnectionImpl } from './connection.js';
|
||||
import { createConnection as createConnectionImpl } from './connection.js';
|
||||
import type { Connection } from '../index.js';
|
||||
import { resolveConfig } from './config.js';
|
||||
import { contextFactory } from './browserContextFactory.js';
|
||||
|
||||
import type { Config } from '../config.js';
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||
|
||||
export async function createConnection(config: Config = {}): Promise<Connection> {
|
||||
return createConnectionImpl(config);
|
||||
export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Connection> {
|
||||
const config = await resolveConfig(userConfig);
|
||||
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config.browser);
|
||||
return createConnectionImpl(config, factory);
|
||||
}
|
||||
|
||||
class SimpleBrowserContextFactory implements BrowserContextFactory {
|
||||
private readonly _contextGetter: () => Promise<BrowserContext>;
|
||||
|
||||
constructor(contextGetter: () => Promise<BrowserContext>) {
|
||||
this._contextGetter = contextGetter;
|
||||
}
|
||||
|
||||
async createContext(): Promise<{ browserContext: BrowserContext, close: () => Promise<void> }> {
|
||||
const browserContext = await this._contextGetter();
|
||||
return {
|
||||
browserContext,
|
||||
close: () => browserContext.close()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,23 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Context } from '../context.js';
|
||||
import debug from 'debug';
|
||||
|
||||
export type ResourceSchema = {
|
||||
uri: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
mimeType?: string;
|
||||
};
|
||||
const errorsDebug = debug('pw:mcp:errors');
|
||||
|
||||
export type ResourceResult = {
|
||||
uri: string;
|
||||
mimeType?: string;
|
||||
text?: string;
|
||||
blob?: string;
|
||||
};
|
||||
export function logUnhandledError(error: unknown) {
|
||||
errorsDebug(error);
|
||||
}
|
||||
|
||||
export type Resource = {
|
||||
schema: ResourceSchema;
|
||||
read: (context: Context, uri: string) => Promise<ResourceResult[]>;
|
||||
};
|
||||
export const testDebug = debug('pw:mcp:test');
|
||||
22
src/package.ts
Normal file
22
src/package.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 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 'node:fs';
|
||||
import url from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const __filename = url.fileURLToPath(import.meta.url);
|
||||
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8'));
|
||||
@@ -15,6 +15,11 @@
|
||||
*/
|
||||
|
||||
import * as playwright from 'playwright';
|
||||
import { callOnPageNoTrace } from './tools/utils.js';
|
||||
|
||||
type PageEx = playwright.Page & {
|
||||
_snapshotForAI: () => Promise<string>;
|
||||
};
|
||||
|
||||
export class PageSnapshot {
|
||||
private _page: playwright.Page;
|
||||
@@ -35,16 +40,16 @@ export class PageSnapshot {
|
||||
}
|
||||
|
||||
private async _build() {
|
||||
const yamlDocument = await (this._page as any)._snapshotForAI();
|
||||
const snapshot = await callOnPageNoTrace(this._page, page => (page as PageEx)._snapshotForAI());
|
||||
this._text = [
|
||||
`- Page Snapshot`,
|
||||
`- Page Snapshot:`,
|
||||
'```yaml',
|
||||
yamlDocument.toString({ indentSeq: false }).trim(),
|
||||
snapshot,
|
||||
'```',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
refLocator(ref: string): playwright.Locator {
|
||||
return this._page.locator(`aria-ref=${ref}`);
|
||||
refLocator(params: { element: string, ref: string }): playwright.Locator {
|
||||
return this._page.locator(`aria-ref=${params.ref}`).describe(params.element);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { program } from 'commander';
|
||||
import { program, Option } from 'commander';
|
||||
// @ts-ignore
|
||||
import { startTraceViewerServer } from 'playwright-core/lib/server';
|
||||
|
||||
import { startHttpTransport, startStdioTransport } from './transport.js';
|
||||
import { resolveConfig } from './config.js';
|
||||
|
||||
import type { Connection } from './connection.js';
|
||||
import { packageJSON } from './context.js';
|
||||
import { startHttpServer, startHttpTransport, startStdioTransport } from './transport.js';
|
||||
import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js';
|
||||
import { Server } from './server.js';
|
||||
import { packageJSON } from './package.js';
|
||||
|
||||
program
|
||||
.version('Version ' + packageJSON.version)
|
||||
@@ -29,7 +30,7 @@ program
|
||||
.option('--blocked-origins <origins>', 'semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.', semicolonSeparatedList)
|
||||
.option('--block-service-workers', 'block service workers')
|
||||
.option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
|
||||
.option('--caps <caps>', 'comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
|
||||
.option('--caps <caps>', 'comma-separated list of additional capabilities to enable, possible values: vision, pdf.', commaSeparatedList)
|
||||
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
|
||||
.option('--config <path>', 'path to the configuration file.')
|
||||
.option('--device <device>', 'device to emulate, for example: "iPhone 15"')
|
||||
@@ -38,43 +39,42 @@ program
|
||||
.option('--host <host>', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
|
||||
.option('--ignore-https-errors', 'ignore https errors')
|
||||
.option('--isolated', 'keep the browser profile in memory, do not save it to disk.')
|
||||
.option('--no-image-responses', 'do not send image responses to the client.')
|
||||
.option('--image-responses <mode>', 'whether to send image responses to the client. Can be "allow" or "omit", Defaults to "allow".')
|
||||
.option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.')
|
||||
.option('--output-dir <path>', 'path to the directory for output files.')
|
||||
.option('--port <port>', 'port to listen on for SSE transport.')
|
||||
.option('--proxy-bypass <bypass>', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"')
|
||||
.option('--proxy-server <proxy>', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"')
|
||||
.option('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.')
|
||||
.option('--storage-state <path>', 'path to the storage state file for isolated sessions.')
|
||||
.option('--user-agent <ua string>', 'specify user agent string')
|
||||
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
|
||||
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"')
|
||||
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
|
||||
.addOption(new Option('--vision', 'Legacy option, use --caps=vision instead').hideHelp())
|
||||
.action(async options => {
|
||||
const config = await resolveConfig(options);
|
||||
const connectionList: Connection[] = [];
|
||||
setupExitWatchdog(connectionList);
|
||||
if (options.vision) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('The --vision option is deprecated, use --caps=vision instead');
|
||||
options.caps = 'vision';
|
||||
}
|
||||
const config = await resolveCLIConfig(options);
|
||||
const httpServer = config.server.port !== undefined ? await startHttpServer(config.server) : undefined;
|
||||
|
||||
if (options.port)
|
||||
startHttpTransport(config, +options.port, options.host, connectionList);
|
||||
const server = new Server(config);
|
||||
server.setupExitWatchdog();
|
||||
|
||||
if (httpServer)
|
||||
startHttpTransport(httpServer, server);
|
||||
else
|
||||
await startStdioTransport(config, connectionList);
|
||||
await startStdioTransport(server);
|
||||
|
||||
if (config.saveTrace) {
|
||||
const server = await startTraceViewerServer();
|
||||
const urlPrefix = server.urlPrefix('human-readable');
|
||||
const url = urlPrefix + '/trace/index.html?trace=' + config.browser.launchOptions.tracesDir + '/trace.json';
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('\nTrace viewer listening on ' + url);
|
||||
}
|
||||
});
|
||||
|
||||
function setupExitWatchdog(connectionList: Connection[]) {
|
||||
const handleExit = async () => {
|
||||
setTimeout(() => process.exit(0), 15000);
|
||||
for (const connection of connectionList)
|
||||
await connection.close();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.stdin.on('close', handleExit);
|
||||
process.on('SIGINT', handleExit);
|
||||
process.on('SIGTERM', handleExit);
|
||||
}
|
||||
|
||||
function semicolonSeparatedList(value: string): string[] {
|
||||
return value.split(';').map(v => v.trim());
|
||||
}
|
||||
|
||||
program.parse(process.argv);
|
||||
void program.parseAsync(process.argv);
|
||||
|
||||
59
src/server.ts
Normal file
59
src/server.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createConnection } from './connection.js';
|
||||
import { contextFactory } from './browserContextFactory.js';
|
||||
|
||||
import type { FullConfig } from './config.js';
|
||||
import type { Connection } from './connection.js';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||
|
||||
export class Server {
|
||||
readonly config: FullConfig;
|
||||
private _connectionList: Connection[] = [];
|
||||
private _browserConfig: FullConfig['browser'];
|
||||
private _contextFactory: BrowserContextFactory;
|
||||
|
||||
constructor(config: FullConfig) {
|
||||
this.config = config;
|
||||
this._browserConfig = config.browser;
|
||||
this._contextFactory = contextFactory(this._browserConfig);
|
||||
}
|
||||
|
||||
async createConnection(transport: Transport): Promise<Connection> {
|
||||
const connection = createConnection(this.config, this._contextFactory);
|
||||
this._connectionList.push(connection);
|
||||
await connection.server.connect(transport);
|
||||
return connection;
|
||||
}
|
||||
|
||||
setupExitWatchdog() {
|
||||
let isExiting = false;
|
||||
const handleExit = async () => {
|
||||
if (isExiting)
|
||||
return;
|
||||
isExiting = true;
|
||||
setTimeout(() => process.exit(0), 15000);
|
||||
await Promise.all(this._connectionList.map(connection => connection.close()));
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.stdin.on('close', handleExit);
|
||||
process.on('SIGINT', handleExit);
|
||||
process.on('SIGTERM', handleExit);
|
||||
}
|
||||
}
|
||||
66
src/tab.ts
66
src/tab.ts
@@ -17,13 +17,16 @@
|
||||
import * as playwright from 'playwright';
|
||||
|
||||
import { PageSnapshot } from './pageSnapshot.js';
|
||||
import { callOnPageNoTrace } from './tools/utils.js';
|
||||
import { logUnhandledError } from './log.js';
|
||||
|
||||
import type { Context } from './context.js';
|
||||
|
||||
export class Tab {
|
||||
readonly context: Context;
|
||||
readonly page: playwright.Page;
|
||||
private _consoleMessages: playwright.ConsoleMessage[] = [];
|
||||
private _consoleMessages: ConsoleMessage[] = [];
|
||||
private _recentConsoleMessages: ConsoleMessage[] = [];
|
||||
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
|
||||
private _snapshot: PageSnapshot | undefined;
|
||||
private _onPageClose: (tab: Tab) => void;
|
||||
@@ -32,7 +35,8 @@ export class Tab {
|
||||
this.context = context;
|
||||
this.page = page;
|
||||
this._onPageClose = onPageClose;
|
||||
page.on('console', event => this._consoleMessages.push(event));
|
||||
page.on('console', event => this._handleConsoleMessage(messageToConsoleMessage(event)));
|
||||
page.on('pageerror', error => this._handleConsoleMessage(pageErrorToConsoleMessage(error)));
|
||||
page.on('request', request => this._requests.set(request, null));
|
||||
page.on('response', response => this._requests.set(response.request(), response));
|
||||
page.on('close', () => this._onClose());
|
||||
@@ -53,18 +57,32 @@ export class Tab {
|
||||
|
||||
private _clearCollectedArtifacts() {
|
||||
this._consoleMessages.length = 0;
|
||||
this._recentConsoleMessages.length = 0;
|
||||
this._requests.clear();
|
||||
}
|
||||
|
||||
private _handleConsoleMessage(message: ConsoleMessage) {
|
||||
this._consoleMessages.push(message);
|
||||
this._recentConsoleMessages.push(message);
|
||||
}
|
||||
|
||||
private _onClose() {
|
||||
this._clearCollectedArtifacts();
|
||||
this._onPageClose(this);
|
||||
}
|
||||
|
||||
async title(): Promise<string> {
|
||||
return await callOnPageNoTrace(this.page, page => page.title());
|
||||
}
|
||||
|
||||
async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise<void> {
|
||||
await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(logUnhandledError));
|
||||
}
|
||||
|
||||
async navigate(url: string) {
|
||||
this._clearCollectedArtifacts();
|
||||
|
||||
const downloadEvent = this.page.waitForEvent('download').catch(() => {});
|
||||
const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(logUnhandledError));
|
||||
try {
|
||||
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
} catch (_e: unknown) {
|
||||
@@ -74,18 +92,17 @@ export class Tab {
|
||||
|| e.message.includes('Download is starting'); // firefox + webkit
|
||||
if (!mightBeDownload)
|
||||
throw e;
|
||||
|
||||
// on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit
|
||||
const download = await Promise.race([
|
||||
downloadEvent,
|
||||
new Promise(resolve => setTimeout(resolve, 500)),
|
||||
new Promise(resolve => setTimeout(resolve, 1000)),
|
||||
]);
|
||||
if (!download)
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Cap load event to 5 seconds, the page is operational at this point.
|
||||
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
|
||||
await this.waitForLoadState('load', { timeout: 5000 });
|
||||
}
|
||||
|
||||
hasSnapshot(): boolean {
|
||||
@@ -98,7 +115,7 @@ export class Tab {
|
||||
return this._snapshot;
|
||||
}
|
||||
|
||||
consoleMessages(): playwright.ConsoleMessage[] {
|
||||
consoleMessages(): ConsoleMessage[] {
|
||||
return this._consoleMessages;
|
||||
}
|
||||
|
||||
@@ -109,4 +126,39 @@ export class Tab {
|
||||
async captureSnapshot() {
|
||||
this._snapshot = await PageSnapshot.create(this.page);
|
||||
}
|
||||
|
||||
takeRecentConsoleMessages(): ConsoleMessage[] {
|
||||
const result = this._recentConsoleMessages.slice();
|
||||
this._recentConsoleMessages.length = 0;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export type ConsoleMessage = {
|
||||
type: ReturnType<playwright.ConsoleMessage['type']> | undefined;
|
||||
text: string;
|
||||
toString(): string;
|
||||
};
|
||||
|
||||
function messageToConsoleMessage(message: playwright.ConsoleMessage): ConsoleMessage {
|
||||
return {
|
||||
type: message.type(),
|
||||
text: message.text(),
|
||||
toString: () => `[${message.type().toUpperCase()}] ${message.text()} @ ${message.location().url}:${message.location().lineNumber}`,
|
||||
};
|
||||
}
|
||||
|
||||
function pageErrorToConsoleMessage(errorOrValue: Error | any): ConsoleMessage {
|
||||
if (errorOrValue instanceof Error) {
|
||||
return {
|
||||
type: undefined,
|
||||
text: errorOrValue.message,
|
||||
toString: () => errorOrValue.stack || errorOrValue.message,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: undefined,
|
||||
text: String(errorOrValue),
|
||||
toString: () => String(errorOrValue),
|
||||
};
|
||||
}
|
||||
|
||||
39
src/tools.ts
39
src/tools.ts
@@ -17,6 +17,7 @@
|
||||
import common from './tools/common.js';
|
||||
import console from './tools/console.js';
|
||||
import dialogs from './tools/dialogs.js';
|
||||
import evaluate from './tools/evaluate.js';
|
||||
import files from './tools/files.js';
|
||||
import install from './tools/install.js';
|
||||
import keyboard from './tools/keyboard.js';
|
||||
@@ -26,41 +27,25 @@ import pdf from './tools/pdf.js';
|
||||
import snapshot from './tools/snapshot.js';
|
||||
import tabs from './tools/tabs.js';
|
||||
import screenshot from './tools/screenshot.js';
|
||||
import testing from './tools/testing.js';
|
||||
import vision from './tools/vision.js';
|
||||
import wait from './tools/wait.js';
|
||||
import mouse from './tools/mouse.js';
|
||||
|
||||
import type { Tool } from './tools/tool.js';
|
||||
|
||||
export const snapshotTools: Tool<any>[] = [
|
||||
...common(true),
|
||||
export const allTools: Tool<any>[] = [
|
||||
...common,
|
||||
...console,
|
||||
...dialogs(true),
|
||||
...files(true),
|
||||
...dialogs,
|
||||
...evaluate,
|
||||
...files,
|
||||
...install,
|
||||
...keyboard(true),
|
||||
...navigate(true),
|
||||
...keyboard,
|
||||
...navigate,
|
||||
...network,
|
||||
...mouse,
|
||||
...pdf,
|
||||
...screenshot,
|
||||
...snapshot,
|
||||
...tabs(true),
|
||||
...testing,
|
||||
...wait(true),
|
||||
];
|
||||
|
||||
export const visionTools: Tool<any>[] = [
|
||||
...common(false),
|
||||
...console,
|
||||
...dialogs(false),
|
||||
...files(false),
|
||||
...install,
|
||||
...keyboard(false),
|
||||
...navigate(false),
|
||||
...network,
|
||||
...pdf,
|
||||
...tabs(false),
|
||||
...testing,
|
||||
...vision,
|
||||
...wait(false),
|
||||
...tabs,
|
||||
...wait,
|
||||
];
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { defineTool, type ToolFactory } from './tool.js';
|
||||
import { defineTool } from './tool.js';
|
||||
|
||||
const close = defineTool({
|
||||
capability: 'core',
|
||||
@@ -38,7 +38,7 @@ const close = defineTool({
|
||||
},
|
||||
});
|
||||
|
||||
const resize: ToolFactory = captureSnapshot => defineTool({
|
||||
const resize = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_resize',
|
||||
@@ -66,13 +66,13 @@ const resize: ToolFactory = captureSnapshot => defineTool({
|
||||
return {
|
||||
code,
|
||||
action,
|
||||
captureSnapshot,
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: true
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default (captureSnapshot: boolean) => [
|
||||
export default [
|
||||
close,
|
||||
resize(captureSnapshot)
|
||||
resize
|
||||
];
|
||||
|
||||
@@ -28,7 +28,7 @@ const console = defineTool({
|
||||
},
|
||||
handle: async context => {
|
||||
const messages = context.currentTabOrDie().consoleMessages();
|
||||
const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n');
|
||||
const log = messages.map(message => message.toString()).join('\n');
|
||||
return {
|
||||
code: [`// <internal code to get console messages>`],
|
||||
action: async () => {
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { defineTool, type ToolFactory } from './tool.js';
|
||||
import { defineTool } from './tool.js';
|
||||
|
||||
const handleDialog: ToolFactory = captureSnapshot => defineTool({
|
||||
const handleDialog = defineTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
@@ -49,7 +49,7 @@ const handleDialog: ToolFactory = captureSnapshot => defineTool({
|
||||
|
||||
return {
|
||||
code,
|
||||
captureSnapshot,
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
@@ -57,6 +57,6 @@ const handleDialog: ToolFactory = captureSnapshot => defineTool({
|
||||
clearsModalState: 'dialog',
|
||||
});
|
||||
|
||||
export default (captureSnapshot: boolean) => [
|
||||
handleDialog(captureSnapshot),
|
||||
export default [
|
||||
handleDialog,
|
||||
];
|
||||
|
||||
71
src/tools/evaluate.ts
Normal file
71
src/tools/evaluate.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 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.js';
|
||||
import * as javascript from '../javascript.js';
|
||||
import { generateLocator } from './utils.js';
|
||||
|
||||
import type * as playwright from 'playwright';
|
||||
|
||||
const evaluateSchema = z.object({
|
||||
function: z.string().describe('() => { /* code */ } or (element) => { /* code */ } when element is provided'),
|
||||
element: z.string().optional().describe('Human-readable element description used to obtain permission to interact with the element'),
|
||||
ref: z.string().optional().describe('Exact target element reference from the page snapshot'),
|
||||
});
|
||||
|
||||
const evaluate = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_evaluate',
|
||||
title: 'Evaluate JavaScript',
|
||||
description: 'Evaluate JavaScript expression on page or element',
|
||||
inputSchema: evaluateSchema,
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const tab = context.currentTabOrDie();
|
||||
const code: string[] = [];
|
||||
|
||||
let locator: playwright.Locator | undefined;
|
||||
if (params.ref && params.element) {
|
||||
const snapshot = tab.snapshotOrDie();
|
||||
locator = snapshot.refLocator({ ref: params.ref, element: params.element });
|
||||
code.push(`await page.${await generateLocator(locator)}.evaluate(${javascript.quote(params.function)});`);
|
||||
} else {
|
||||
code.push(`await page.evaluate(${javascript.quote(params.function)});`);
|
||||
}
|
||||
|
||||
return {
|
||||
code,
|
||||
action: async () => {
|
||||
const receiver = locator ?? tab.page as any;
|
||||
const result = await receiver._evaluateFunction(params.function);
|
||||
return {
|
||||
content: [{ type: 'text', text: '- Result: ' + (JSON.stringify(result, null, 2) || 'undefined') }],
|
||||
};
|
||||
},
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
evaluate,
|
||||
];
|
||||
@@ -15,10 +15,10 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { defineTool, type ToolFactory } from './tool.js';
|
||||
import { defineTool } from './tool.js';
|
||||
|
||||
const uploadFile: ToolFactory = captureSnapshot => defineTool({
|
||||
capability: 'files',
|
||||
const uploadFile = defineTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_file_upload',
|
||||
@@ -47,13 +47,13 @@ const uploadFile: ToolFactory = captureSnapshot => defineTool({
|
||||
return {
|
||||
code,
|
||||
action,
|
||||
captureSnapshot,
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: true,
|
||||
};
|
||||
},
|
||||
clearsModalState: 'fileChooser',
|
||||
});
|
||||
|
||||
export default (captureSnapshot: boolean) => [
|
||||
uploadFile(captureSnapshot),
|
||||
export default [
|
||||
uploadFile,
|
||||
];
|
||||
|
||||
@@ -23,7 +23,7 @@ import { defineTool } from './tool.js';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const install = defineTool({
|
||||
capability: 'install',
|
||||
capability: 'core-install',
|
||||
schema: {
|
||||
name: 'browser_install',
|
||||
title: 'Install the browser specified in the config',
|
||||
|
||||
@@ -15,9 +15,13 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { defineTool, type ToolFactory } from './tool.js';
|
||||
|
||||
const pressKey: ToolFactory = captureSnapshot => defineTool({
|
||||
import { defineTool } from './tool.js';
|
||||
import { elementSchema } from './snapshot.js';
|
||||
import { generateLocator } from './utils.js';
|
||||
import * as javascript from '../javascript.js';
|
||||
|
||||
const pressKey = defineTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
@@ -43,12 +47,61 @@ const pressKey: ToolFactory = captureSnapshot => defineTool({
|
||||
return {
|
||||
code,
|
||||
action,
|
||||
captureSnapshot,
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: true
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default (captureSnapshot: boolean) => [
|
||||
pressKey(captureSnapshot),
|
||||
const typeSchema = elementSchema.extend({
|
||||
text: z.string().describe('Text to type into the element'),
|
||||
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
|
||||
slowly: z.boolean().optional().describe('Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.'),
|
||||
});
|
||||
|
||||
const type = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_type',
|
||||
title: 'Type text',
|
||||
description: 'Type text into editable element',
|
||||
inputSchema: typeSchema,
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||
const locator = snapshot.refLocator(params);
|
||||
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
pressKey,
|
||||
type,
|
||||
];
|
||||
|
||||
@@ -17,50 +17,14 @@
|
||||
import { z } from 'zod';
|
||||
import { defineTool } from './tool.js';
|
||||
|
||||
import * as javascript from '../javascript.js';
|
||||
|
||||
const elementSchema = z.object({
|
||||
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
|
||||
});
|
||||
|
||||
const screenshot = defineTool({
|
||||
capability: 'core',
|
||||
const mouseMove = defineTool({
|
||||
capability: 'vision',
|
||||
schema: {
|
||||
name: 'browser_screen_capture',
|
||||
title: 'Take a screenshot',
|
||||
description: 'Take a screenshot of the current page',
|
||||
inputSchema: z.object({}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
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 = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_screen_move_mouse',
|
||||
name: 'browser_mouse_move_xy',
|
||||
title: 'Move mouse',
|
||||
description: 'Move mouse to a given position',
|
||||
inputSchema: elementSchema.extend({
|
||||
@@ -86,12 +50,12 @@ const moveMouse = defineTool({
|
||||
},
|
||||
});
|
||||
|
||||
const click = defineTool({
|
||||
capability: 'core',
|
||||
const mouseClick = defineTool({
|
||||
capability: 'vision',
|
||||
schema: {
|
||||
name: 'browser_screen_click',
|
||||
name: 'browser_mouse_click_xy',
|
||||
title: 'Click',
|
||||
description: 'Click left mouse button',
|
||||
description: 'Click left mouse button at a given position',
|
||||
inputSchema: elementSchema.extend({
|
||||
x: z.number().describe('X coordinate'),
|
||||
y: z.number().describe('Y coordinate'),
|
||||
@@ -121,12 +85,12 @@ const click = defineTool({
|
||||
},
|
||||
});
|
||||
|
||||
const drag = defineTool({
|
||||
capability: 'core',
|
||||
const mouseDrag = defineTool({
|
||||
capability: 'vision',
|
||||
schema: {
|
||||
name: 'browser_screen_drag',
|
||||
name: 'browser_mouse_drag_xy',
|
||||
title: 'Drag mouse',
|
||||
description: 'Drag left mouse button',
|
||||
description: 'Drag left mouse button to a given position',
|
||||
inputSchema: elementSchema.extend({
|
||||
startX: z.number().describe('Start X coordinate'),
|
||||
startY: z.number().describe('Start Y coordinate'),
|
||||
@@ -163,51 +127,8 @@ const drag = defineTool({
|
||||
},
|
||||
});
|
||||
|
||||
const type = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_screen_type',
|
||||
title: 'Type text',
|
||||
description: 'Type text',
|
||||
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)'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
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');
|
||||
};
|
||||
|
||||
if (params.submit) {
|
||||
code.push(`// Submit text`);
|
||||
code.push(`await page.keyboard.press('Enter');`);
|
||||
}
|
||||
|
||||
return {
|
||||
code,
|
||||
action,
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
screenshot,
|
||||
moveMouse,
|
||||
click,
|
||||
drag,
|
||||
type,
|
||||
mouseMove,
|
||||
mouseClick,
|
||||
mouseDrag,
|
||||
];
|
||||
@@ -15,9 +15,9 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { defineTool, type ToolFactory } from './tool.js';
|
||||
import { defineTool } from './tool.js';
|
||||
|
||||
const navigate: ToolFactory = captureSnapshot => defineTool({
|
||||
const navigate = defineTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
@@ -41,14 +41,14 @@ const navigate: ToolFactory = captureSnapshot => defineTool({
|
||||
|
||||
return {
|
||||
code,
|
||||
captureSnapshot,
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const goBack: ToolFactory = captureSnapshot => defineTool({
|
||||
capability: 'history',
|
||||
const goBack = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_navigate_back',
|
||||
title: 'Go back',
|
||||
@@ -67,14 +67,14 @@ const goBack: ToolFactory = captureSnapshot => defineTool({
|
||||
|
||||
return {
|
||||
code,
|
||||
captureSnapshot,
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const goForward: ToolFactory = captureSnapshot => defineTool({
|
||||
capability: 'history',
|
||||
const goForward = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_navigate_forward',
|
||||
title: 'Go forward',
|
||||
@@ -91,14 +91,14 @@ const goForward: ToolFactory = captureSnapshot => defineTool({
|
||||
];
|
||||
return {
|
||||
code,
|
||||
captureSnapshot,
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default (captureSnapshot: boolean) => [
|
||||
navigate(captureSnapshot),
|
||||
goBack(captureSnapshot),
|
||||
goForward(captureSnapshot),
|
||||
export default [
|
||||
navigate,
|
||||
goBack,
|
||||
goForward,
|
||||
];
|
||||
|
||||
@@ -57,14 +57,14 @@ const screenshot = defineTool({
|
||||
`// Screenshot ${isElementScreenshot ? params.element : 'viewport'} and save it as ${fileName}`,
|
||||
];
|
||||
|
||||
const locator = params.ref ? snapshot.refLocator(params.ref) : null;
|
||||
const locator = params.ref ? snapshot.refLocator({ element: params.element || '', ref: 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.noImageResponses;
|
||||
const includeBase64 = context.clientSupportsImages();
|
||||
const action = async () => {
|
||||
const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
|
||||
return {
|
||||
@@ -79,7 +79,7 @@ const screenshot = defineTool({
|
||||
return {
|
||||
code,
|
||||
action,
|
||||
captureSnapshot: true,
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,33 +41,44 @@ const snapshot = defineTool({
|
||||
},
|
||||
});
|
||||
|
||||
const elementSchema = z.object({
|
||||
export 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 clickSchema = elementSchema.extend({
|
||||
doubleClick: z.boolean().optional().describe('Whether to perform a double click instead of a single click'),
|
||||
button: z.enum(['left', 'right', 'middle']).optional().describe('Button to click, defaults to left'),
|
||||
});
|
||||
|
||||
const click = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_click',
|
||||
title: 'Click',
|
||||
description: 'Perform click on a web page',
|
||||
inputSchema: elementSchema,
|
||||
inputSchema: clickSchema,
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const tab = context.currentTabOrDie();
|
||||
const locator = tab.snapshotOrDie().refLocator(params.ref);
|
||||
const locator = tab.snapshotOrDie().refLocator(params);
|
||||
const button = params.button;
|
||||
const buttonAttr = button ? `{ button: '${button}' }` : '';
|
||||
|
||||
const code = [
|
||||
`// Click ${params.element}`,
|
||||
`await page.${await generateLocator(locator)}.click();`
|
||||
];
|
||||
const code: string[] = [];
|
||||
if (params.doubleClick) {
|
||||
code.push(`// Double click ${params.element}`);
|
||||
code.push(`await page.${await generateLocator(locator)}.dblclick(${buttonAttr});`);
|
||||
} else {
|
||||
code.push(`// Click ${params.element}`);
|
||||
code.push(`await page.${await generateLocator(locator)}.click(${buttonAttr});`);
|
||||
}
|
||||
|
||||
return {
|
||||
code,
|
||||
action: () => locator.click(),
|
||||
action: () => params.doubleClick ? locator.dblclick({ button }) : locator.click({ button }),
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: true,
|
||||
};
|
||||
@@ -91,8 +102,8 @@ const drag = defineTool({
|
||||
|
||||
handle: async (context, params) => {
|
||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||
const startLocator = snapshot.refLocator(params.startRef);
|
||||
const endLocator = snapshot.refLocator(params.endRef);
|
||||
const startLocator = snapshot.refLocator({ ref: params.startRef, element: params.startElement });
|
||||
const endLocator = snapshot.refLocator({ ref: params.endRef, element: params.endElement });
|
||||
|
||||
const code = [
|
||||
`// Drag ${params.startElement} to ${params.endElement}`,
|
||||
@@ -120,7 +131,7 @@ const hover = defineTool({
|
||||
|
||||
handle: async (context, params) => {
|
||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||
const locator = snapshot.refLocator(params.ref);
|
||||
const locator = snapshot.refLocator(params);
|
||||
|
||||
const code = [
|
||||
`// Hover over ${params.element}`,
|
||||
@@ -136,54 +147,6 @@ const hover = defineTool({
|
||||
},
|
||||
});
|
||||
|
||||
const typeSchema = elementSchema.extend({
|
||||
text: z.string().describe('Text to type into the element'),
|
||||
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
|
||||
slowly: z.boolean().optional().describe('Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.'),
|
||||
});
|
||||
|
||||
const type = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_type',
|
||||
title: 'Type text',
|
||||
description: 'Type text into editable element',
|
||||
inputSchema: typeSchema,
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
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.'),
|
||||
});
|
||||
@@ -200,7 +163,7 @@ const selectOption = defineTool({
|
||||
|
||||
handle: async (context, params) => {
|
||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||
const locator = snapshot.refLocator(params.ref);
|
||||
const locator = snapshot.refLocator(params);
|
||||
|
||||
const code = [
|
||||
`// Select options [${params.values.join(', ')}] in ${params.element}`,
|
||||
@@ -221,6 +184,5 @@ export default [
|
||||
click,
|
||||
drag,
|
||||
hover,
|
||||
type,
|
||||
selectOption,
|
||||
];
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { defineTool, type ToolFactory } from './tool.js';
|
||||
import { defineTool } from './tool.js';
|
||||
|
||||
const listTabs = defineTool({
|
||||
capability: 'tabs',
|
||||
capability: 'core-tabs',
|
||||
|
||||
schema: {
|
||||
name: 'browser_tab_list',
|
||||
@@ -44,8 +44,8 @@ const listTabs = defineTool({
|
||||
},
|
||||
});
|
||||
|
||||
const selectTab: ToolFactory = captureSnapshot => defineTool({
|
||||
capability: 'tabs',
|
||||
const selectTab = defineTool({
|
||||
capability: 'core-tabs',
|
||||
|
||||
schema: {
|
||||
name: 'browser_tab_select',
|
||||
@@ -65,14 +65,14 @@ const selectTab: ToolFactory = captureSnapshot => defineTool({
|
||||
|
||||
return {
|
||||
code,
|
||||
captureSnapshot,
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: false
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const newTab: ToolFactory = captureSnapshot => defineTool({
|
||||
capability: 'tabs',
|
||||
const newTab = defineTool({
|
||||
capability: 'core-tabs',
|
||||
|
||||
schema: {
|
||||
name: 'browser_tab_new',
|
||||
@@ -94,14 +94,14 @@ const newTab: ToolFactory = captureSnapshot => defineTool({
|
||||
];
|
||||
return {
|
||||
code,
|
||||
captureSnapshot,
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: false
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const closeTab: ToolFactory = captureSnapshot => defineTool({
|
||||
capability: 'tabs',
|
||||
const closeTab = defineTool({
|
||||
capability: 'core-tabs',
|
||||
|
||||
schema: {
|
||||
name: 'browser_tab_close',
|
||||
@@ -120,15 +120,15 @@ const closeTab: ToolFactory = captureSnapshot => defineTool({
|
||||
];
|
||||
return {
|
||||
code,
|
||||
captureSnapshot,
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: false
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default (captureSnapshot: boolean) => [
|
||||
export default [
|
||||
listTabs,
|
||||
newTab(captureSnapshot),
|
||||
selectTab(captureSnapshot),
|
||||
closeTab(captureSnapshot),
|
||||
newTab,
|
||||
selectTab,
|
||||
closeTab,
|
||||
];
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { defineTool } from './tool.js';
|
||||
|
||||
const generateTestSchema = z.object({
|
||||
name: z.string().describe('The name of the test'),
|
||||
description: z.string().describe('The description of the test'),
|
||||
steps: z.array(z.string()).describe('The steps of the test'),
|
||||
});
|
||||
|
||||
const generateTest = defineTool({
|
||||
capability: 'testing',
|
||||
|
||||
schema: {
|
||||
name: 'browser_generate_playwright_test',
|
||||
title: 'Generate a Playwright test',
|
||||
description: 'Generate a Playwright test for given scenario',
|
||||
inputSchema: generateTestSchema,
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
return {
|
||||
resultOverride: {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: instructions(params),
|
||||
}],
|
||||
},
|
||||
code: [],
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const instructions = (params: { name: string, description: string, steps: string[] }) => [
|
||||
`## Instructions`,
|
||||
`- You are a playwright test generator.`,
|
||||
`- You are given a scenario and you need to generate a playwright test for it.`,
|
||||
'- DO NOT generate test code based on the scenario alone. DO run steps one by one using the tools provided instead.',
|
||||
'- Only after all steps are completed, emit a Playwright TypeScript test that uses @playwright/test based on message history',
|
||||
'- Save generated test file in the tests directory',
|
||||
`Test name: ${params.name}`,
|
||||
`Description: ${params.description}`,
|
||||
`Steps:`,
|
||||
...params.steps.map((step, index) => `- ${index + 1}. ${step}`),
|
||||
].join('\n');
|
||||
|
||||
export default [
|
||||
generateTest,
|
||||
];
|
||||
@@ -61,8 +61,6 @@ export type Tool<Input extends InputType = InputType> = {
|
||||
handle: (context: Context, params: z.output<Input>) => Promise<ToolResult>;
|
||||
};
|
||||
|
||||
export type ToolFactory = (snapshot: boolean) => Tool<any>;
|
||||
|
||||
export function defineTool<Input extends InputType>(tool: Tool<Input>): Tool<Input> {
|
||||
return tool;
|
||||
}
|
||||
|
||||
@@ -14,10 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// @ts-ignore
|
||||
import { asLocator } from 'playwright-core/lib/utils';
|
||||
|
||||
import type * as playwright from 'playwright';
|
||||
import type { Context } from '../context.js';
|
||||
import type { Tab } from '../tab.js';
|
||||
|
||||
export async function waitForCompletion<R>(context: Context, page: playwright.Page, callback: () => Promise<R>): Promise<R> {
|
||||
export async function waitForCompletion<R>(context: Context, tab: Tab, callback: () => Promise<R>): Promise<R> {
|
||||
const requests = new Set<playwright.Request>();
|
||||
let frameNavigated = false;
|
||||
let waitCallback: () => void = () => {};
|
||||
@@ -36,9 +40,7 @@ export async function waitForCompletion<R>(context: Context, page: playwright.Pa
|
||||
frameNavigated = true;
|
||||
dispose();
|
||||
clearTimeout(timeout);
|
||||
void frame.waitForLoadState('load').then(() => {
|
||||
waitCallback();
|
||||
});
|
||||
void tab.waitForLoadState('load').then(waitCallback);
|
||||
};
|
||||
|
||||
const onTimeout = () => {
|
||||
@@ -46,15 +48,15 @@ export async function waitForCompletion<R>(context: Context, page: playwright.Pa
|
||||
waitCallback();
|
||||
};
|
||||
|
||||
page.on('request', requestListener);
|
||||
page.on('requestfinished', requestFinishedListener);
|
||||
page.on('framenavigated', frameNavigateListener);
|
||||
tab.page.on('request', requestListener);
|
||||
tab.page.on('requestfinished', requestFinishedListener);
|
||||
tab.page.on('framenavigated', frameNavigateListener);
|
||||
const timeout = setTimeout(onTimeout, 10000);
|
||||
|
||||
const dispose = () => {
|
||||
page.off('request', requestListener);
|
||||
page.off('requestfinished', requestFinishedListener);
|
||||
page.off('framenavigated', frameNavigateListener);
|
||||
tab.page.off('request', requestListener);
|
||||
tab.page.off('requestfinished', requestFinishedListener);
|
||||
tab.page.off('framenavigated', frameNavigateListener);
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
|
||||
@@ -79,5 +81,14 @@ export function sanitizeForFilePath(s: string) {
|
||||
}
|
||||
|
||||
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
||||
return (locator as any)._generateLocatorString();
|
||||
try {
|
||||
const { resolvedSelector } = await (locator as any)._resolveSelector();
|
||||
return asLocator('javascript', resolvedSelector);
|
||||
} catch (e) {
|
||||
throw new Error('Ref not found, likely because element was removed. Use browser_snapshot to see what elements are currently on the page.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function callOnPageNoTrace<T>(page: playwright.Page, callback: (page: playwright.Page) => Promise<T>): Promise<T> {
|
||||
return await (page as any)._wrapApiCall(() => callback(page), { internal: true });
|
||||
}
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { defineTool, type ToolFactory } from './tool.js';
|
||||
import { defineTool } from './tool.js';
|
||||
|
||||
const wait: ToolFactory = captureSnapshot => defineTool({
|
||||
capability: 'wait',
|
||||
const wait = defineTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_wait_for',
|
||||
@@ -40,7 +40,7 @@ const wait: ToolFactory = captureSnapshot => defineTool({
|
||||
|
||||
if (params.time) {
|
||||
code.push(`await new Promise(f => setTimeout(f, ${params.time!} * 1000));`);
|
||||
await new Promise(f => setTimeout(f, Math.min(10000, params.time! * 1000)));
|
||||
await new Promise(f => setTimeout(f, Math.min(30000, params.time! * 1000)));
|
||||
}
|
||||
|
||||
const tab = context.currentTabOrDie();
|
||||
@@ -59,12 +59,12 @@ const wait: ToolFactory = captureSnapshot => defineTool({
|
||||
|
||||
return {
|
||||
code,
|
||||
captureSnapshot,
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default (captureSnapshot: boolean) => [
|
||||
wait(captureSnapshot),
|
||||
export default [
|
||||
wait,
|
||||
];
|
||||
|
||||
119
src/transport.ts
119
src/transport.ts
@@ -18,22 +18,21 @@ import http from 'node:http';
|
||||
import assert from 'node:assert';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import debug from 'debug';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
|
||||
import { createConnection } from './connection.js';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import type { Server } from './server.js';
|
||||
|
||||
import type { Config } from '../config.js';
|
||||
import type { Connection } from './connection.js';
|
||||
|
||||
export async function startStdioTransport(config: Config, connectionList: Connection[]) {
|
||||
const connection = await createConnection(config);
|
||||
await connection.connect(new StdioServerTransport());
|
||||
connectionList.push(connection);
|
||||
export async function startStdioTransport(server: Server) {
|
||||
await server.createConnection(new StdioServerTransport());
|
||||
}
|
||||
|
||||
async function handleSSE(config: Config, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>, connectionList: Connection[]) {
|
||||
const testDebug = debug('pw:mcp:test');
|
||||
|
||||
async function handleSSE(server: Server, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>) {
|
||||
if (req.method === 'POST') {
|
||||
const sessionId = url.searchParams.get('sessionId');
|
||||
if (!sessionId) {
|
||||
@@ -51,15 +50,13 @@ async function handleSSE(config: Config, req: http.IncomingMessage, res: http.Se
|
||||
} else if (req.method === 'GET') {
|
||||
const transport = new SSEServerTransport('/sse', res);
|
||||
sessions.set(transport.sessionId, transport);
|
||||
const connection = await createConnection(config);
|
||||
await connection.connect(transport);
|
||||
connectionList.push(connection);
|
||||
testDebug(`create SSE session: ${transport.sessionId}`);
|
||||
const connection = await server.createConnection(transport);
|
||||
res.on('close', () => {
|
||||
testDebug(`delete SSE session: ${transport.sessionId}`);
|
||||
sessions.delete(transport.sessionId);
|
||||
connection.close().catch(e => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
});
|
||||
// eslint-disable-next-line no-console
|
||||
void connection.close().catch(e => console.error(e));
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -68,7 +65,7 @@ async function handleSSE(config: Config, req: http.IncomingMessage, res: http.Se
|
||||
res.end('Method not allowed');
|
||||
}
|
||||
|
||||
async function handleStreamable(config: Config, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, StreamableHTTPServerTransport>, connectionList: Connection[]) {
|
||||
async function handleStreamable(server: Server, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, StreamableHTTPServerTransport>) {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (sessionId) {
|
||||
const transport = sessions.get(sessionId);
|
||||
@@ -91,12 +88,15 @@ async function handleStreamable(config: Config, req: http.IncomingMessage, res:
|
||||
if (transport.sessionId)
|
||||
sessions.delete(transport.sessionId);
|
||||
};
|
||||
const connection = await createConnection(config);
|
||||
connectionList.push(connection);
|
||||
await Promise.all([
|
||||
connection.connect(transport),
|
||||
transport.handleRequest(req, res),
|
||||
]);
|
||||
const connection = await server.createConnection(transport);
|
||||
// Ensure connection is closed when transport closes
|
||||
transport.onclose = () => {
|
||||
if (transport.sessionId)
|
||||
sessions.delete(transport.sessionId);
|
||||
// eslint-disable-next-line no-console
|
||||
void connection.close().catch(e => console.error(e));
|
||||
};
|
||||
await transport.handleRequest(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -104,42 +104,53 @@ async function handleStreamable(config: Config, req: http.IncomingMessage, res:
|
||||
res.end('Invalid request');
|
||||
}
|
||||
|
||||
export function startHttpTransport(config: Config, port: number, hostname: string | undefined, connectionList: Connection[]) {
|
||||
export async function startHttpServer(config: { host?: string, port?: number }): Promise<http.Server> {
|
||||
const { host, port } = config;
|
||||
const httpServer = http.createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
httpServer.on('error', reject);
|
||||
httpServer.listen(port, host, () => {
|
||||
resolve();
|
||||
httpServer.removeListener('error', reject);
|
||||
});
|
||||
});
|
||||
return httpServer;
|
||||
}
|
||||
|
||||
export function startHttpTransport(httpServer: http.Server, mcpServer: Server) {
|
||||
const sseSessions = new Map<string, SSEServerTransport>();
|
||||
const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
|
||||
const httpServer = http.createServer(async (req, res) => {
|
||||
httpServer.on('request', async (req, res) => {
|
||||
const url = new URL(`http://localhost${req.url}`);
|
||||
if (url.pathname.startsWith('/mcp'))
|
||||
await handleStreamable(config, req, res, streamableSessions, connectionList);
|
||||
if (url.pathname.startsWith('/sse'))
|
||||
await handleSSE(mcpServer, req, res, url, sseSessions);
|
||||
else
|
||||
await handleSSE(config, req, res, url, sseSessions, connectionList);
|
||||
await handleStreamable(mcpServer, req, res, streamableSessions);
|
||||
});
|
||||
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}`;
|
||||
}
|
||||
const message = [
|
||||
`Listening on ${url}`,
|
||||
'Put this in your client config:',
|
||||
JSON.stringify({
|
||||
'mcpServers': {
|
||||
'playwright': {
|
||||
'url': `${url}/sse`
|
||||
}
|
||||
const url = httpAddressToString(httpServer.address());
|
||||
const message = [
|
||||
`Listening on ${url}`,
|
||||
'Put this in your client config:',
|
||||
JSON.stringify({
|
||||
'mcpServers': {
|
||||
'playwright': {
|
||||
'url': `${url}/mcp`
|
||||
}
|
||||
}, undefined, 2),
|
||||
'If your client supports streamable HTTP, you can use the /mcp endpoint instead.',
|
||||
].join('\n');
|
||||
}
|
||||
}, undefined, 2),
|
||||
'For legacy SSE transport support, you can use the /sse endpoint instead.',
|
||||
].join('\n');
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(message);
|
||||
});
|
||||
console.error(message);
|
||||
}
|
||||
|
||||
export function httpAddressToString(address: string | AddressInfo | null): string {
|
||||
assert(address, 'Could not bind server socket');
|
||||
if (typeof address === 'string')
|
||||
return address;
|
||||
const resolvedPort = address.port;
|
||||
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
|
||||
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
|
||||
resolvedHost = 'localhost';
|
||||
return `http://${resolvedHost}:${resolvedPort}`;
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ test('test snapshot tool list', async ({ client }) => {
|
||||
'browser_click',
|
||||
'browser_console_messages',
|
||||
'browser_drag',
|
||||
'browser_evaluate',
|
||||
'browser_file_upload',
|
||||
'browser_generate_playwright_test',
|
||||
'browser_handle_dialog',
|
||||
'browser_hover',
|
||||
'browser_select_option',
|
||||
@@ -34,7 +34,6 @@ test('test snapshot tool list', async ({ client }) => {
|
||||
'browser_navigate_forward',
|
||||
'browser_navigate',
|
||||
'browser_network_requests',
|
||||
'browser_pdf_save',
|
||||
'browser_press_key',
|
||||
'browser_resize',
|
||||
'browser_snapshot',
|
||||
@@ -47,46 +46,33 @@ test('test snapshot tool list', async ({ client }) => {
|
||||
]));
|
||||
});
|
||||
|
||||
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_generate_playwright_test',
|
||||
'browser_handle_dialog',
|
||||
'browser_install',
|
||||
'browser_navigate_back',
|
||||
'browser_navigate_forward',
|
||||
'browser_navigate',
|
||||
'browser_network_requests',
|
||||
'browser_pdf_save',
|
||||
'browser_press_key',
|
||||
'browser_resize',
|
||||
'browser_screen_capture',
|
||||
'browser_screen_click',
|
||||
'browser_screen_drag',
|
||||
'browser_screen_move_mouse',
|
||||
'browser_screen_type',
|
||||
'browser_tab_close',
|
||||
'browser_tab_list',
|
||||
'browser_tab_new',
|
||||
'browser_tab_select',
|
||||
'browser_wait_for',
|
||||
]));
|
||||
});
|
||||
|
||||
test('test capabilities', async ({ startClient }) => {
|
||||
const client = await startClient({
|
||||
args: ['--caps="core"'],
|
||||
test('test capabilities (pdf)', async ({ startClient }) => {
|
||||
const { client } = await startClient({
|
||||
args: ['--caps=pdf'],
|
||||
});
|
||||
const { tools } = await client.listTools();
|
||||
const toolNames = tools.map(t => t.name);
|
||||
expect(toolNames).not.toContain('browser_file_upload');
|
||||
expect(toolNames).not.toContain('browser_pdf_save');
|
||||
expect(toolNames).not.toContain('browser_screen_capture');
|
||||
expect(toolNames).not.toContain('browser_screen_click');
|
||||
expect(toolNames).not.toContain('browser_screen_drag');
|
||||
expect(toolNames).not.toContain('browser_screen_move_mouse');
|
||||
expect(toolNames).not.toContain('browser_screen_type');
|
||||
expect(toolNames).toContain('browser_pdf_save');
|
||||
});
|
||||
|
||||
test('test capabilities (vision)', async ({ startClient }) => {
|
||||
const { client } = await startClient({
|
||||
args: ['--caps=vision'],
|
||||
});
|
||||
const { tools } = await client.listTools();
|
||||
const toolNames = tools.map(t => t.name);
|
||||
expect(toolNames).toContain('browser_mouse_move_xy');
|
||||
expect(toolNames).toContain('browser_mouse_click_xy');
|
||||
expect(toolNames).toContain('browser_mouse_drag_xy');
|
||||
});
|
||||
|
||||
test('support for legacy --vision option', async ({ startClient }) => {
|
||||
const { client } = await startClient({
|
||||
args: ['--vision'],
|
||||
});
|
||||
const { tools } = await client.listTools();
|
||||
const toolNames = tools.map(t => t.name);
|
||||
expect(toolNames).toContain('browser_mouse_move_xy');
|
||||
expect(toolNames).toContain('browser_mouse_click_xy');
|
||||
expect(toolNames).toContain('browser_mouse_drag_xy');
|
||||
});
|
||||
|
||||
@@ -14,18 +14,26 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import url from 'node:url';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test('cdp server', async ({ cdpEndpoint, startClient, server }) => {
|
||||
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
|
||||
test('cdp server', async ({ cdpServer, startClient, server }) => {
|
||||
await cdpServer.start();
|
||||
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
||||
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
|
||||
});
|
||||
|
||||
test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
|
||||
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
|
||||
test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
|
||||
const browserContext = await cdpServer.start();
|
||||
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
||||
|
||||
const [page] = browserContext.pages();
|
||||
await page.goto(server.HELLO_WORLD);
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
@@ -33,28 +41,28 @@ test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
|
||||
element: 'Hello, world!',
|
||||
ref: 'f0',
|
||||
},
|
||||
})).toHaveTextContent(`Error: No current snapshot available. Capture a snapshot of navigate to a new location first.`);
|
||||
})).toHaveTextContent(`Error: No current snapshot available. Capture a snapshot or navigate to a new location first.`);
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_snapshot',
|
||||
})).toHaveTextContent(`
|
||||
- Ran Playwright code:
|
||||
### Ran Playwright code
|
||||
\`\`\`js
|
||||
// <internal code to capture accessibility snapshot>
|
||||
\`\`\`
|
||||
|
||||
- Page URL: data:text/html,hello world
|
||||
- Page Title:
|
||||
- Page Snapshot
|
||||
### Page state
|
||||
- Page URL: ${server.HELLO_WORLD}
|
||||
- Page Title: Title
|
||||
- Page Snapshot:
|
||||
\`\`\`yaml
|
||||
- generic [ref=e1]: hello world
|
||||
- generic [active] [ref=e1]: Hello, world!
|
||||
\`\`\`
|
||||
`);
|
||||
});
|
||||
|
||||
test('should throw connection error and allow re-connecting', async ({ cdpEndpoint, startClient, server }) => {
|
||||
const port = 3200 + test.info().parallelIndex;
|
||||
const client = await startClient({ args: [`--cdp-endpoint=http://localhost:${port}`] });
|
||||
test('should throw connection error and allow re-connecting', async ({ cdpServer, startClient, server }) => {
|
||||
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
||||
|
||||
server.setContent('/', `
|
||||
<title>Title</title>
|
||||
@@ -65,9 +73,21 @@ test('should throw connection error and allow re-connecting', async ({ cdpEndpoi
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
})).toContainTextContent(`Error: browserType.connectOverCDP: connect ECONNREFUSED`);
|
||||
await cdpEndpoint(port);
|
||||
await cdpServer.start();
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
||||
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
|
||||
});
|
||||
|
||||
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||
const __filename = url.fileURLToPath(import.meta.url);
|
||||
|
||||
test('does not support --device', async () => {
|
||||
const result = spawnSync('node', [
|
||||
path.join(__filename, '../../cli.js'), '--device=Pixel 5', '--cdp-endpoint=http://localhost:1234',
|
||||
]);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr.toString()).toContain('Device emulation is not supported with cdpEndpoint.');
|
||||
});
|
||||
|
||||
119
tests/click.spec.ts
Normal file
119
tests/click.spec.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 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.js';
|
||||
|
||||
test('browser_click', async ({ client, server, mcpBrowser }) => {
|
||||
server.setContent('/', `
|
||||
<title>Title</title>
|
||||
<button>Submit</button>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Submit button',
|
||||
ref: 'e2',
|
||||
},
|
||||
})).toHaveTextContent(`
|
||||
### Ran Playwright code
|
||||
\`\`\`js
|
||||
// Click Submit button
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
\`\`\`
|
||||
|
||||
### Page state
|
||||
- Page URL: ${server.PREFIX}
|
||||
- Page Title: Title
|
||||
- Page Snapshot:
|
||||
\`\`\`yaml
|
||||
- button "Submit" ${mcpBrowser !== 'webkit' || process.platform === 'linux' ? '[active] ' : ''}[ref=e2]
|
||||
\`\`\`
|
||||
`);
|
||||
});
|
||||
|
||||
test('browser_click (double)', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<title>Title</title>
|
||||
<script>
|
||||
function handle() {
|
||||
document.querySelector('h1').textContent = 'Double clicked';
|
||||
}
|
||||
</script>
|
||||
<h1 ondblclick="handle()">Click me</h1>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Click me',
|
||||
ref: 'e2',
|
||||
doubleClick: true,
|
||||
},
|
||||
})).toHaveTextContent(`
|
||||
### Ran Playwright code
|
||||
\`\`\`js
|
||||
// Double click Click me
|
||||
await page.getByRole('heading', { name: 'Click me' }).dblclick();
|
||||
\`\`\`
|
||||
|
||||
### Page state
|
||||
- Page URL: ${server.PREFIX}
|
||||
- Page Title: Title
|
||||
- Page Snapshot:
|
||||
\`\`\`yaml
|
||||
- heading "Double clicked" [level=1] [ref=e3]
|
||||
\`\`\`
|
||||
`);
|
||||
});
|
||||
|
||||
test('browser_click (right)', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<button oncontextmenu="handle">Menu</button>
|
||||
<script>
|
||||
document.addEventListener('contextmenu', event => {
|
||||
event.preventDefault();
|
||||
document.querySelector('button').textContent = 'Right clicked';
|
||||
});
|
||||
</script>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
const result = await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Menu',
|
||||
ref: 'e2',
|
||||
button: 'right',
|
||||
},
|
||||
});
|
||||
expect(result).toContainTextContent(`await page.getByRole('button', { name: 'Menu' }).click({ button: 'right' });`);
|
||||
expect(result).toContainTextContent(`- button "Right clicked"`);
|
||||
});
|
||||
@@ -19,7 +19,7 @@ import fs from 'node:fs';
|
||||
import { Config } from '../config.js';
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test('config user data dir', async ({ startClient, localOutputPath, server }) => {
|
||||
test('config user data dir', async ({ startClient, server, mcpMode }, testInfo) => {
|
||||
server.setContent('/', `
|
||||
<title>Title</title>
|
||||
<body>Hello, world!</body>
|
||||
@@ -27,13 +27,13 @@ test('config user data dir', async ({ startClient, localOutputPath, server }) =>
|
||||
|
||||
const config: Config = {
|
||||
browser: {
|
||||
userDataDir: localOutputPath('user-data-dir'),
|
||||
userDataDir: testInfo.outputPath('user-data-dir'),
|
||||
},
|
||||
};
|
||||
const configPath = localOutputPath('config.json');
|
||||
const configPath = testInfo.outputPath('config.json');
|
||||
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
const client = await startClient({ args: ['--config', configPath] });
|
||||
const { client } = await startClient({ args: ['--config', configPath] });
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
@@ -42,3 +42,22 @@ test('config user data dir', async ({ startClient, localOutputPath, server }) =>
|
||||
const files = await fs.promises.readdir(config.browser!.userDataDir!);
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test.describe(() => {
|
||||
test.use({ mcpBrowser: '' });
|
||||
test('browserName', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/458' } }, async ({ startClient, mcpMode }, testInfo) => {
|
||||
const config: Config = {
|
||||
browser: {
|
||||
browserName: 'firefox',
|
||||
},
|
||||
};
|
||||
const configPath = testInfo.outputPath('config.json');
|
||||
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
const { client } = await startClient({ args: ['--config', configPath] });
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: 'data:text/html,<script>document.title = navigator.userAgent</script>' },
|
||||
})).toContainTextContent(`Firefox`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,7 +38,59 @@ test('browser_console_messages', async ({ client, server }) => {
|
||||
name: 'browser_console_messages',
|
||||
});
|
||||
expect(resource).toHaveTextContent([
|
||||
'[LOG] Hello, world!',
|
||||
'[ERROR] Error',
|
||||
`[LOG] Hello, world! @ ${server.PREFIX}:4`,
|
||||
`[ERROR] Error @ ${server.PREFIX}:5`,
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
test('browser_console_messages (page error)', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<script>
|
||||
throw new Error("Error in script");
|
||||
</script>
|
||||
</html>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: server.PREFIX,
|
||||
},
|
||||
});
|
||||
|
||||
const resource = await client.callTool({
|
||||
name: 'browser_console_messages',
|
||||
});
|
||||
expect(resource).toHaveTextContent(/Error: Error in script/);
|
||||
expect(resource).toHaveTextContent(new RegExp(server.PREFIX));
|
||||
});
|
||||
|
||||
test('recent console messages', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<button onclick="console.log('Hello, world!');">Click me</button>
|
||||
</html>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: server.PREFIX,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Click me',
|
||||
ref: 'e2',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response).toContainTextContent(`
|
||||
### New console messages
|
||||
- [LOG] Hello, world! @`);
|
||||
});
|
||||
|
||||
@@ -21,55 +21,23 @@ test('browser_navigate', async ({ client, server }) => {
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toHaveTextContent(`
|
||||
- Ran Playwright code:
|
||||
### Ran Playwright code
|
||||
\`\`\`js
|
||||
// Navigate to ${server.HELLO_WORLD}
|
||||
await page.goto('${server.HELLO_WORLD}');
|
||||
\`\`\`
|
||||
|
||||
### Page state
|
||||
- Page URL: ${server.HELLO_WORLD}
|
||||
- Page Title: Title
|
||||
- Page Snapshot
|
||||
- Page Snapshot:
|
||||
\`\`\`yaml
|
||||
- generic [ref=e1]: Hello, world!
|
||||
- generic [active] [ref=e1]: Hello, world!
|
||||
\`\`\`
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
test('browser_click', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<title>Title</title>
|
||||
<button>Submit</button>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Submit button',
|
||||
ref: 'e2',
|
||||
},
|
||||
})).toHaveTextContent(`
|
||||
- Ran Playwright code:
|
||||
\`\`\`js
|
||||
// Click Submit button
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
\`\`\`
|
||||
|
||||
- Page URL: ${server.PREFIX}
|
||||
- Page Title: Title
|
||||
- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- button "Submit" [ref=e2]
|
||||
\`\`\`
|
||||
`);
|
||||
});
|
||||
|
||||
test('browser_select_option', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<title>Title</title>
|
||||
@@ -92,15 +60,16 @@ test('browser_select_option', async ({ client, server }) => {
|
||||
values: ['bar'],
|
||||
},
|
||||
})).toHaveTextContent(`
|
||||
- Ran Playwright code:
|
||||
### Ran Playwright code
|
||||
\`\`\`js
|
||||
// Select options [bar] in Select
|
||||
await page.getByRole('combobox').selectOption(['bar']);
|
||||
\`\`\`
|
||||
|
||||
### Page state
|
||||
- Page URL: ${server.PREFIX}
|
||||
- Page Title: Title
|
||||
- Page Snapshot
|
||||
- Page Snapshot:
|
||||
\`\`\`yaml
|
||||
- combobox [ref=e2]:
|
||||
- option "Foo"
|
||||
@@ -132,15 +101,16 @@ test('browser_select_option (multiple)', async ({ client, server }) => {
|
||||
values: ['bar', 'baz'],
|
||||
},
|
||||
})).toHaveTextContent(`
|
||||
- Ran Playwright code:
|
||||
### Ran Playwright code
|
||||
\`\`\`js
|
||||
// Select options [bar, baz] in Select
|
||||
await page.getByRole('listbox').selectOption(['bar', 'baz']);
|
||||
\`\`\`
|
||||
|
||||
### Page state
|
||||
- Page URL: ${server.PREFIX}
|
||||
- Page Title: Title
|
||||
- Page Snapshot
|
||||
- Page Snapshot:
|
||||
\`\`\`yaml
|
||||
- listbox [ref=e2]:
|
||||
- option "Foo" [ref=e3]
|
||||
@@ -175,7 +145,7 @@ test('browser_type', async ({ client, server }) => {
|
||||
});
|
||||
expect(await client.callTool({
|
||||
name: 'browser_console_messages',
|
||||
})).toHaveTextContent('[LOG] Key pressed: Enter , Text: Hi!');
|
||||
})).toHaveTextContent(/\[LOG\] Key pressed: Enter , Text: Hi!/);
|
||||
});
|
||||
|
||||
test('browser_type (slowly)', async ({ client, server }) => {
|
||||
@@ -199,14 +169,13 @@ test('browser_type (slowly)', async ({ client, server }) => {
|
||||
slowly: true,
|
||||
},
|
||||
});
|
||||
expect(await client.callTool({
|
||||
const response = await client.callTool({
|
||||
name: 'browser_console_messages',
|
||||
})).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'));
|
||||
});
|
||||
expect(response).toHaveTextContent(/\[LOG\] Key pressed: H Text: /);
|
||||
expect(response).toHaveTextContent(/\[LOG\] Key pressed: i Text: H/);
|
||||
expect(response).toHaveTextContent(/\[LOG\] Key pressed: ! Text: Hi/);
|
||||
expect(response).toHaveTextContent(/\[LOG\] Key pressed: Enter Text: Hi!/);
|
||||
});
|
||||
|
||||
test('browser_resize', async ({ client, server }) => {
|
||||
@@ -230,10 +199,67 @@ test('browser_resize', async ({ client, server }) => {
|
||||
height: 780,
|
||||
},
|
||||
});
|
||||
expect(response).toContainTextContent(`- Ran Playwright code:
|
||||
expect(response).toContainTextContent(`### Ran Playwright code
|
||||
\`\`\`js
|
||||
// Resize browser window to 390x780
|
||||
await page.setViewportSize({ width: 390, height: 780 });
|
||||
\`\`\``);
|
||||
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent('Window size: 390x780');
|
||||
});
|
||||
|
||||
test('old locator error message', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<button>Button 1</button>
|
||||
<button>Button 2</button>
|
||||
<script>
|
||||
document.querySelector('button').addEventListener('click', () => {
|
||||
document.querySelectorAll('button')[1].remove();
|
||||
});
|
||||
</script>
|
||||
`, 'text/html');
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: server.PREFIX,
|
||||
},
|
||||
})).toContainTextContent(`
|
||||
- button "Button 1" [ref=e2]
|
||||
- button "Button 2" [ref=e3]
|
||||
`.trim());
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Button 1',
|
||||
ref: 'e2',
|
||||
},
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Button 2',
|
||||
ref: 'e3',
|
||||
},
|
||||
})).toContainTextContent('Ref not found');
|
||||
});
|
||||
|
||||
test('visibility: hidden > visible should be shown', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/535' } }, async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<div style="visibility: hidden;">
|
||||
<div style="visibility: visible;">
|
||||
<button>Button</button>
|
||||
</div>
|
||||
</div>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_snapshot'
|
||||
})).toContainTextContent('- button "Button"');
|
||||
});
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test('--device should work', async ({ startClient, server }) => {
|
||||
const client = await startClient({
|
||||
test('--device should work', async ({ startClient, server, mcpMode }) => {
|
||||
const { client } = await startClient({
|
||||
args: ['--device', 'iPhone 15'],
|
||||
});
|
||||
|
||||
|
||||
@@ -16,9 +16,6 @@
|
||||
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
// https://github.com/microsoft/playwright/issues/35663
|
||||
test.skip(({ mcpBrowser, mcpHeadless }) => mcpBrowser === 'webkit' && mcpHeadless);
|
||||
|
||||
test('alert dialog', async ({ client, server }) => {
|
||||
server.setContent('/', `<button onclick="alert('Alert')">Button</button>`, 'text/html');
|
||||
expect(await client.callTool({
|
||||
@@ -32,7 +29,7 @@ test('alert dialog', async ({ client, server }) => {
|
||||
element: 'Button',
|
||||
ref: 'e2',
|
||||
},
|
||||
})).toHaveTextContent(`- Ran Playwright code:
|
||||
})).toHaveTextContent(`### Ran Playwright code
|
||||
\`\`\`js
|
||||
// Click Button
|
||||
await page.getByRole('button', { name: 'Button' }).click();
|
||||
@@ -49,23 +46,20 @@ await page.getByRole('button', { name: 'Button' }).click();
|
||||
});
|
||||
|
||||
expect(result).not.toContainTextContent('### Modal state');
|
||||
expect(result).toHaveTextContent(`- Ran Playwright code:
|
||||
expect(result).toContainTextContent(`### Ran Playwright code
|
||||
\`\`\`js
|
||||
// <internal code to handle "alert" dialog>
|
||||
\`\`\`
|
||||
|
||||
### Page state
|
||||
- Page URL: ${server.PREFIX}
|
||||
- Page Title:
|
||||
- Page Snapshot
|
||||
- Page Snapshot:
|
||||
\`\`\`yaml
|
||||
- button "Button" [ref=e2]
|
||||
\`\`\`
|
||||
`);
|
||||
- button "Button"`);
|
||||
});
|
||||
|
||||
test('two alert dialogs', async ({ client, server }) => {
|
||||
test.fixme(true, 'Race between the dialog and ariaSnapshot');
|
||||
|
||||
server.setContent('/', `
|
||||
<title>Title</title>
|
||||
<body>
|
||||
@@ -84,7 +78,7 @@ test('two alert dialogs', async ({ client, server }) => {
|
||||
element: 'Button',
|
||||
ref: 'e2',
|
||||
},
|
||||
})).toHaveTextContent(`- Ran Playwright code:
|
||||
})).toHaveTextContent(`### Ran Playwright code
|
||||
\`\`\`js
|
||||
// Click Button
|
||||
await page.getByRole('button', { name: 'Button' }).click();
|
||||
@@ -100,7 +94,18 @@ await page.getByRole('button', { name: 'Button' }).click();
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).not.toContainTextContent('### Modal state');
|
||||
expect(result).toContainTextContent(`
|
||||
### Modal state
|
||||
- ["alert" dialog with message "Alert 2"]: can be handled by the "browser_handle_dialog" tool`);
|
||||
|
||||
const result2 = await client.callTool({
|
||||
name: 'browser_handle_dialog',
|
||||
arguments: {
|
||||
accept: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result2).not.toContainTextContent('### Modal state');
|
||||
});
|
||||
|
||||
test('confirm dialog (true)', async ({ client, server }) => {
|
||||
@@ -134,9 +139,9 @@ test('confirm dialog (true)', async ({ client, server }) => {
|
||||
|
||||
expect(result).not.toContainTextContent('### Modal state');
|
||||
expect(result).toContainTextContent('// <internal code to handle "confirm" dialog>');
|
||||
expect(result).toContainTextContent(`- Page Snapshot
|
||||
expect(result).toContainTextContent(`- Page Snapshot:
|
||||
\`\`\`yaml
|
||||
- generic [ref=e1]: "true"
|
||||
- generic [active] [ref=e1]: "true"
|
||||
\`\`\``);
|
||||
});
|
||||
|
||||
@@ -169,9 +174,9 @@ test('confirm dialog (false)', async ({ client, server }) => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toContainTextContent(`- Page Snapshot
|
||||
expect(result).toContainTextContent(`- Page Snapshot:
|
||||
\`\`\`yaml
|
||||
- generic [ref=e1]: "false"
|
||||
- generic [active] [ref=e1]: "false"
|
||||
\`\`\``);
|
||||
});
|
||||
|
||||
@@ -205,8 +210,51 @@ test('prompt dialog', async ({ client, server }) => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toContainTextContent(`- Page Snapshot
|
||||
expect(result).toContainTextContent(`- Page Snapshot:
|
||||
\`\`\`yaml
|
||||
- generic [ref=e1]: Answer
|
||||
- generic [active] [ref=e1]: Answer
|
||||
\`\`\``);
|
||||
});
|
||||
|
||||
test('alert dialog w/ race', async ({ client, server }) => {
|
||||
server.setContent('/', `<button onclick="setTimeout(() => alert('Alert'), 100)">Button</button>`, 'text/html');
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
})).toContainTextContent('- button "Button" [ref=e2]');
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Button',
|
||||
ref: 'e2',
|
||||
},
|
||||
})).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).toContainTextContent(`### Ran Playwright code
|
||||
\`\`\`js
|
||||
// <internal code to handle "alert" dialog>
|
||||
\`\`\`
|
||||
|
||||
### Page state
|
||||
- Page URL: ${server.PREFIX}
|
||||
- Page Title:
|
||||
- Page Snapshot:
|
||||
\`\`\`yaml
|
||||
- button "Button"`);
|
||||
});
|
||||
|
||||
51
tests/evaluate.spec.ts
Normal file
51
tests/evaluate.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test('browser_evaluate', async ({ client, server }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toContainTextContent(`- Page Title: Title`);
|
||||
|
||||
const result = await client.callTool({
|
||||
name: 'browser_evaluate',
|
||||
arguments: {
|
||||
function: '() => document.title',
|
||||
},
|
||||
});
|
||||
expect(result).toContainTextContent(`"Title"`);
|
||||
});
|
||||
|
||||
test('browser_evaluate (element)', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<body style="background-color: red">Hello, world!</body>
|
||||
`, 'text/html');
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_evaluate',
|
||||
arguments: {
|
||||
function: 'element => element.style.backgroundColor',
|
||||
element: 'body',
|
||||
ref: 'e1',
|
||||
},
|
||||
})).toContainTextContent(`- Result: "red"`);
|
||||
});
|
||||
@@ -16,9 +16,8 @@
|
||||
|
||||
import { test, expect } from './fixtures.js';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
test('browser_file_upload', async ({ client, localOutputPath, server }) => {
|
||||
test('browser_file_upload', async ({ client, server }, testInfo) => {
|
||||
server.setContent('/', `
|
||||
<input type="file" />
|
||||
<button>Button</button>
|
||||
@@ -29,7 +28,7 @@ test('browser_file_upload', async ({ client, localOutputPath, server }) => {
|
||||
arguments: { url: server.PREFIX },
|
||||
})).toContainTextContent(`
|
||||
\`\`\`yaml
|
||||
- generic [ref=e1]:
|
||||
- generic [active] [ref=e1]:
|
||||
- button "Choose File" [ref=e2]
|
||||
- button "Button" [ref=e3]
|
||||
\`\`\``);
|
||||
@@ -54,7 +53,7 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
||||
})).toContainTextContent(`### Modal state
|
||||
- [File chooser]: can be handled by the "browser_file_upload" tool`);
|
||||
|
||||
const filePath = localOutputPath('test.txt');
|
||||
const filePath = testInfo.outputPath('test.txt');
|
||||
await fs.writeFile(filePath, 'Hello, world!');
|
||||
|
||||
{
|
||||
@@ -66,12 +65,6 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
||||
});
|
||||
|
||||
expect(response).not.toContainTextContent('### Modal state');
|
||||
expect(response).toContainTextContent(`
|
||||
\`\`\`yaml
|
||||
- generic [ref=e1]:
|
||||
- button "Choose File" [ref=e2]
|
||||
- button "Button" [ref=e3]
|
||||
\`\`\``);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -101,10 +94,9 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
||||
}
|
||||
});
|
||||
|
||||
test('clicking on download link emits download', async ({ startClient, localOutputPath, server }) => {
|
||||
const outputDir = localOutputPath('output');
|
||||
const client = await startClient({
|
||||
config: { outputDir },
|
||||
test('clicking on download link emits download', async ({ startClient, server, mcpMode }, testInfo) => {
|
||||
const { client } = await startClient({
|
||||
config: { outputDir: testInfo.outputPath('output') },
|
||||
});
|
||||
|
||||
server.setContent('/', `<a href="/download" download="test.txt">Download</a>`, 'text/html');
|
||||
@@ -123,10 +115,14 @@ test('clicking on download link emits download', async ({ startClient, localOutp
|
||||
});
|
||||
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(`
|
||||
### Downloads
|
||||
- Downloaded file test.txt to ${path.join(outputDir, 'test.txt')}`);
|
||||
- Downloaded file test.txt to ${testInfo.outputPath('output', 'test.txt')}`);
|
||||
});
|
||||
|
||||
test('navigating to download link emits download', async ({ client, server, mcpBrowser }) => {
|
||||
test('navigating to download link emits download', async ({ startClient, server, mcpBrowser, mcpMode }, testInfo) => {
|
||||
const { client } = await startClient({
|
||||
config: { outputDir: testInfo.outputPath('output') },
|
||||
});
|
||||
|
||||
test.skip(mcpBrowser === 'webkit' && process.platform === 'linux', 'https://github.com/microsoft/playwright/blob/8e08fdb52c27bb75de9bf87627bf740fadab2122/tests/library/download.spec.ts#L436');
|
||||
server.route('/download', (req, res) => {
|
||||
res.writeHead(200, {
|
||||
|
||||
@@ -22,26 +22,31 @@ import { chromium } from 'playwright';
|
||||
import { test as baseTest, expect as baseExpect } from '@playwright/test';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { ChildProcessWithoutNullStreams, spawn } from 'child_process';
|
||||
import { TestServer } from './testserver/index.ts';
|
||||
|
||||
import type { Config } from '../config';
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import type { Stream } from 'stream';
|
||||
|
||||
export type TestOptions = {
|
||||
mcpBrowser: string | undefined;
|
||||
mcpMode: 'docker' | undefined;
|
||||
};
|
||||
|
||||
type CDPServer = {
|
||||
endpoint: string;
|
||||
start: () => Promise<BrowserContext>;
|
||||
};
|
||||
|
||||
type TestFixtures = {
|
||||
client: Client;
|
||||
visionClient: Client;
|
||||
startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<Client>;
|
||||
startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<{ client: Client, stderr: () => string }>;
|
||||
wsEndpoint: string;
|
||||
cdpEndpoint: (port?: number) => Promise<string>;
|
||||
cdpServer: CDPServer;
|
||||
server: TestServer;
|
||||
httpsServer: TestServer;
|
||||
mcpHeadless: boolean;
|
||||
localOutputPath: (filePath: string) => string;
|
||||
};
|
||||
|
||||
type WorkerFixtures = {
|
||||
@@ -51,20 +56,19 @@ type WorkerFixtures = {
|
||||
export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>({
|
||||
|
||||
client: async ({ startClient }, use) => {
|
||||
await use(await startClient());
|
||||
},
|
||||
|
||||
visionClient: async ({ startClient }, use) => {
|
||||
await use(await startClient({ args: ['--vision'] }));
|
||||
const { client } = await startClient();
|
||||
await use(client);
|
||||
},
|
||||
|
||||
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
|
||||
const userDataDir = testInfo.outputPath('user-data-dir');
|
||||
const userDataDir = mcpMode !== 'docker' ? testInfo.outputPath('user-data-dir') : undefined;
|
||||
const configDir = path.dirname(test.info().config.configFile!);
|
||||
let client: Client | undefined;
|
||||
|
||||
await use(async options => {
|
||||
const args = ['--user-data-dir', path.relative(configDir, userDataDir)];
|
||||
const args: string[] = [];
|
||||
if (userDataDir)
|
||||
args.push('--user-data-dir', userDataDir);
|
||||
if (process.env.CI && process.platform === 'linux')
|
||||
args.push('--no-sandbox');
|
||||
if (mcpHeadless)
|
||||
@@ -80,10 +84,16 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
||||
}
|
||||
|
||||
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
|
||||
const transport = createTransport(args, mcpMode);
|
||||
const { transport, stderr } = await createTransport(args, mcpMode);
|
||||
let stderrBuffer = '';
|
||||
stderr?.on('data', data => {
|
||||
if (process.env.PWMCP_DEBUG)
|
||||
process.stderr.write(data);
|
||||
stderrBuffer += data.toString();
|
||||
});
|
||||
await client.connect(transport);
|
||||
await client.ping();
|
||||
return client;
|
||||
return { client, stderr: () => stderrBuffer };
|
||||
});
|
||||
|
||||
await client?.close();
|
||||
@@ -95,39 +105,25 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
||||
await browserServer.close();
|
||||
},
|
||||
|
||||
cdpEndpoint: async ({ }, use, testInfo) => {
|
||||
let browserProcess: ChildProcessWithoutNullStreams | undefined;
|
||||
cdpServer: async ({ mcpBrowser }, use, testInfo) => {
|
||||
test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser!), 'CDP is not supported for non-Chromium browsers');
|
||||
|
||||
await use(async port => {
|
||||
if (!port)
|
||||
port = 3200 + test.info().parallelIndex;
|
||||
if (browserProcess)
|
||||
return `http://localhost:${port}`;
|
||||
browserProcess = spawn(chromium.executablePath(), [
|
||||
`--user-data-dir=${testInfo.outputPath('user-data-dir')}`,
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--no-first-run`,
|
||||
`--no-sandbox`,
|
||||
`--headless`,
|
||||
'--use-mock-keychain',
|
||||
`data:text/html,hello world`,
|
||||
], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
await new Promise<void>(resolve => {
|
||||
browserProcess!.stderr.on('data', data => {
|
||||
if (data.toString().includes('DevTools listening on '))
|
||||
resolve();
|
||||
let browserContext: BrowserContext | undefined;
|
||||
const port = 3200 + test.info().parallelIndex;
|
||||
await use({
|
||||
endpoint: `http://localhost:${port}`,
|
||||
start: async () => {
|
||||
browserContext = await chromium.launchPersistentContext(testInfo.outputPath('cdp-user-data-dir'), {
|
||||
channel: mcpBrowser,
|
||||
headless: true,
|
||||
args: [
|
||||
`--remote-debugging-port=${port}`,
|
||||
],
|
||||
});
|
||||
});
|
||||
return `http://localhost:${port}`;
|
||||
});
|
||||
await new Promise<void>(resolve => {
|
||||
if (!browserProcess)
|
||||
return resolve();
|
||||
browserProcess.on('exit', () => resolve());
|
||||
browserProcess.kill();
|
||||
return browserContext;
|
||||
}
|
||||
});
|
||||
await browserContext?.close();
|
||||
},
|
||||
|
||||
mcpHeadless: async ({ headless }, use) => {
|
||||
@@ -138,14 +134,7 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
||||
|
||||
mcpMode: [undefined, { option: true }],
|
||||
|
||||
localOutputPath: async ({ mcpMode }, use, testInfo) => {
|
||||
await use(filePath => {
|
||||
test.skip(mcpMode === 'docker', 'Mounting files is not supported in docker mode');
|
||||
return testInfo.outputPath(filePath);
|
||||
});
|
||||
},
|
||||
|
||||
_workerServers: [async ({}, use, workerInfo) => {
|
||||
_workerServers: [async ({ }, use, workerInfo) => {
|
||||
const port = 8907 + workerInfo.workerIndex * 4;
|
||||
const server = await TestServer.create(port);
|
||||
|
||||
@@ -171,22 +160,40 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
||||
},
|
||||
});
|
||||
|
||||
function createTransport(args: string[], mcpMode: TestOptions['mcpMode']) {
|
||||
async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): Promise<{
|
||||
transport: Transport,
|
||||
stderr: Stream | null,
|
||||
}> {
|
||||
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||
const __filename = url.fileURLToPath(import.meta.url);
|
||||
if (mcpMode === 'docker') {
|
||||
const dockerArgs = ['run', '--rm', '-i', '--network=host', '-v', `${test.info().project.outputDir}:/app/test-results`];
|
||||
return new StdioClientTransport({
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'docker',
|
||||
args: [...dockerArgs, 'playwright-mcp-dev:latest', ...args],
|
||||
});
|
||||
return {
|
||||
transport,
|
||||
stderr: transport.stderr,
|
||||
};
|
||||
}
|
||||
return new StdioClientTransport({
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
|
||||
cwd: path.join(path.dirname(__filename), '..'),
|
||||
env: process.env as Record<string, string>,
|
||||
stderr: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
DEBUG: 'pw:mcp:test',
|
||||
DEBUG_COLORS: '0',
|
||||
DEBUG_HIDE_DATE: '1',
|
||||
},
|
||||
});
|
||||
return {
|
||||
transport,
|
||||
stderr: transport.stderr!,
|
||||
};
|
||||
}
|
||||
|
||||
type Response = Awaited<ReturnType<Client['callTool']>>;
|
||||
@@ -219,17 +226,14 @@ export const expect = baseExpect.extend({
|
||||
};
|
||||
},
|
||||
|
||||
toContainTextContent(response: Response, content: string | string[]) {
|
||||
toContainTextContent(response: Response, content: string) {
|
||||
const isNot = this.isNot;
|
||||
try {
|
||||
content = Array.isArray(content) ? content : [content];
|
||||
const texts = (response.content as any).map(c => c.text);
|
||||
for (let i = 0; i < texts.length; i++) {
|
||||
if (isNot)
|
||||
expect(texts[i]).not.toContain(content[i]);
|
||||
else
|
||||
expect(texts[i]).toContain(content[i]);
|
||||
}
|
||||
const texts = (response.content as any).map(c => c.text).join('\n');
|
||||
if (isNot)
|
||||
expect(texts).not.toContain(content);
|
||||
else
|
||||
expect(texts).toContain(content);
|
||||
} catch (e) {
|
||||
return {
|
||||
pass: isNot,
|
||||
@@ -242,3 +246,7 @@ export const expect = baseExpect.extend({
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export function formatOutput(output: string): string[] {
|
||||
return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/user data dir.*/, 'user data dir').trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
246
tests/http.spec.ts
Normal file
246
tests/http.spec.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* 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 'node:fs';
|
||||
import url from 'node:url';
|
||||
|
||||
import { ChildProcess, spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
|
||||
import { test as baseTest, expect } from './fixtures.js';
|
||||
import type { Config } from '../config.d.ts';
|
||||
|
||||
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||
const __filename = url.fileURLToPath(import.meta.url);
|
||||
|
||||
const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noPort?: boolean }) => Promise<{ url: URL, stderr: () => string }> }>({
|
||||
serverEndpoint: async ({ mcpHeadless }, use, testInfo) => {
|
||||
let cp: ChildProcess | undefined;
|
||||
const userDataDir = testInfo.outputPath('user-data-dir');
|
||||
await use(async (options?: { args?: string[], noPort?: boolean }) => {
|
||||
if (cp)
|
||||
throw new Error('Process already running');
|
||||
|
||||
cp = spawn('node', [
|
||||
path.join(path.dirname(__filename), '../cli.js'),
|
||||
...(options?.noPort ? [] : ['--port=0']),
|
||||
'--user-data-dir=' + userDataDir,
|
||||
...(mcpHeadless ? ['--headless'] : []),
|
||||
...(options?.args || []),
|
||||
], {
|
||||
stdio: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
DEBUG: 'pw:mcp:test',
|
||||
DEBUG_COLORS: '0',
|
||||
DEBUG_HIDE_DATE: '1',
|
||||
},
|
||||
});
|
||||
let stderr = '';
|
||||
const url = await new Promise<string>(resolve => cp!.stderr?.on('data', data => {
|
||||
stderr += data.toString();
|
||||
const match = stderr.match(/Listening on (http:\/\/.*)/);
|
||||
if (match)
|
||||
resolve(match[1]);
|
||||
}));
|
||||
|
||||
return { url: new URL(url), stderr: () => stderr };
|
||||
});
|
||||
cp?.kill('SIGTERM');
|
||||
},
|
||||
});
|
||||
|
||||
test('http transport', async ({ serverEndpoint }) => {
|
||||
const { url } = await serverEndpoint();
|
||||
const transport = new StreamableHTTPClientTransport(new URL('/mcp', url));
|
||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
await client.ping();
|
||||
expect(transport.sessionId, 'has session support').toBeDefined();
|
||||
});
|
||||
|
||||
test('http transport (config)', async ({ serverEndpoint }) => {
|
||||
const config: Config = {
|
||||
server: {
|
||||
port: 0,
|
||||
}
|
||||
};
|
||||
const configFile = test.info().outputPath('config.json');
|
||||
await fs.promises.writeFile(configFile, JSON.stringify(config, null, 2));
|
||||
|
||||
const { url } = await serverEndpoint({ noPort: true, args: ['--config=' + configFile] });
|
||||
const transport = new StreamableHTTPClientTransport(new URL('/mcp', url));
|
||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
await client.ping();
|
||||
});
|
||||
|
||||
test('http transport browser lifecycle (isolated)', async ({ serverEndpoint, server }) => {
|
||||
const { url, stderr } = await serverEndpoint({ args: ['--isolated'] });
|
||||
|
||||
const transport1 = new StreamableHTTPClientTransport(new URL('/mcp', url));
|
||||
const client1 = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client1.connect(transport1);
|
||||
await client1.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
await client1.close();
|
||||
|
||||
const transport2 = new StreamableHTTPClientTransport(new URL('/mcp', url));
|
||||
const client2 = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client2.connect(transport2);
|
||||
await client2.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
await client2.close();
|
||||
|
||||
await expect(async () => {
|
||||
const lines = stderr().split('\n');
|
||||
expect(lines.filter(line => line.match(/create context/)).length).toBe(2);
|
||||
expect(lines.filter(line => line.match(/close context/)).length).toBe(2);
|
||||
|
||||
expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(2);
|
||||
expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(2);
|
||||
|
||||
expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(2);
|
||||
expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(2);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('http transport browser lifecycle (isolated, multiclient)', async ({ serverEndpoint, server }) => {
|
||||
const { url, stderr } = await serverEndpoint({ args: ['--isolated'] });
|
||||
|
||||
const transport1 = new StreamableHTTPClientTransport(new URL('/mcp', url));
|
||||
const client1 = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client1.connect(transport1);
|
||||
await client1.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
|
||||
const transport2 = new StreamableHTTPClientTransport(new URL('/mcp', url));
|
||||
const client2 = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client2.connect(transport2);
|
||||
await client2.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
await client1.close();
|
||||
|
||||
const transport3 = new StreamableHTTPClientTransport(new URL('/mcp', url));
|
||||
const client3 = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client3.connect(transport3);
|
||||
await client3.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
|
||||
await client2.close();
|
||||
await client3.close();
|
||||
|
||||
await expect(async () => {
|
||||
const lines = stderr().split('\n');
|
||||
expect(lines.filter(line => line.match(/create context/)).length).toBe(3);
|
||||
expect(lines.filter(line => line.match(/close context/)).length).toBe(3);
|
||||
|
||||
expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(3);
|
||||
expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(3);
|
||||
|
||||
expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(1);
|
||||
expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(1);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('http transport browser lifecycle (persistent)', async ({ serverEndpoint, server }) => {
|
||||
const { url, stderr } = await serverEndpoint();
|
||||
|
||||
const transport1 = new StreamableHTTPClientTransport(new URL('/mcp', url));
|
||||
const client1 = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client1.connect(transport1);
|
||||
await client1.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
await client1.close();
|
||||
|
||||
const transport2 = new StreamableHTTPClientTransport(new URL('/mcp', url));
|
||||
const client2 = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client2.connect(transport2);
|
||||
await client2.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
await client2.close();
|
||||
|
||||
await expect(async () => {
|
||||
const lines = stderr().split('\n');
|
||||
expect(lines.filter(line => line.match(/create context/)).length).toBe(2);
|
||||
expect(lines.filter(line => line.match(/close context/)).length).toBe(2);
|
||||
|
||||
expect(lines.filter(line => line.match(/create browser context \(persistent\)/)).length).toBe(2);
|
||||
expect(lines.filter(line => line.match(/close browser context \(persistent\)/)).length).toBe(2);
|
||||
|
||||
expect(lines.filter(line => line.match(/lock user data dir/)).length).toBe(2);
|
||||
expect(lines.filter(line => line.match(/release user data dir/)).length).toBe(2);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('http transport browser lifecycle (persistent, multiclient)', async ({ serverEndpoint, server }) => {
|
||||
const { url } = await serverEndpoint();
|
||||
|
||||
const transport1 = new StreamableHTTPClientTransport(new URL('/mcp', url));
|
||||
const client1 = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client1.connect(transport1);
|
||||
await client1.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
|
||||
const transport2 = new StreamableHTTPClientTransport(new URL('/mcp', url));
|
||||
const client2 = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client2.connect(transport2);
|
||||
const response = await client2.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
expect(response.isError).toBe(true);
|
||||
expect(response.content?.[0].text).toContain('use --isolated to run multiple instances of the same browser');
|
||||
|
||||
await client1.close();
|
||||
await client2.close();
|
||||
});
|
||||
|
||||
test('sse transport', async ({ serverEndpoint }) => {
|
||||
const { url } = await serverEndpoint();
|
||||
const transport = new SSEClientTransport(new URL('/sse', url));
|
||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
await client.ping();
|
||||
});
|
||||
|
||||
test('http transport (default)', async ({ serverEndpoint }) => {
|
||||
const { url } = await serverEndpoint();
|
||||
const transport = new StreamableHTTPClientTransport(url);
|
||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
await client.ping();
|
||||
expect(transport.sessionId, 'has session support').toBeDefined();
|
||||
});
|
||||
@@ -24,10 +24,10 @@ test('stitched aria frames', async ({ client }) => {
|
||||
},
|
||||
})).toContainTextContent(`
|
||||
\`\`\`yaml
|
||||
- generic [ref=e1]:
|
||||
- generic [active] [ref=e1]:
|
||||
- heading "Hello" [level=1] [ref=e2]
|
||||
- iframe [ref=e3]:
|
||||
- generic [ref=f1e1]:
|
||||
- generic [active] [ref=f1e1]:
|
||||
- button "World" [ref=f1e2]
|
||||
- main [ref=f1e3]:
|
||||
- iframe [ref=f1e4]:
|
||||
|
||||
@@ -16,9 +16,10 @@
|
||||
|
||||
import fs from 'fs';
|
||||
|
||||
import { test, expect } from './fixtures.js';
|
||||
import { test, expect, formatOutput } from './fixtures.js';
|
||||
|
||||
test('test reopen browser', async ({ client, server }) => {
|
||||
test('test reopen browser', async ({ startClient, server, mcpMode }) => {
|
||||
const { client, stderr } = await startClient();
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
@@ -31,11 +32,32 @@ test('test reopen browser', async ({ client, server }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
||||
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
|
||||
|
||||
await client.close();
|
||||
|
||||
if (process.platform === 'win32')
|
||||
return;
|
||||
|
||||
await expect.poll(() => formatOutput(stderr()), { timeout: 0 }).toEqual([
|
||||
'create context',
|
||||
'create browser context (persistent)',
|
||||
'lock user data dir',
|
||||
'close context',
|
||||
'close browser context (persistent)',
|
||||
'release user data dir',
|
||||
'close browser context complete (persistent)',
|
||||
'create browser context (persistent)',
|
||||
'lock user data dir',
|
||||
'close context',
|
||||
'close browser context (persistent)',
|
||||
'release user data dir',
|
||||
'close browser context complete (persistent)',
|
||||
]);
|
||||
});
|
||||
|
||||
test('executable path', async ({ startClient, server }) => {
|
||||
const client = await startClient({ args: [`--executable-path=bogus`] });
|
||||
const { client } = await startClient({ args: [`--executable-path=bogus`] });
|
||||
const response = await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
@@ -53,7 +75,7 @@ test('persistent context', async ({ startClient, server }) => {
|
||||
</script>
|
||||
`, 'text/html');
|
||||
|
||||
const client = await startClient();
|
||||
const { client } = await startClient();
|
||||
const response = await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
@@ -66,7 +88,7 @@ test('persistent context', async ({ startClient, server }) => {
|
||||
name: 'browser_close',
|
||||
});
|
||||
|
||||
const client2 = await startClient();
|
||||
const { client: client2 } = await startClient();
|
||||
const response2 = await client2.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
@@ -85,18 +107,18 @@ test('isolated context', async ({ startClient, server }) => {
|
||||
</script>
|
||||
`, 'text/html');
|
||||
|
||||
const client = await startClient({ args: [`--isolated`] });
|
||||
const response = await client.callTool({
|
||||
const { client: client1 } = await startClient({ args: [`--isolated`] });
|
||||
const response = await client1.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
expect(response).toContainTextContent(`Storage: NO`);
|
||||
|
||||
await client.callTool({
|
||||
await client1.callTool({
|
||||
name: 'browser_close',
|
||||
});
|
||||
|
||||
const client2 = await startClient({ args: [`--isolated`] });
|
||||
const { client: client2 } = await startClient({ args: [`--isolated`] });
|
||||
const response2 = await client2.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
@@ -104,8 +126,8 @@ test('isolated context', async ({ startClient, server }) => {
|
||||
expect(response2).toContainTextContent(`Storage: NO`);
|
||||
});
|
||||
|
||||
test('isolated context with storage state', async ({ startClient, server, localOutputPath }) => {
|
||||
const storageStatePath = localOutputPath('storage-state.json');
|
||||
test('isolated context with storage state', async ({ startClient, server }, testInfo) => {
|
||||
const storageStatePath = testInfo.outputPath('storage-state.json');
|
||||
await fs.promises.writeFile(storageStatePath, JSON.stringify({
|
||||
origins: [
|
||||
{
|
||||
@@ -123,7 +145,7 @@ test('isolated context with storage state', async ({ startClient, server, localO
|
||||
</script>
|
||||
`, 'text/html');
|
||||
|
||||
const client = await startClient({ args: [
|
||||
const { client } = await startClient({ args: [
|
||||
`--isolated`,
|
||||
`--storage-state=${storageStatePath}`,
|
||||
] });
|
||||
|
||||
28
tests/library.spec.ts
Normal file
28
tests/library.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 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.js';
|
||||
import fs from 'node:fs/promises';
|
||||
import child_process from 'node:child_process';
|
||||
|
||||
test('library can be used from CommonJS', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/456' } }, async ({}, testInfo) => {
|
||||
const file = testInfo.outputPath('main.cjs');
|
||||
await fs.writeFile(file, `
|
||||
import('@playwright/mcp')
|
||||
.then(playwrightMCP => playwrightMCP.createConnection())
|
||||
.then(() => console.log('OK'));
|
||||
`);
|
||||
expect(child_process.execSync(`node ${file}`, { encoding: 'utf-8' })).toContain('OK');
|
||||
});
|
||||
@@ -19,7 +19,7 @@ import fs from 'fs';
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test('save as pdf unavailable', async ({ startClient, server }) => {
|
||||
const client = await startClient({ args: ['--caps="no-pdf"'] });
|
||||
const { client } = await startClient();
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
@@ -30,13 +30,17 @@ test('save as pdf unavailable', async ({ startClient, server }) => {
|
||||
})).toHaveTextContent(/Tool \"browser_pdf_save\" not found/);
|
||||
});
|
||||
|
||||
test('save as pdf', async ({ client, mcpBrowser, server }) => {
|
||||
test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => {
|
||||
const { client } = await startClient({
|
||||
config: { outputDir: testInfo.outputPath('output'), capabilities: ['pdf'] },
|
||||
});
|
||||
|
||||
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: server.HELLO_WORLD },
|
||||
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
||||
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
|
||||
|
||||
const response = await client.callTool({
|
||||
name: 'browser_pdf_save',
|
||||
@@ -44,17 +48,17 @@ test('save as pdf', async ({ client, mcpBrowser, server }) => {
|
||||
expect(response).toHaveTextContent(/Save page as.*page-[^:]+.pdf/);
|
||||
});
|
||||
|
||||
test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, server, localOutputPath }) => {
|
||||
test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, server }, testInfo) => {
|
||||
const outputDir = testInfo.outputPath('output');
|
||||
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
|
||||
const outputDir = localOutputPath('output');
|
||||
const client = await startClient({
|
||||
config: { outputDir },
|
||||
const { client } = await startClient({
|
||||
config: { outputDir, capabilities: ['pdf'] },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
||||
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_pdf_save',
|
||||
@@ -73,6 +77,7 @@ test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, ser
|
||||
const files = [...fs.readdirSync(outputDir)];
|
||||
|
||||
expect(fs.existsSync(outputDir)).toBeTruthy();
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files[0]).toMatch(/^output.pdf$/);
|
||||
const pdfFiles = files.filter(f => f.endsWith('.pdf'));
|
||||
expect(pdfFiles).toHaveLength(1);
|
||||
expect(pdfFiles[0]).toMatch(/^output.pdf$/);
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ test('default to allow all', async ({ server, client }) => {
|
||||
});
|
||||
|
||||
test('blocked works', async ({ startClient }) => {
|
||||
const client = await startClient({
|
||||
const { client } = await startClient({
|
||||
args: ['--blocked-origins', 'microsoft.com;example.com;playwright.dev']
|
||||
});
|
||||
const result = await fetchPage(client, 'https://example.com/');
|
||||
@@ -46,7 +46,7 @@ test('blocked works', async ({ startClient }) => {
|
||||
|
||||
test('allowed works', async ({ server, startClient }) => {
|
||||
server.setContent('/ppp', 'content:PPP', 'text/html');
|
||||
const client = await startClient({
|
||||
const { client } = await startClient({
|
||||
args: ['--allowed-origins', `microsoft.com;${new URL(server.PREFIX).host};playwright.dev`]
|
||||
});
|
||||
const result = await fetchPage(client, server.PREFIX + 'ppp');
|
||||
@@ -54,7 +54,7 @@ test('allowed works', async ({ server, startClient }) => {
|
||||
});
|
||||
|
||||
test('blocked takes precedence', async ({ startClient }) => {
|
||||
const client = await startClient({
|
||||
const { client } = await startClient({
|
||||
args: [
|
||||
'--blocked-origins', 'example.com',
|
||||
'--allowed-origins', 'example.com',
|
||||
@@ -65,7 +65,7 @@ test('blocked takes precedence', async ({ startClient }) => {
|
||||
});
|
||||
|
||||
test('allowed without blocked blocks all non-explicitly specified origins', async ({ startClient }) => {
|
||||
const client = await startClient({
|
||||
const { client } = await startClient({
|
||||
args: ['--allowed-origins', 'playwright.dev'],
|
||||
});
|
||||
const result = await fetchPage(client, 'https://example.com/');
|
||||
@@ -74,7 +74,7 @@ test('allowed without blocked blocks all non-explicitly specified origins', asyn
|
||||
|
||||
test('blocked without allowed allows non-explicitly specified origins', async ({ server, startClient }) => {
|
||||
server.setContent('/ppp', 'content:PPP', 'text/html');
|
||||
const client = await startClient({
|
||||
const { client } = await startClient({
|
||||
args: ['--blocked-origins', 'example.com'],
|
||||
});
|
||||
const result = await fetchPage(client, server.PREFIX + 'ppp');
|
||||
|
||||
@@ -18,7 +18,10 @@ import fs from 'fs';
|
||||
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test('browser_take_screenshot (viewport)', async ({ client, server }) => {
|
||||
test('browser_take_screenshot (viewport)', async ({ startClient, server }, testInfo) => {
|
||||
const { client } = await startClient({
|
||||
config: { outputDir: testInfo.outputPath('output') },
|
||||
});
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
@@ -41,7 +44,10 @@ test('browser_take_screenshot (viewport)', async ({ client, server }) => {
|
||||
});
|
||||
});
|
||||
|
||||
test('browser_take_screenshot (element)', async ({ client, server }) => {
|
||||
test('browser_take_screenshot (element)', async ({ startClient, server }, testInfo) => {
|
||||
const { client } = await startClient({
|
||||
config: { outputDir: testInfo.outputPath('output') },
|
||||
});
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
@@ -68,10 +74,10 @@ test('browser_take_screenshot (element)', async ({ client, server }) => {
|
||||
});
|
||||
});
|
||||
|
||||
test('--output-dir should work', async ({ startClient, localOutputPath, server }) => {
|
||||
const outputDir = localOutputPath('output');
|
||||
const client = await startClient({
|
||||
args: ['--output-dir', outputDir],
|
||||
test('--output-dir should work', async ({ startClient, server }, testInfo) => {
|
||||
const outputDir = testInfo.outputPath('output');
|
||||
const { client } = await startClient({
|
||||
config: { outputDir },
|
||||
});
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
@@ -83,14 +89,16 @@ test('--output-dir should work', async ({ startClient, localOutputPath, server }
|
||||
});
|
||||
|
||||
expect(fs.existsSync(outputDir)).toBeTruthy();
|
||||
expect([...fs.readdirSync(outputDir)]).toHaveLength(1);
|
||||
const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.jpeg'));
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files[0]).toMatch(/^page-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.jpeg$/);
|
||||
});
|
||||
|
||||
for (const raw of [undefined, true]) {
|
||||
test(`browser_take_screenshot (raw: ${raw})`, async ({ startClient, localOutputPath, server }) => {
|
||||
test(`browser_take_screenshot (raw: ${raw})`, async ({ startClient, server }, testInfo) => {
|
||||
const outputDir = testInfo.outputPath('output');
|
||||
const ext = raw ? 'png' : 'jpeg';
|
||||
const outputDir = localOutputPath('output');
|
||||
const client = await startClient({
|
||||
const { client } = await startClient({
|
||||
config: { outputDir },
|
||||
});
|
||||
expect(await client.callTool({
|
||||
@@ -117,7 +125,7 @@ for (const raw of [undefined, true]) {
|
||||
],
|
||||
});
|
||||
|
||||
const files = [...fs.readdirSync(outputDir)];
|
||||
const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith(`.${ext}`));
|
||||
|
||||
expect(fs.existsSync(outputDir)).toBeTruthy();
|
||||
expect(files).toHaveLength(1);
|
||||
@@ -128,9 +136,9 @@ for (const raw of [undefined, true]) {
|
||||
|
||||
}
|
||||
|
||||
test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient, localOutputPath, server }) => {
|
||||
const outputDir = localOutputPath('output');
|
||||
const client = await startClient({
|
||||
test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient, server }, testInfo) => {
|
||||
const outputDir = testInfo.outputPath('output');
|
||||
const { client } = await startClient({
|
||||
config: { outputDir },
|
||||
});
|
||||
expect(await client.callTool({
|
||||
@@ -157,17 +165,19 @@ test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient,
|
||||
],
|
||||
});
|
||||
|
||||
const files = [...fs.readdirSync(outputDir)];
|
||||
const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.jpeg'));
|
||||
|
||||
expect(fs.existsSync(outputDir)).toBeTruthy();
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files[0]).toMatch(/^output.jpeg$/);
|
||||
expect(files[0]).toMatch(/^output\.jpeg$/);
|
||||
});
|
||||
|
||||
test('browser_take_screenshot (noImageResponses)', async ({ startClient, server }) => {
|
||||
const client = await startClient({
|
||||
test('browser_take_screenshot (imageResponses=omit)', async ({ startClient, server }, testInfo) => {
|
||||
const outputDir = testInfo.outputPath('output');
|
||||
const { client } = await startClient({
|
||||
config: {
|
||||
noImageResponses: true,
|
||||
outputDir,
|
||||
imageResponses: 'omit',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -191,27 +201,3 @@ test('browser_take_screenshot (noImageResponses)', async ({ startClient, server
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('browser_take_screenshot (cursor)', async ({ startClient, server }) => {
|
||||
const client = await startClient({ clientName: 'cursor:vscode' });
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toContainTextContent(`Navigate to http://localhost`);
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_take_screenshot',
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_take_screenshot',
|
||||
})).toEqual({
|
||||
content: [
|
||||
{
|
||||
text: expect.stringContaining(`Screenshot viewport and save it as`),
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import url from 'node:url';
|
||||
import http from 'node:http';
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
|
||||
import { createConnection } from '@playwright/mcp';
|
||||
|
||||
import { test as baseTest, expect } from './fixtures.js';
|
||||
|
||||
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||
const __filename = url.fileURLToPath(import.meta.url);
|
||||
|
||||
const test = baseTest.extend<{ serverEndpoint: string }>({
|
||||
serverEndpoint: async ({}, use) => {
|
||||
const cp = spawn('node', [path.join(path.dirname(__filename), '../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]);
|
||||
}));
|
||||
|
||||
await use(url);
|
||||
} finally {
|
||||
cp.kill();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
test('sse transport', async ({ serverEndpoint }) => {
|
||||
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 }) => {
|
||||
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();
|
||||
});
|
||||
|
||||
test('sse transport via public API', async ({ server }) => {
|
||||
const sessions = new Map<string, SSEServerTransport>();
|
||||
const mcpServer = http.createServer(async (req, res) => {
|
||||
if (req.method === 'GET') {
|
||||
const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
|
||||
const transport = new SSEServerTransport('/sse', res);
|
||||
sessions.set(transport.sessionId, transport);
|
||||
await connection.connect(transport);
|
||||
} else if (req.method === 'POST') {
|
||||
const url = new URL(`http://localhost${req.url}`);
|
||||
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');
|
||||
}
|
||||
void transport.handlePostMessage(req, res);
|
||||
}
|
||||
});
|
||||
await new Promise<void>(resolve => mcpServer.listen(0, () => resolve()));
|
||||
const serverUrl = `http://localhost:${(mcpServer.address() as AddressInfo).port}/sse`;
|
||||
const transport = new SSEClientTransport(new URL(serverUrl));
|
||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
await client.ping();
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
||||
await client.close();
|
||||
mcpServer.close();
|
||||
});
|
||||
@@ -14,8 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
@@ -33,7 +31,7 @@ test('list initial tabs', async ({ client }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_tab_list',
|
||||
})).toHaveTextContent(`### Open tabs
|
||||
- 1: (current) [] (about:blank)`);
|
||||
- 0: (current) [] (about:blank)`);
|
||||
});
|
||||
|
||||
test('list first tab', async ({ client }) => {
|
||||
@@ -41,46 +39,36 @@ test('list first tab', async ({ client }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_tab_list',
|
||||
})).toHaveTextContent(`### Open tabs
|
||||
- 1: [] (about:blank)
|
||||
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`);
|
||||
- 0: [] (about:blank)
|
||||
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`);
|
||||
});
|
||||
|
||||
test('create new tab', async ({ client }) => {
|
||||
expect(await createTab(client, 'Tab one', 'Body one')).toHaveTextContent(`
|
||||
- Ran Playwright code:
|
||||
\`\`\`js
|
||||
// <internal code to open a new tab>
|
||||
\`\`\`
|
||||
|
||||
expect(await createTab(client, 'Tab one', 'Body one')).toContainTextContent(`
|
||||
### Open tabs
|
||||
- 1: [] (about:blank)
|
||||
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||
- 0: [] (about:blank)
|
||||
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||
|
||||
### Current tab
|
||||
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
||||
- Page Title: Tab one
|
||||
- Page Snapshot
|
||||
- Page Snapshot:
|
||||
\`\`\`yaml
|
||||
- generic [ref=e1]: Body one
|
||||
- generic [active] [ref=e1]: Body one
|
||||
\`\`\``);
|
||||
|
||||
expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(`
|
||||
- Ran Playwright code:
|
||||
\`\`\`js
|
||||
// <internal code to open a new tab>
|
||||
\`\`\`
|
||||
|
||||
expect(await createTab(client, 'Tab two', 'Body two')).toContainTextContent(`
|
||||
### 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>)
|
||||
- 0: [] (about:blank)
|
||||
- 1: [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||
- 2: (current) [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
|
||||
|
||||
### Current tab
|
||||
- Page URL: data:text/html,<title>Tab two</title><body>Body two</body>
|
||||
- Page Title: Tab two
|
||||
- Page Snapshot
|
||||
- Page Snapshot:
|
||||
\`\`\`yaml
|
||||
- generic [ref=e1]: Body two
|
||||
- generic [active] [ref=e1]: Body two
|
||||
\`\`\``);
|
||||
});
|
||||
|
||||
@@ -90,25 +78,25 @@ test('select tab', async ({ client }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_tab_select',
|
||||
arguments: {
|
||||
index: 2,
|
||||
index: 1,
|
||||
},
|
||||
})).toHaveTextContent(`
|
||||
- Ran Playwright code:
|
||||
### Ran Playwright code
|
||||
\`\`\`js
|
||||
// <internal code to select tab 2>
|
||||
// <internal code to select tab 1>
|
||||
\`\`\`
|
||||
|
||||
### 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>)
|
||||
- 0: [] (about:blank)
|
||||
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||
- 2: [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
|
||||
|
||||
### Current tab
|
||||
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
||||
- Page Title: Tab one
|
||||
- Page Snapshot
|
||||
- Page Snapshot:
|
||||
\`\`\`yaml
|
||||
- generic [ref=e1]: Body one
|
||||
- generic [active] [ref=e1]: Body one
|
||||
\`\`\``);
|
||||
});
|
||||
|
||||
@@ -118,38 +106,35 @@ test('close tab', async ({ client }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_tab_close',
|
||||
arguments: {
|
||||
index: 3,
|
||||
index: 2,
|
||||
},
|
||||
})).toHaveTextContent(`
|
||||
- Ran Playwright code:
|
||||
### Ran Playwright code
|
||||
\`\`\`js
|
||||
// <internal code to close tab 3>
|
||||
// <internal code to close tab 2>
|
||||
\`\`\`
|
||||
|
||||
### Open tabs
|
||||
- 1: [] (about:blank)
|
||||
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||
- 0: [] (about:blank)
|
||||
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||
|
||||
### Current tab
|
||||
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
||||
- Page Title: Tab one
|
||||
- Page Snapshot
|
||||
- Page Snapshot:
|
||||
\`\`\`yaml
|
||||
- generic [ref=e1]: Body one
|
||||
- generic [active] [ref=e1]: Body one
|
||||
\`\`\``);
|
||||
});
|
||||
|
||||
test('reuse first tab when navigating', async ({ startClient, cdpEndpoint, server }) => {
|
||||
server.setContent('/', `<title>Title</title><body>Body</body>`, 'text/html');
|
||||
test('reuse first tab when navigating', async ({ startClient, cdpServer, server }) => {
|
||||
const browserContext = await cdpServer.start();
|
||||
const pages = browserContext.pages();
|
||||
|
||||
const browser = await chromium.connectOverCDP(await cdpEndpoint());
|
||||
const [context] = browser.contexts();
|
||||
const pages = context.pages();
|
||||
|
||||
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
|
||||
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
|
||||
expect(pages.length).toBe(1);
|
||||
|
||||
35
tests/trace.spec.ts
Normal file
35
tests/trace.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 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 path from 'path';
|
||||
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test('check that trace is saved', async ({ startClient, server, mcpMode }, testInfo) => {
|
||||
const outputDir = testInfo.outputPath('output');
|
||||
|
||||
const { client } = await startClient({
|
||||
args: ['--save-trace', `--output-dir=${outputDir}`],
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toContainTextContent(`Navigate to http://localhost`);
|
||||
|
||||
expect(fs.existsSync(path.join(outputDir, 'traces', 'trace.trace'))).toBeTruthy();
|
||||
});
|
||||
@@ -20,60 +20,20 @@ import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import url from 'node:url'
|
||||
import zodToJsonSchema from 'zod-to-json-schema'
|
||||
|
||||
import commonTools from '../lib/tools/common.js';
|
||||
import consoleTools from '../lib/tools/console.js';
|
||||
import dialogsTools from '../lib/tools/dialogs.js';
|
||||
import filesTools from '../lib/tools/files.js';
|
||||
import installTools from '../lib/tools/install.js';
|
||||
import keyboardTools from '../lib/tools/keyboard.js';
|
||||
import navigateTools from '../lib/tools/navigate.js';
|
||||
import networkTools from '../lib/tools/network.js';
|
||||
import pdfTools from '../lib/tools/pdf.js';
|
||||
import snapshotTools from '../lib/tools/snapshot.js';
|
||||
import tabsTools from '../lib/tools/tabs.js';
|
||||
import screenshotTools from '../lib/tools/screenshot.js';
|
||||
import testTools from '../lib/tools/testing.js';
|
||||
import visionTools from '../lib/tools/vision.js';
|
||||
import waitTools from '../lib/tools/wait.js';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
const categories = {
|
||||
'Interactions': [
|
||||
...snapshotTools,
|
||||
...keyboardTools(true),
|
||||
...waitTools(true),
|
||||
...filesTools(true),
|
||||
...dialogsTools(true),
|
||||
],
|
||||
'Navigation': [
|
||||
...navigateTools(true),
|
||||
],
|
||||
'Resources': [
|
||||
...screenshotTools,
|
||||
...pdfTools,
|
||||
...networkTools,
|
||||
...consoleTools,
|
||||
],
|
||||
'Utilities': [
|
||||
...installTools,
|
||||
...commonTools(true),
|
||||
],
|
||||
'Tabs': [
|
||||
...tabsTools(true),
|
||||
],
|
||||
'Testing': [
|
||||
...testTools,
|
||||
],
|
||||
'Vision mode': [
|
||||
...visionTools,
|
||||
...keyboardTools(),
|
||||
...waitTools(false),
|
||||
...filesTools(false),
|
||||
...dialogsTools(false),
|
||||
],
|
||||
import { allTools } from '../lib/tools.js';
|
||||
|
||||
const capabilities = {
|
||||
'core': 'Core automation',
|
||||
'core-tabs': 'Tab management',
|
||||
'core-install': 'Browser installation',
|
||||
'vision': 'Coordinate-based (opt-in via --caps=vision)',
|
||||
'pdf': 'PDF generation (opt-in via --caps=pdf)',
|
||||
};
|
||||
|
||||
const toolsByCapability = Object.fromEntries(Object.entries(capabilities).map(([capability, title]) => [title, allTools.filter(tool => tool.capability === capability).sort((a, b) => a.schema.name.localeCompare(b.schema.name))]));
|
||||
|
||||
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||
const __filename = url.fileURLToPath(import.meta.url);
|
||||
|
||||
@@ -139,14 +99,12 @@ async function updateSection(content, startMarker, endMarker, generatedLines) {
|
||||
async function updateTools(content) {
|
||||
console.log('Loading tool information from compiled modules...');
|
||||
|
||||
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(`<details>\n<summary><b>${category}</b></summary>`);
|
||||
for (const [capability, tools] of Object.entries(toolsByCapability)) {
|
||||
console.log('Updating tools for capability:', capability);
|
||||
generatedLines.push(`<details>\n<summary><b>${capability}</b></summary>`);
|
||||
generatedLines.push('');
|
||||
for (const tool of categoryTools)
|
||||
for (const tool of tools)
|
||||
generatedLines.push(...formatToolForReadme(tool.schema));
|
||||
generatedLines.push(`</details>`);
|
||||
generatedLines.push('');
|
||||
|
||||
Reference in New Issue
Block a user