Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b944e8cbaf | ||
|
|
f8f6e2fcc3 | ||
|
|
86eba2245a | ||
|
|
2521a67b2f | ||
|
|
fb28e99fa4 | ||
|
|
64af5f8763 | ||
|
|
fb65bc7559 | ||
|
|
94ca0763d5 | ||
|
|
2ae7800ac1 | ||
|
|
f6862a39c3 | ||
|
|
e664e0460c | ||
|
|
865eac2fee | ||
|
|
d5d810f896 | ||
|
|
1efd3b55e5 | ||
|
|
1d1db1e287 | ||
|
|
25f15e7f5e | ||
|
|
c559243ef6 | ||
|
|
91d5d24cab | ||
|
|
92554abfd1 | ||
|
|
4370f2cdf2 | ||
|
|
ba726fb44a | ||
|
|
2fc4e88048 | ||
|
|
3f148a4005 | ||
|
|
c92aefdc12 | ||
|
|
badfd82202 | ||
|
|
12942b81d6 | ||
|
|
73adb0fdf0 | ||
|
|
8572ab300c | ||
|
|
c091a11d76 | ||
|
|
dbd44110f1 | ||
|
|
2f41a3f6b1 | ||
|
|
7c4d67b3ae | ||
|
|
53c6b6dcb1 | ||
|
|
1fb2878271 | ||
|
|
ab0ecc4075 | ||
|
|
f010164bf1 | ||
|
|
db9cfe1720 | ||
|
|
24f81a7a27 | ||
|
|
21ced701b5 | ||
|
|
d3bf2eefc6 | ||
|
|
2ca899316d | ||
|
|
16f3523317 | ||
|
|
6c2dda31ad | ||
|
|
3b6ecf0a43 | ||
|
|
636f1956cc | ||
|
|
5aef2aafcb | ||
|
|
8ecc46c905 | ||
|
|
5dbb1504ba | ||
|
|
20e1144c3b | ||
|
|
eab20aa69e | ||
|
|
46ce86f97e | ||
|
|
4890b9d509 | ||
|
|
3f6837baa9 | ||
|
|
6d62c173c8 | ||
|
|
3c6eac9b21 | ||
|
|
41a44f7abc | ||
|
|
372395666a | ||
|
|
a60d7b8cd1 | ||
|
|
ffe0117456 | ||
|
|
7c07cc86eb | ||
|
|
3787439fc1 | ||
|
|
2a86ac74e3 | ||
|
|
6dd44923da | ||
|
|
f600234897 | ||
|
|
4df162aff5 | ||
|
|
65d99fe595 | ||
|
|
903c857f19 | ||
|
|
9b5f97b076 |
54
.github/workflows/ci.yml
vendored
54
.github/workflows/ci.yml
vendored
@@ -11,10 +11,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js 18
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '20'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -28,15 +28,14 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
os: [ubuntu-latest, macos-15, windows-latest]
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js 18
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
# https://github.com/microsoft/playwright-mcp/issues/344
|
node-version: '20'
|
||||||
node-version: '18.19'
|
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -55,10 +54,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js 18
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '20'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -83,3 +82,42 @@ jobs:
|
|||||||
npm run test -- --project=chromium-docker
|
npm run test -- --project=chromium-docker
|
||||||
env:
|
env:
|
||||||
MCP_IN_DOCKER: 1
|
MCP_IN_DOCKER: 1
|
||||||
|
|
||||||
|
test_extension:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
runs-on: macos-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ./extension
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Use Node.js 20
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20' # crypto.randomUUID(); stalls in v18.20.8
|
||||||
|
cache: 'npm'
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Build extension
|
||||||
|
run: npm run build
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: extension
|
||||||
|
path: ./extension/dist
|
||||||
|
retention-days: 7
|
||||||
|
- name: Install and build MCP server
|
||||||
|
run: |
|
||||||
|
cd ..
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
npx playwright install chromium
|
||||||
|
- name: Run tests
|
||||||
|
run: |
|
||||||
|
if [[ "$(uname)" == "Linux" ]]; then
|
||||||
|
xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test
|
||||||
|
else
|
||||||
|
npm run test
|
||||||
|
fi
|
||||||
|
shell: bash
|
||||||
|
|||||||
28
.github/workflows/publish.yml
vendored
28
.github/workflows/publish.yml
vendored
@@ -68,3 +68,31 @@ jobs:
|
|||||||
for tag in $(echo ${{ steps.build-push.outputs.metadata['image.name'] }} | tr ',' '\n'); do
|
for tag in $(echo ${{ steps.build-push.outputs.metadata['image.name'] }} | tr ',' '\n'); do
|
||||||
attach_eol_manifest $tag
|
attach_eol_manifest $tag
|
||||||
done
|
done
|
||||||
|
|
||||||
|
package-extension:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write # Needed to upload release assets
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: 'npm'
|
||||||
|
- name: Install extension dependencies
|
||||||
|
working-directory: ./extension
|
||||||
|
run: npm ci
|
||||||
|
- name: Build extension
|
||||||
|
working-directory: ./extension
|
||||||
|
run: npm run build
|
||||||
|
- name: Package extension
|
||||||
|
working-directory: ./extension
|
||||||
|
run: |
|
||||||
|
cd dist
|
||||||
|
zip -r ../playwright-mcp-extension-${{ github.event.release.tag_name }}.zip .
|
||||||
|
cd ..
|
||||||
|
- name: Upload extension to release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
gh release upload ${{github.event.release.tag_name}} ./extension/playwright-mcp-extension-${{ github.event.release.tag_name }}.zip
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
lib/
|
lib/
|
||||||
|
dist/
|
||||||
node_modules/
|
node_modules/
|
||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
|
|||||||
94
README.md
94
README.md
@@ -56,12 +56,27 @@ Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user),
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Codex</summary>
|
||||||
|
|
||||||
|
Create or edit the configuration file `~/.codex/config.toml` and add:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[mcp_servers.playwright]
|
||||||
|
command = "npx"
|
||||||
|
args = ["@playwright/mcp@latest"]
|
||||||
|
```
|
||||||
|
|
||||||
|
For more information, see the [Codex MCP documentation](https://github.com/openai/codex/blob/main/codex-rs/config.md#mcp_servers).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Cursor</summary>
|
<summary>Cursor</summary>
|
||||||
|
|
||||||
#### Click the button to install:
|
#### Click the button to install:
|
||||||
|
|
||||||
[](https://cursor.com/install-mcp?name=playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
|
[](cursor://anysphere.cursor-deeplink/mcp/install?name=Playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
|
||||||
|
|
||||||
#### Or install manually:
|
#### Or install manually:
|
||||||
|
|
||||||
@@ -100,6 +115,29 @@ Go to `Advanced settings` -> `Extensions` -> `Add custom extension`. Name to you
|
|||||||
Go to `Program` in the right sidebar -> `Install` -> `Edit mcp.json`. Use the standard config above.
|
Go to `Program` in the right sidebar -> `Install` -> `Edit mcp.json`. Use the standard config above.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>opencode</summary>
|
||||||
|
|
||||||
|
Follow the MCP Servers [documentation](https://opencode.ai/docs/mcp-servers/). For example in `~/.config/opencode/opencode.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"mcp": {
|
||||||
|
"playwright": {
|
||||||
|
"type": "local",
|
||||||
|
"command": [
|
||||||
|
"npx",
|
||||||
|
"@playwright/mcp@latest"
|
||||||
|
],
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Qodo Gen</summary>
|
<summary>Qodo Gen</summary>
|
||||||
|
|
||||||
@@ -158,6 +196,9 @@ Playwright MCP server supports following arguments. They can be provided in the
|
|||||||
--config <path> path to the configuration file.
|
--config <path> path to the configuration file.
|
||||||
--device <device> device to emulate, for example: "iPhone 15"
|
--device <device> device to emulate, for example: "iPhone 15"
|
||||||
--executable-path <path> path to the browser executable.
|
--executable-path <path> path to the browser executable.
|
||||||
|
--extension Connect to a running browser instance
|
||||||
|
(Edge/Chrome only). Requires the "Playwright MCP
|
||||||
|
Bridge" browser extension to be installed.
|
||||||
--headless run browser in headless mode, headed by default
|
--headless run browser in headless mode, headed by default
|
||||||
--host <host> host to bind server to. Default is localhost. Use
|
--host <host> host to bind server to. Default is localhost. Use
|
||||||
0.0.0.0 to bind to all interfaces.
|
0.0.0.0 to bind to all interfaces.
|
||||||
@@ -191,7 +232,7 @@ Playwright MCP server supports following arguments. They can be provided in the
|
|||||||
|
|
||||||
### User profile
|
### User profile
|
||||||
|
|
||||||
You can run Playwright MCP with persistent profile like a regular browser (default), or in the isolated contexts for the testing sessions.
|
You can run Playwright MCP with persistent profile like a regular browser (default), in isolated contexts for testing sessions, or connect to your existing browser using the browser extension.
|
||||||
|
|
||||||
**Persistent profile**
|
**Persistent profile**
|
||||||
|
|
||||||
@@ -231,6 +272,10 @@ state [here](https://playwright.dev/docs/auth).
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Browser Extension**
|
||||||
|
|
||||||
|
The Playwright MCP Chrome Extension allows you to connect to existing browser tabs and leverage your logged-in sessions and browser state. See [extension/README.md](extension/README.md) for installation and setup instructions.
|
||||||
|
|
||||||
### Configuration file
|
### Configuration file
|
||||||
|
|
||||||
The Playwright MCP server can be configured using a JSON configuration file. You can specify the configuration file
|
The Playwright MCP server can be configured using a JSON configuration file. You can specify the configuration file
|
||||||
@@ -486,14 +531,6 @@ http.createServer(async (req, res) => {
|
|||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_navigate_forward**
|
|
||||||
- Title: Go forward
|
|
||||||
- Description: Go forward to the next page
|
|
||||||
- Parameters: None
|
|
||||||
- Read-only: **true**
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_network_requests**
|
- **browser_network_requests**
|
||||||
- Title: List network requests
|
- Title: List network requests
|
||||||
- Description: Returns all network requests since loading the page
|
- Description: Returns all network requests since loading the page
|
||||||
@@ -544,7 +581,7 @@ http.createServer(async (req, res) => {
|
|||||||
- Title: Take a screenshot
|
- Title: Take a screenshot
|
||||||
- Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.
|
- Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `raw` (boolean, optional): Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.
|
- `type` (string, optional): Image format for the screenshot. Default is png.
|
||||||
- `filename` (string, optional): File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.
|
- `filename` (string, optional): File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.
|
||||||
- `element` (string, optional): Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.
|
- `element` (string, optional): Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.
|
||||||
- `ref` (string, optional): Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.
|
- `ref` (string, optional): Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.
|
||||||
@@ -582,39 +619,14 @@ http.createServer(async (req, res) => {
|
|||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_tab_close**
|
- **browser_tabs**
|
||||||
- Title: Close a tab
|
- Title: Manage tabs
|
||||||
- Description: Close a tab
|
- Description: List, create, close, or select a browser tab.
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `index` (number, optional): The index of the tab to close. Closes current tab if not provided.
|
- `action` (string): Operation to perform
|
||||||
|
- `index` (number, optional): Tab index, used for close/select. If omitted for close, current tab is closed.
|
||||||
- Read-only: **false**
|
- Read-only: **false**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_tab_list**
|
|
||||||
- Title: List tabs
|
|
||||||
- Description: List browser tabs
|
|
||||||
- Parameters: None
|
|
||||||
- Read-only: **true**
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_tab_new**
|
|
||||||
- Title: Open a new tab
|
|
||||||
- Description: Open a new tab
|
|
||||||
- Parameters:
|
|
||||||
- `url` (string, optional): The URL to navigate to in the new tab. If not provided, the new tab will be blank.
|
|
||||||
- Read-only: **true**
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_tab_select**
|
|
||||||
- Title: Select a tab
|
|
||||||
- Description: Select a tab by index
|
|
||||||
- Parameters:
|
|
||||||
- `index` (number): The index of the tab to select
|
|
||||||
- Read-only: **true**
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|||||||
48
extension/README.md
Normal file
48
extension/README.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Playwright MCP Chrome Extension
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
The Playwright MCP Chrome Extension allows you to connect to pages in your existing browser and leverage the state of your default user profile. This means the AI assistant can interact with websites where you're already logged in, using your existing cookies, sessions, and browser state, providing a seamless experience without requiring separate authentication or setup.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Chrome/Edge/Chromium browser
|
||||||
|
|
||||||
|
## Installation Steps
|
||||||
|
|
||||||
|
### Download the Extension
|
||||||
|
|
||||||
|
Download the latest Chrome extension from GitHub:
|
||||||
|
- **Download link**: https://github.com/microsoft/playwright-mcp/releases
|
||||||
|
|
||||||
|
### Load Chrome Extension
|
||||||
|
|
||||||
|
1. Open Chrome and navigate to `chrome://extensions/`
|
||||||
|
2. Enable "Developer mode" (toggle in the top right corner)
|
||||||
|
3. Click "Load unpacked" and select the extension directory
|
||||||
|
|
||||||
|
### Configure Playwright MCP server
|
||||||
|
|
||||||
|
Configure Playwright MCP server to connect to the browser using the extension by passing the `--extension` option when running the MCP server:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"playwright-extension": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"@playwright/mcp@latest",
|
||||||
|
"--extension"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Browser Tab Selection
|
||||||
|
|
||||||
|
When the LLM interacts with the browser for the first time, it will load a page where you can select which browser tab the LLM will connect to. This allows you to control which specific page the AI assistant will interact with during the session.
|
||||||
|
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Playwright MCP Bridge",
|
"name": "Playwright MCP Bridge",
|
||||||
"version": "1.0.0",
|
"version": "0.0.34",
|
||||||
"description": "Share browser tabs with Playwright MCP server",
|
"description": "Share browser tabs with Playwright MCP server",
|
||||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9nMS2b0WCohjVHPGb8D9qAdkbIngDqoAjTeSccHJijgcONejge+OJxOQOMLu7b0ovt1c9BiEJa5JcpM+EHFVGL1vluBxK71zmBy1m2f9vZF3HG0LSCp7YRkum9rAIEthDwbkxx6XTvpmAY5rjFa/NON6b9Hlbo+8peUSkoOK7HTwYnnI36asZ9eUTiveIf+DMPLojW2UX33vDWG2UKvMVDewzclb4+uLxAYshY7Mx8we/b44xu+Anb/EBLKjOPk9Yh541xJ5Ozc8EiP/5yxOp9c/lRiYUHaRW+4r0HKZyFt0eZ52ti2iM4Nfk7jRXR7an3JPsUIf5deC/1cVM/+1ZQIDAQAB",
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9nMS2b0WCohjVHPGb8D9qAdkbIngDqoAjTeSccHJijgcONejge+OJxOQOMLu7b0ovt1c9BiEJa5JcpM+EHFVGL1vluBxK71zmBy1m2f9vZF3HG0LSCp7YRkum9rAIEthDwbkxx6XTvpmAY5rjFa/NON6b9Hlbo+8peUSkoOK7HTwYnnI36asZ9eUTiveIf+DMPLojW2UX33vDWG2UKvMVDewzclb4+uLxAYshY7Mx8we/b44xu+Anb/EBLKjOPk9Yh541xJ5Ozc8EiP/5yxOp9c/lRiYUHaRW+4r0HKZyFt0eZ52ti2iM4Nfk7jRXR7an3JPsUIf5deC/1cVM/+1ZQIDAQAB",
|
||||||
|
|
||||||
|
|||||||
1884
extension/package-lock.json
generated
Normal file
1884
extension/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
extension/package.json
Normal file
36
extension/package.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "@playwright/mcp-extension",
|
||||||
|
"version": "0.0.34",
|
||||||
|
"description": "Playwright MCP Browser Extension",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/microsoft/playwright-mcp.git"
|
||||||
|
},
|
||||||
|
"homepage": "https://playwright.dev",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"author": {
|
||||||
|
"name": "Microsoft Corporation"
|
||||||
|
},
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --project . && tsc --project tsconfig.ui.json && vite build",
|
||||||
|
"watch": "tsc --watch --project . & tsc --watch --project tsconfig.ui.json & vite build --watch",
|
||||||
|
"test": "playwright test",
|
||||||
|
"clean": "rm -rf dist"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/chrome": "^0.0.315",
|
||||||
|
"@types/react": "^18.2.66",
|
||||||
|
"@types/react-dom": "^18.2.22",
|
||||||
|
"@vitejs/plugin-react": "^4.0.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"typescript": "^5.8.2",
|
||||||
|
"vite": "^5.0.0",
|
||||||
|
"vite-plugin-static-copy": "^3.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
31
extension/playwright.config.ts
Normal file
31
extension/playwright.config.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* 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 { defineConfig } from '@playwright/test';
|
||||||
|
|
||||||
|
import type { TestOptions } from '../tests/fixtures.js';
|
||||||
|
|
||||||
|
export default defineConfig<TestOptions>({
|
||||||
|
testDir: './tests',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: 'list',
|
||||||
|
projects: [
|
||||||
|
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -19,42 +19,69 @@ import { RelayConnection, debugLog } from './relayConnection.js';
|
|||||||
type PageMessage = {
|
type PageMessage = {
|
||||||
type: 'connectToMCPRelay';
|
type: 'connectToMCPRelay';
|
||||||
mcpRelayUrl: string;
|
mcpRelayUrl: string;
|
||||||
tabId: number;
|
|
||||||
windowId: number;
|
|
||||||
} | {
|
} | {
|
||||||
type: 'getTabs';
|
type: 'getTabs';
|
||||||
|
} | {
|
||||||
|
type: 'connectToTab';
|
||||||
|
tabId?: number;
|
||||||
|
windowId?: number;
|
||||||
|
mcpRelayUrl: string;
|
||||||
|
} | {
|
||||||
|
type: 'getConnectionStatus';
|
||||||
|
} | {
|
||||||
|
type: 'disconnect';
|
||||||
};
|
};
|
||||||
|
|
||||||
class TabShareExtension {
|
class TabShareExtension {
|
||||||
private _activeConnection: RelayConnection | undefined;
|
private _activeConnection: RelayConnection | undefined;
|
||||||
private _connectedTabId: number | null = null;
|
private _connectedTabId: number | null = null;
|
||||||
|
private _pendingTabSelection = new Map<number, { connection: RelayConnection, timerId?: number }>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
chrome.tabs.onRemoved.addListener(this._onTabRemoved.bind(this));
|
chrome.tabs.onRemoved.addListener(this._onTabRemoved.bind(this));
|
||||||
chrome.tabs.onUpdated.addListener(this._onTabUpdated.bind(this));
|
chrome.tabs.onUpdated.addListener(this._onTabUpdated.bind(this));
|
||||||
|
chrome.tabs.onActivated.addListener(this._onTabActivated.bind(this));
|
||||||
chrome.runtime.onMessage.addListener(this._onMessage.bind(this));
|
chrome.runtime.onMessage.addListener(this._onMessage.bind(this));
|
||||||
|
chrome.action.onClicked.addListener(this._onActionClicked.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Promise-based message handling is not supported in Chrome: https://issues.chromium.org/issues/40753031
|
// Promise-based message handling is not supported in Chrome: https://issues.chromium.org/issues/40753031
|
||||||
private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) {
|
private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) {
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case 'connectToMCPRelay':
|
case 'connectToMCPRelay':
|
||||||
this._connectTab(message.tabId, message.windowId, message.mcpRelayUrl!).then(
|
this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl).then(
|
||||||
() => sendResponse({ success: true }),
|
() => sendResponse({ success: true }),
|
||||||
(error: any) => sendResponse({ success: false, error: error.message }));
|
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||||
return true; // Return true to indicate that the response will be sent asynchronously
|
return true;
|
||||||
case 'getTabs':
|
case 'getTabs':
|
||||||
this._getTabs().then(
|
this._getTabs().then(
|
||||||
tabs => sendResponse({ success: true, tabs, currentTabId: sender.tab?.id }),
|
tabs => sendResponse({ success: true, tabs, currentTabId: sender.tab?.id }),
|
||||||
(error: any) => sendResponse({ success: false, error: error.message }));
|
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||||
return true;
|
return true;
|
||||||
|
case 'connectToTab':
|
||||||
|
const tabId = message.tabId || sender.tab?.id!;
|
||||||
|
const windowId = message.windowId || sender.tab?.windowId!;
|
||||||
|
this._connectTab(sender.tab!.id!, tabId, windowId, message.mcpRelayUrl!).then(
|
||||||
|
() => sendResponse({ success: true }),
|
||||||
|
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||||
|
return true; // Return true to indicate that the response will be sent asynchronously
|
||||||
|
case 'getConnectionStatus':
|
||||||
|
sendResponse({
|
||||||
|
connectedTabId: this._connectedTabId
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
case 'disconnect':
|
||||||
|
this._disconnect().then(
|
||||||
|
() => sendResponse({ success: true }),
|
||||||
|
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _connectTab(tabId: number, windowId: number, mcpRelayUrl: string): Promise<void> {
|
private async _connectToRelay(selectorTabId: number, mcpRelayUrl: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
debugLog(`Connecting tab ${tabId} to bridge at ${mcpRelayUrl}`);
|
debugLog(`Connecting to relay at ${mcpRelayUrl}`);
|
||||||
const socket = new WebSocket(mcpRelayUrl);
|
const socket = new WebSocket(mcpRelayUrl);
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
socket.onopen = () => resolve();
|
socket.onopen = () => resolve();
|
||||||
@@ -62,17 +89,42 @@ class TabShareExtension {
|
|||||||
setTimeout(() => reject(new Error('Connection timeout')), 5000);
|
setTimeout(() => reject(new Error('Connection timeout')), 5000);
|
||||||
});
|
});
|
||||||
|
|
||||||
const connection = new RelayConnection(socket, tabId);
|
const connection = new RelayConnection(socket);
|
||||||
const connectionClosed = (m: string) => {
|
connection.onclose = () => {
|
||||||
debugLog(m);
|
debugLog('Connection closed');
|
||||||
if (this._activeConnection === connection) {
|
this._pendingTabSelection.delete(selectorTabId);
|
||||||
this._activeConnection = undefined;
|
// TODO: show error in the selector tab?
|
||||||
void this._setConnectedTabId(null);
|
};
|
||||||
}
|
this._pendingTabSelection.set(selectorTabId, { connection });
|
||||||
|
debugLog(`Connected to MCP relay`);
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = `Failed to connect to MCP relay: ${error.message}`;
|
||||||
|
debugLog(message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _connectTab(selectorTabId: number, tabId: number, windowId: number, mcpRelayUrl: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
debugLog(`Connecting tab ${tabId} to relay at ${mcpRelayUrl}`);
|
||||||
|
try {
|
||||||
|
this._activeConnection?.close('Another connection is requested');
|
||||||
|
} catch (error: any) {
|
||||||
|
debugLog(`Error closing active connection:`, error);
|
||||||
|
}
|
||||||
|
await this._setConnectedTabId(null);
|
||||||
|
|
||||||
|
this._activeConnection = this._pendingTabSelection.get(selectorTabId)?.connection;
|
||||||
|
if (!this._activeConnection)
|
||||||
|
throw new Error('No active MCP relay connection');
|
||||||
|
this._pendingTabSelection.delete(selectorTabId);
|
||||||
|
|
||||||
|
this._activeConnection.setTabId(tabId);
|
||||||
|
this._activeConnection.onclose = () => {
|
||||||
|
debugLog('MCP connection closed');
|
||||||
|
this._activeConnection = undefined;
|
||||||
|
void this._setConnectedTabId(null);
|
||||||
};
|
};
|
||||||
socket.onclose = () => connectionClosed('WebSocket closed');
|
|
||||||
socket.onerror = error => connectionClosed(`WebSocket error: ${error}`);
|
|
||||||
this._activeConnection = connection;
|
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this._setConnectedTabId(tabId),
|
this._setConnectedTabId(tabId),
|
||||||
@@ -91,18 +143,29 @@ class TabShareExtension {
|
|||||||
const oldTabId = this._connectedTabId;
|
const oldTabId = this._connectedTabId;
|
||||||
this._connectedTabId = tabId;
|
this._connectedTabId = tabId;
|
||||||
if (oldTabId && oldTabId !== tabId)
|
if (oldTabId && oldTabId !== tabId)
|
||||||
await this._updateBadge(oldTabId, { text: '', color: null });
|
await this._updateBadge(oldTabId, { text: '' });
|
||||||
if (tabId)
|
if (tabId)
|
||||||
await this._updateBadge(tabId, { text: '●', color: '#4CAF50' });
|
await this._updateBadge(tabId, { text: '✓', color: '#4CAF50', title: 'Connected to MCP client' });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _updateBadge(tabId: number, { text, color }: { text: string; color: string | null }): Promise<void> {
|
private async _updateBadge(tabId: number, { text, color, title }: { text: string; color?: string, title?: string }): Promise<void> {
|
||||||
await chrome.action.setBadgeText({ tabId, text });
|
try {
|
||||||
if (color)
|
await chrome.action.setBadgeText({ tabId, text });
|
||||||
await chrome.action.setBadgeBackgroundColor({ tabId, color });
|
await chrome.action.setTitle({ tabId, title: title || '' });
|
||||||
|
if (color)
|
||||||
|
await chrome.action.setBadgeBackgroundColor({ tabId, color });
|
||||||
|
} catch (error: any) {
|
||||||
|
// Ignore errors as the tab may be closed already.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _onTabRemoved(tabId: number): Promise<void> {
|
private async _onTabRemoved(tabId: number): Promise<void> {
|
||||||
|
const pendingConnection = this._pendingTabSelection.get(tabId)?.connection;
|
||||||
|
if (pendingConnection) {
|
||||||
|
this._pendingTabSelection.delete(tabId);
|
||||||
|
pendingConnection.close('Browser tab closed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this._connectedTabId !== tabId)
|
if (this._connectedTabId !== tabId)
|
||||||
return;
|
return;
|
||||||
this._activeConnection?.close('Browser tab closed');
|
this._activeConnection?.close('Browser tab closed');
|
||||||
@@ -110,14 +173,49 @@ class TabShareExtension {
|
|||||||
this._connectedTabId = null;
|
this._connectedTabId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _onTabUpdated(tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab): Promise<void> {
|
private _onTabActivated(activeInfo: chrome.tabs.TabActiveInfo) {
|
||||||
if (changeInfo.status === 'complete' && this._connectedTabId === tabId)
|
for (const [tabId, pending] of this._pendingTabSelection) {
|
||||||
await this._setConnectedTabId(tabId);
|
if (tabId === activeInfo.tabId) {
|
||||||
|
if (pending.timerId) {
|
||||||
|
clearTimeout(pending.timerId);
|
||||||
|
pending.timerId = undefined;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!pending.timerId) {
|
||||||
|
pending.timerId = setTimeout(() => {
|
||||||
|
const existed = this._pendingTabSelection.delete(tabId);
|
||||||
|
if (existed) {
|
||||||
|
pending.connection.close('Tab has been inactive for 5 seconds');
|
||||||
|
chrome.tabs.sendMessage(tabId, { type: 'connectionTimeout' });
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onTabUpdated(tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) {
|
||||||
|
if (this._connectedTabId === tabId)
|
||||||
|
void this._setConnectedTabId(tabId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _getTabs(): Promise<chrome.tabs.Tab[]> {
|
private async _getTabs(): Promise<chrome.tabs.Tab[]> {
|
||||||
const tabs = await chrome.tabs.query({});
|
const tabs = await chrome.tabs.query({});
|
||||||
return tabs;
|
return tabs.filter(tab => tab.url && !['chrome:', 'edge:', 'devtools:'].some(scheme => tab.url!.startsWith(scheme)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _onActionClicked(): Promise<void> {
|
||||||
|
await chrome.tabs.create({
|
||||||
|
url: chrome.runtime.getURL('status.html'),
|
||||||
|
active: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _disconnect(): Promise<void> {
|
||||||
|
this._activeConnection?.close('User disconnected');
|
||||||
|
this._activeConnection = undefined;
|
||||||
|
await this._setConnectedTabId(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,172 +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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface TabInfo {
|
|
||||||
id: number;
|
|
||||||
windowId: number;
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
favIconUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
class ConnectPage {
|
|
||||||
private _tabList: HTMLElement;
|
|
||||||
private _tabListContainer: HTMLElement;
|
|
||||||
private _statusContainer: HTMLElement;
|
|
||||||
private _selectedTab: TabInfo | undefined;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this._tabList = document.getElementById('tab-list')!;
|
|
||||||
this._tabListContainer = document.getElementById('tab-list-container')!;
|
|
||||||
this._statusContainer = document.getElementById('status-container') as HTMLElement;
|
|
||||||
this._addButtonHandlers();
|
|
||||||
void this._loadTabs();
|
|
||||||
}
|
|
||||||
|
|
||||||
private _addButtonHandlers() {
|
|
||||||
const continueBtn = document.getElementById('continue-btn') as HTMLButtonElement;
|
|
||||||
const rejectBtn = document.getElementById('reject-btn') as HTMLButtonElement;
|
|
||||||
const buttonRow = document.querySelector('.button-row') as HTMLElement;
|
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const mcpRelayUrl = params.get('mcpRelayUrl');
|
|
||||||
|
|
||||||
if (!mcpRelayUrl) {
|
|
||||||
buttonRow.style.display = 'none';
|
|
||||||
this._showStatus('error', 'Missing mcpRelayUrl parameter in URL.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let clientInfo = 'unknown';
|
|
||||||
try {
|
|
||||||
const client = JSON.parse(params.get('client') || '{}');
|
|
||||||
clientInfo = `${client.name}/${client.version}`;
|
|
||||||
} catch (e) {
|
|
||||||
this._showStatus('error', 'Failed to parse client version.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._showStatus('connecting', `MCP client "${clientInfo}" is trying to connect. Do you want to continue?`);
|
|
||||||
|
|
||||||
rejectBtn.addEventListener('click', async () => {
|
|
||||||
buttonRow.style.display = 'none';
|
|
||||||
this._tabListContainer.style.display = 'none';
|
|
||||||
this._showStatus('error', 'Connection rejected. This tab can be closed.');
|
|
||||||
});
|
|
||||||
|
|
||||||
continueBtn.addEventListener('click', async () => {
|
|
||||||
buttonRow.style.display = 'none';
|
|
||||||
this._tabListContainer.style.display = 'none';
|
|
||||||
try {
|
|
||||||
const selectedTab = this._selectedTab;
|
|
||||||
if (!selectedTab) {
|
|
||||||
this._showStatus('error', 'Tab not selected.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const response = await chrome.runtime.sendMessage({
|
|
||||||
type: 'connectToMCPRelay',
|
|
||||||
mcpRelayUrl,
|
|
||||||
tabId: selectedTab.id,
|
|
||||||
windowId: selectedTab.windowId,
|
|
||||||
});
|
|
||||||
if (response?.success)
|
|
||||||
this._showStatus('connected', `MCP client "${clientInfo}" connected.`);
|
|
||||||
else
|
|
||||||
this._showStatus('error', response?.error || `MCP client "${clientInfo}" failed to connect.`);
|
|
||||||
} catch (e) {
|
|
||||||
this._showStatus('error', `MCP client "${clientInfo}" failed to connect: ${e}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _loadTabs(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
|
|
||||||
if (response.success)
|
|
||||||
this._populateTabList(response.tabs, response.currentTabId);
|
|
||||||
else
|
|
||||||
this._showStatus('error', 'Failed to load tabs: ' + response.error);
|
|
||||||
} catch (error) {
|
|
||||||
this._showStatus('error', 'Failed to communicate with background script: ' + error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _populateTabList(tabs: TabInfo[], currentTabId: number): void {
|
|
||||||
this._tabList.replaceChildren();
|
|
||||||
this._selectedTab = tabs.find(tab => tab.id === currentTabId);
|
|
||||||
|
|
||||||
tabs.forEach((tab, index) => {
|
|
||||||
const tabElement = this._createTabElement(tab);
|
|
||||||
this._tabList.appendChild(tabElement);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private _createTabElement(tab: TabInfo): HTMLElement {
|
|
||||||
const disabled = tab.url.startsWith('chrome://');
|
|
||||||
|
|
||||||
const tabInfoDiv = document.createElement('div');
|
|
||||||
tabInfoDiv.className = 'tab-info';
|
|
||||||
tabInfoDiv.style.padding = '5px';
|
|
||||||
if (disabled)
|
|
||||||
tabInfoDiv.style.opacity = '0.5';
|
|
||||||
|
|
||||||
const radioButton = document.createElement('input');
|
|
||||||
radioButton.type = 'radio';
|
|
||||||
radioButton.name = 'tab-selection';
|
|
||||||
radioButton.checked = tab.id === this._selectedTab?.id;
|
|
||||||
radioButton.id = `tab-${tab.id}`;
|
|
||||||
radioButton.addEventListener('change', e => {
|
|
||||||
if (radioButton.checked)
|
|
||||||
this._selectedTab = tab;
|
|
||||||
});
|
|
||||||
if (disabled)
|
|
||||||
radioButton.disabled = true;
|
|
||||||
|
|
||||||
const favicon = document.createElement('img');
|
|
||||||
favicon.className = 'tab-favicon';
|
|
||||||
if (tab.favIconUrl)
|
|
||||||
favicon.src = tab.favIconUrl;
|
|
||||||
favicon.alt = '';
|
|
||||||
favicon.style.height = '16px';
|
|
||||||
favicon.style.width = '16px';
|
|
||||||
|
|
||||||
const title = document.createElement('span');
|
|
||||||
title.style.paddingLeft = '5px';
|
|
||||||
title.className = 'tab-title';
|
|
||||||
title.textContent = tab.title || 'Untitled';
|
|
||||||
|
|
||||||
const url = document.createElement('span');
|
|
||||||
url.style.paddingLeft = '5px';
|
|
||||||
url.className = 'tab-url';
|
|
||||||
url.textContent = tab.url;
|
|
||||||
|
|
||||||
tabInfoDiv.appendChild(radioButton);
|
|
||||||
tabInfoDiv.appendChild(favicon);
|
|
||||||
tabInfoDiv.appendChild(title);
|
|
||||||
tabInfoDiv.appendChild(url);
|
|
||||||
|
|
||||||
return tabInfoDiv;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _showStatus(type: 'connected' | 'error' | 'connecting', message: string) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.className = `status ${type}`;
|
|
||||||
div.textContent = message;
|
|
||||||
this._statusContainer.replaceChildren(div);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
new ConnectPage();
|
|
||||||
@@ -41,11 +41,18 @@ export class RelayConnection {
|
|||||||
private _ws: WebSocket;
|
private _ws: WebSocket;
|
||||||
private _eventListener: (source: chrome.debugger.DebuggerSession, method: string, params: any) => void;
|
private _eventListener: (source: chrome.debugger.DebuggerSession, method: string, params: any) => void;
|
||||||
private _detachListener: (source: chrome.debugger.Debuggee, reason: string) => void;
|
private _detachListener: (source: chrome.debugger.Debuggee, reason: string) => void;
|
||||||
|
private _tabPromise: Promise<void>;
|
||||||
|
private _tabPromiseResolve!: () => void;
|
||||||
|
private _closed = false;
|
||||||
|
|
||||||
constructor(ws: WebSocket, tabId: number) {
|
onclose?: () => void;
|
||||||
this._debuggee = { tabId };
|
|
||||||
|
constructor(ws: WebSocket) {
|
||||||
|
this._debuggee = { };
|
||||||
|
this._tabPromise = new Promise(resolve => this._tabPromiseResolve = resolve);
|
||||||
this._ws = ws;
|
this._ws = ws;
|
||||||
this._ws.onmessage = this._onMessage.bind(this);
|
this._ws.onmessage = this._onMessage.bind(this);
|
||||||
|
this._ws.onclose = () => this._onClose();
|
||||||
// Store listeners for cleanup
|
// Store listeners for cleanup
|
||||||
this._eventListener = this._onDebuggerEvent.bind(this);
|
this._eventListener = this._onDebuggerEvent.bind(this);
|
||||||
this._detachListener = this._onDebuggerDetach.bind(this);
|
this._detachListener = this._onDebuggerDetach.bind(this);
|
||||||
@@ -53,14 +60,27 @@ export class RelayConnection {
|
|||||||
chrome.debugger.onDetach.addListener(this._detachListener);
|
chrome.debugger.onDetach.addListener(this._detachListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
close(message: string): void {
|
// Either setTabId or close is called after creating the connection.
|
||||||
chrome.debugger.onEvent.removeListener(this._eventListener);
|
setTabId(tabId: number): void {
|
||||||
chrome.debugger.onDetach.removeListener(this._detachListener);
|
this._debuggee = { tabId };
|
||||||
this._ws.close(1000, message);
|
this._tabPromiseResolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _detachDebugger(): Promise<void> {
|
close(message: string): void {
|
||||||
await chrome.debugger.detach(this._debuggee);
|
this._ws.close(1000, message);
|
||||||
|
// ws.onclose is called asynchronously, so we call it here to avoid forwarding
|
||||||
|
// CDP events to the closed connection.
|
||||||
|
this._onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onClose() {
|
||||||
|
if (this._closed)
|
||||||
|
return;
|
||||||
|
this._closed = true;
|
||||||
|
chrome.debugger.onEvent.removeListener(this._eventListener);
|
||||||
|
chrome.debugger.onDetach.removeListener(this._detachListener);
|
||||||
|
chrome.debugger.detach(this._debuggee).catch(() => {});
|
||||||
|
this.onclose?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _onDebuggerEvent(source: chrome.debugger.DebuggerSession, method: string, params: any): void {
|
private _onDebuggerEvent(source: chrome.debugger.DebuggerSession, method: string, params: any): void {
|
||||||
@@ -81,13 +101,7 @@ export class RelayConnection {
|
|||||||
private _onDebuggerDetach(source: chrome.debugger.Debuggee, reason: string): void {
|
private _onDebuggerDetach(source: chrome.debugger.Debuggee, reason: string): void {
|
||||||
if (source.tabId !== this._debuggee.tabId)
|
if (source.tabId !== this._debuggee.tabId)
|
||||||
return;
|
return;
|
||||||
this._sendMessage({
|
this.close(`Debugger detached: ${reason}`);
|
||||||
method: 'detachedFromTab',
|
|
||||||
params: {
|
|
||||||
tabId: this._debuggee.tabId,
|
|
||||||
reason,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this._debuggee = { };
|
this._debuggee = { };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,9 +135,8 @@ export class RelayConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _handleCommand(message: ProtocolCommand): Promise<any> {
|
private async _handleCommand(message: ProtocolCommand): Promise<any> {
|
||||||
if (!this._debuggee.tabId)
|
|
||||||
throw new Error('No tab is connected. Please go to the Playwright MCP extension and select the tab you want to connect to.');
|
|
||||||
if (message.method === 'attachToTab') {
|
if (message.method === 'attachToTab') {
|
||||||
|
await this._tabPromise;
|
||||||
debugLog('Attaching debugger to tab:', this._debuggee);
|
debugLog('Attaching debugger to tab:', this._debuggee);
|
||||||
await chrome.debugger.attach(this._debuggee, '1.3');
|
await chrome.debugger.attach(this._debuggee, '1.3');
|
||||||
const result: any = await chrome.debugger.sendCommand(this._debuggee, 'Target.getTargetInfo');
|
const result: any = await chrome.debugger.sendCommand(this._debuggee, 'Target.getTargetInfo');
|
||||||
@@ -131,10 +144,8 @@ export class RelayConnection {
|
|||||||
targetInfo: result?.targetInfo,
|
targetInfo: result?.targetInfo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (message.method === 'detachFromTab') {
|
if (!this._debuggee.tabId)
|
||||||
debugLog('Detaching debugger from tab:', this._debuggee);
|
throw new Error('No tab is connected. Please go to the Playwright MCP extension and select the tab you want to connect to.');
|
||||||
return await this._detachDebugger();
|
|
||||||
}
|
|
||||||
if (message.method === 'forwardCDPCommand') {
|
if (message.method === 'forwardCDPCommand') {
|
||||||
const { sessionId, method, params } = message.params;
|
const { sessionId, method, params } = message.params;
|
||||||
debugLog('CDP command:', method, params);
|
debugLog('CDP command:', method, params);
|
||||||
@@ -161,6 +172,7 @@ export class RelayConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _sendMessage(message: any): void {
|
private _sendMessage(message: any): void {
|
||||||
this._ws.send(JSON.stringify(message));
|
if (this._ws.readyState === WebSocket.OPEN)
|
||||||
|
this._ws.send(JSON.stringify(message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
206
extension/src/ui/connect.css
Normal file
206
extension/src/ui/connect.css
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base styles */
|
||||||
|
.app-container {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: #1f2328;
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrapper {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Banner */
|
||||||
|
.status-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner {
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner.connected {
|
||||||
|
color: #1f2328;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner.connected::before {
|
||||||
|
content: "\2705";
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner.error {
|
||||||
|
color: #1f2328;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner.error::before {
|
||||||
|
content: "\274C";
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.button-container {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-right: 8px;
|
||||||
|
min-width: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.primary {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
color: #3c4043;
|
||||||
|
border: 1px solid #dadce0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.primary:hover {
|
||||||
|
background-color: #f1f3f4;
|
||||||
|
border-color: #dadce0;
|
||||||
|
box-shadow: 0 1px 2px 0 rgba(60,64,67,.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.default {
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
color: #24292f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.default:hover {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.reject {
|
||||||
|
background-color: #da3633;
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid #da3633;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.reject:hover {
|
||||||
|
background-color: #c73836;
|
||||||
|
border-color: #c73836;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab selection */
|
||||||
|
.tab-section-title {
|
||||||
|
padding-left: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #656d76;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.selected {
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-radio {
|
||||||
|
margin-right: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-favicon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-right: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-title {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1f2328;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-url {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #656d76;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Link-style button */
|
||||||
|
.link-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #0066cc;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
@@ -17,18 +17,13 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Playwright MCP extension</title>
|
<title>Playwright MCP extension</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="../../icons/icon-32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="../../icons/icon-16.png">
|
||||||
|
<link rel="stylesheet" href="connect.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h3>Playwright MCP extension</h3>
|
<div id="root"></div>
|
||||||
<div id="status-container"></div>
|
<script type="module" src="connect.tsx"></script>
|
||||||
<div class="button-row">
|
|
||||||
<button id="continue-btn">Continue</button>
|
|
||||||
<button id="reject-btn">Reject</button>
|
|
||||||
</div>
|
|
||||||
<div id="tab-list-container">
|
|
||||||
<h4>Select page to expose to MCP server:</h4>
|
|
||||||
<div id="tab-list"></div>
|
|
||||||
</div>
|
|
||||||
<script src="lib/connect.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
255
extension/src/ui/connect.tsx
Normal file
255
extension/src/ui/connect.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* 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 React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { Button, TabItem } from './tabItem.js';
|
||||||
|
import type { TabInfo } from './tabItem.js';
|
||||||
|
|
||||||
|
type Status =
|
||||||
|
| { type: 'connecting'; message: string }
|
||||||
|
| { type: 'connected'; message: string }
|
||||||
|
| { type: 'error'; message: string }
|
||||||
|
| { type: 'error'; versionMismatch: { pwMcpVersion: string; extensionVersion: string; downloadUrl: string } };
|
||||||
|
|
||||||
|
const ConnectApp: React.FC = () => {
|
||||||
|
const [tabs, setTabs] = useState<TabInfo[]>([]);
|
||||||
|
const [status, setStatus] = useState<Status | null>(null);
|
||||||
|
const [showButtons, setShowButtons] = useState(true);
|
||||||
|
const [showTabList, setShowTabList] = useState(true);
|
||||||
|
const [clientInfo, setClientInfo] = useState('unknown');
|
||||||
|
const [mcpRelayUrl, setMcpRelayUrl] = useState('');
|
||||||
|
const [newTab, setNewTab] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const relayUrl = params.get('mcpRelayUrl');
|
||||||
|
|
||||||
|
if (!relayUrl) {
|
||||||
|
setShowButtons(false);
|
||||||
|
setStatus({ type: 'error', message: 'Missing mcpRelayUrl parameter in URL.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMcpRelayUrl(relayUrl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = JSON.parse(params.get('client') || '{}');
|
||||||
|
const info = `${client.name}/${client.version}`;
|
||||||
|
setClientInfo(info);
|
||||||
|
setStatus({
|
||||||
|
type: 'connecting',
|
||||||
|
message: `🎭 Playwright MCP started from "${info}" is trying to connect. Do you want to continue?`
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setStatus({ type: 'error', message: 'Failed to parse client version.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pwMcpVersion = params.get('pwMcpVersion');
|
||||||
|
const extensionVersion = chrome.runtime.getManifest().version;
|
||||||
|
if (pwMcpVersion !== extensionVersion) {
|
||||||
|
const downloadUrl = params.get('downloadUrl') || `https://github.com/microsoft/playwright-mcp/releases/download/v${extensionVersion}/playwright-mcp-extension-v${extensionVersion}.zip`;
|
||||||
|
setShowButtons(false);
|
||||||
|
setShowTabList(false);
|
||||||
|
setStatus({
|
||||||
|
type: 'error',
|
||||||
|
versionMismatch: {
|
||||||
|
pwMcpVersion: pwMcpVersion || 'unknown',
|
||||||
|
extensionVersion,
|
||||||
|
downloadUrl
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void connectToMCPRelay(relayUrl);
|
||||||
|
|
||||||
|
// If this is a browser_navigate command, hide the tab list and show simple allow/reject
|
||||||
|
if (params.get('newTab') === 'true') {
|
||||||
|
setNewTab(true);
|
||||||
|
setShowTabList(false);
|
||||||
|
} else {
|
||||||
|
void loadTabs();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleReject = useCallback((message: string) => {
|
||||||
|
setShowButtons(false);
|
||||||
|
setShowTabList(false);
|
||||||
|
setStatus({ type: 'error', message });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const connectToMCPRelay = useCallback(async (mcpRelayUrl: string) => {
|
||||||
|
|
||||||
|
const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl });
|
||||||
|
if (!response.success)
|
||||||
|
handleReject(response.error);
|
||||||
|
}, [handleReject]);
|
||||||
|
|
||||||
|
const loadTabs = useCallback(async () => {
|
||||||
|
const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
|
||||||
|
if (response.success)
|
||||||
|
setTabs(response.tabs);
|
||||||
|
else
|
||||||
|
setStatus({ type: 'error', message: 'Failed to load tabs: ' + response.error });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleConnectToTab = useCallback(async (tab?: TabInfo) => {
|
||||||
|
setShowButtons(false);
|
||||||
|
setShowTabList(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await chrome.runtime.sendMessage({
|
||||||
|
type: 'connectToTab',
|
||||||
|
mcpRelayUrl,
|
||||||
|
tabId: tab?.id,
|
||||||
|
windowId: tab?.windowId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response?.success) {
|
||||||
|
setStatus({ type: 'connected', message: `MCP client "${clientInfo}" connected.` });
|
||||||
|
} else {
|
||||||
|
setStatus({
|
||||||
|
type: 'error',
|
||||||
|
message: response?.error || `MCP client "${clientInfo}" failed to connect.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setStatus({
|
||||||
|
type: 'error',
|
||||||
|
message: `MCP client "${clientInfo}" failed to connect: ${e}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [clientInfo, mcpRelayUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = (message: any) => {
|
||||||
|
if (message.type === 'connectionTimeout')
|
||||||
|
handleReject('Connection timed out.');
|
||||||
|
};
|
||||||
|
chrome.runtime.onMessage.addListener(listener);
|
||||||
|
return () => {
|
||||||
|
chrome.runtime.onMessage.removeListener(listener);
|
||||||
|
};
|
||||||
|
}, [handleReject]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='app-container'>
|
||||||
|
<div className='content-wrapper'>
|
||||||
|
{status && (
|
||||||
|
<div className='status-container'>
|
||||||
|
<StatusBanner status={status} />
|
||||||
|
{showButtons && (
|
||||||
|
<div className='button-container'>
|
||||||
|
{newTab ? (
|
||||||
|
<>
|
||||||
|
<Button variant='primary' onClick={() => handleConnectToTab()}>
|
||||||
|
Allow
|
||||||
|
</Button>
|
||||||
|
<Button variant='reject' onClick={() => handleReject('Connection rejected. This tab can be closed.')}>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button variant='reject' onClick={() => handleReject('Connection rejected. This tab can be closed.')}>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showTabList && (
|
||||||
|
<div>
|
||||||
|
<div className='tab-section-title'>
|
||||||
|
Select page to expose to MCP server:
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<TabItem
|
||||||
|
key={tab.id}
|
||||||
|
tab={tab}
|
||||||
|
button={
|
||||||
|
<Button variant='primary' onClick={() => handleConnectToTab(tab)}>
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const VersionMismatchError: React.FC<{ pwMcpVersion: string; extensionVersion: string; downloadUrl: string }> = ({ pwMcpVersion, extensionVersion, downloadUrl }) => {
|
||||||
|
const readmeUrl = 'https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md';
|
||||||
|
|
||||||
|
const handleDownloadAndOpenExtensions = () => {
|
||||||
|
// Start download
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = downloadUrl;
|
||||||
|
link.download = `playwright-mcp-extension-v${extensionVersion}.zip`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
chrome.tabs.query({ active: true, currentWindow: true }, tabs => {
|
||||||
|
if (tabs[0]?.id)
|
||||||
|
chrome.tabs.update(tabs[0].id, { url: 'chrome://extensions/' });
|
||||||
|
});
|
||||||
|
}, 1000); // Wait 1 second for download to initiate
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
Incompatible Playwright MCP version: {pwMcpVersion} (extension version: {extensionVersion}).{' '}
|
||||||
|
<button
|
||||||
|
onClick={handleDownloadAndOpenExtensions}
|
||||||
|
className='link-button'
|
||||||
|
>Click here</button> to download the matching extension, then drag and drop it into the Chrome Extensions page.{' '}
|
||||||
|
See <a href={readmeUrl} target='_blank' rel='noopener noreferrer'>installation instructions</a> for more details.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatusBanner: React.FC<{ status: Status }> = ({ status }) => {
|
||||||
|
return (
|
||||||
|
<div className={`status-banner ${status.type}`}>
|
||||||
|
{'versionMismatch' in status ? (
|
||||||
|
<VersionMismatchError
|
||||||
|
pwMcpVersion={status.versionMismatch.pwMcpVersion}
|
||||||
|
extensionVersion={status.versionMismatch.extensionVersion}
|
||||||
|
downloadUrl={status.versionMismatch.downloadUrl}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
status.message
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize the React app
|
||||||
|
const container = document.getElementById('root');
|
||||||
|
if (container) {
|
||||||
|
const root = createRoot(container);
|
||||||
|
root.render(<ConnectApp />);
|
||||||
|
}
|
||||||
13
extension/src/ui/status.html
Normal file
13
extension/src/ui/status.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Playwright MCP Bridge Status</title>
|
||||||
|
<link rel="stylesheet" href="connect.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script src="status.tsx" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
110
extension/src/ui/status.tsx
Normal file
110
extension/src/ui/status.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* 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 React, { useState, useEffect } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { Button, TabItem } from './tabItem.js';
|
||||||
|
|
||||||
|
import type { TabInfo } from './tabItem.js';
|
||||||
|
|
||||||
|
interface ConnectionStatus {
|
||||||
|
isConnected: boolean;
|
||||||
|
connectedTabId: number | null;
|
||||||
|
connectedTab?: TabInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatusApp: React.FC = () => {
|
||||||
|
const [status, setStatus] = useState<ConnectionStatus>({
|
||||||
|
isConnected: false,
|
||||||
|
connectedTabId: null
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadStatus = async () => {
|
||||||
|
// Get current connection status from background script
|
||||||
|
const { connectedTabId } = await chrome.runtime.sendMessage({ type: 'getConnectionStatus' });
|
||||||
|
if (connectedTabId) {
|
||||||
|
const tab = await chrome.tabs.get(connectedTabId);
|
||||||
|
setStatus({
|
||||||
|
isConnected: true,
|
||||||
|
connectedTabId,
|
||||||
|
connectedTab: {
|
||||||
|
id: tab.id!,
|
||||||
|
windowId: tab.windowId!,
|
||||||
|
title: tab.title!,
|
||||||
|
url: tab.url!,
|
||||||
|
favIconUrl: tab.favIconUrl
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setStatus({
|
||||||
|
isConnected: false,
|
||||||
|
connectedTabId: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openConnectedTab = async () => {
|
||||||
|
if (!status.connectedTabId)
|
||||||
|
return;
|
||||||
|
await chrome.tabs.update(status.connectedTabId, { active: true });
|
||||||
|
window.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const disconnect = async () => {
|
||||||
|
await chrome.runtime.sendMessage({ type: 'disconnect' });
|
||||||
|
window.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='app-container'>
|
||||||
|
<div className='content-wrapper'>
|
||||||
|
{status.isConnected && status.connectedTab ? (
|
||||||
|
<div>
|
||||||
|
<div className='tab-section-title'>
|
||||||
|
Page with connected MCP client:
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<TabItem
|
||||||
|
tab={status.connectedTab}
|
||||||
|
button={
|
||||||
|
<Button variant='primary' onClick={disconnect}>
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
onClick={openConnectedTab}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='status-banner'>
|
||||||
|
No MCP clients are currently connected.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize the React app
|
||||||
|
const container = document.getElementById('root');
|
||||||
|
if (container) {
|
||||||
|
const root = createRoot(container);
|
||||||
|
root.render(<StatusApp />);
|
||||||
|
}
|
||||||
67
extension/src/ui/tabItem.tsx
Normal file
67
extension/src/ui/tabItem.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* 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 React from 'react';
|
||||||
|
|
||||||
|
export interface TabInfo {
|
||||||
|
id: number;
|
||||||
|
windowId: number;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
favIconUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button: React.FC<{ variant: 'primary' | 'default' | 'reject'; onClick: () => void; children: React.ReactNode }> = ({
|
||||||
|
variant,
|
||||||
|
onClick,
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<button className={`button ${variant}`} onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export interface TabItemProps {
|
||||||
|
tab: TabInfo;
|
||||||
|
onClick?: () => void;
|
||||||
|
button?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TabItem: React.FC<TabItemProps> = ({
|
||||||
|
tab,
|
||||||
|
onClick,
|
||||||
|
button
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className='tab-item' onClick={onClick} style={onClick ? { cursor: 'pointer' } : undefined}>
|
||||||
|
<img
|
||||||
|
src={tab.favIconUrl || 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><rect width="16" height="16" fill="%23f6f8fa"/></svg>'}
|
||||||
|
alt=''
|
||||||
|
className='tab-favicon'
|
||||||
|
/>
|
||||||
|
<div className='tab-content'>
|
||||||
|
<div className='tab-title'>
|
||||||
|
{tab.title || 'Untitled'}
|
||||||
|
</div>
|
||||||
|
<div className='tab-url'>{tab.url}</div>
|
||||||
|
</div>
|
||||||
|
{button}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
4
extension/src/ui/tsconfig.json
Normal file
4
extension/src/ui/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Help VSCode to find right tsconfig file.
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.ui.json"
|
||||||
|
}
|
||||||
251
extension/tests/extension.spec.ts
Normal file
251
extension/tests/extension.spec.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
/**
|
||||||
|
* 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 { fileURLToPath } from 'url';
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
import packageJSON from '../../package.json' assert { type: 'json' };
|
||||||
|
import { test as base, expect } from '../../tests/fixtures.js';
|
||||||
|
|
||||||
|
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import type { BrowserContext } from 'playwright';
|
||||||
|
import type { StartClient } from '../../tests/fixtures.js';
|
||||||
|
|
||||||
|
type BrowserWithExtension = {
|
||||||
|
userDataDir: string;
|
||||||
|
launch: (mode?: 'disable-extension') => Promise<BrowserContext>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TestFixtures = {
|
||||||
|
browserWithExtension: BrowserWithExtension,
|
||||||
|
pathToExtension: string,
|
||||||
|
useShortConnectionTimeout: (timeoutMs: number) => void
|
||||||
|
};
|
||||||
|
|
||||||
|
const test = base.extend<TestFixtures>({
|
||||||
|
pathToExtension: async ({}, use) => {
|
||||||
|
await use(fileURLToPath(new URL('../dist', import.meta.url)));
|
||||||
|
},
|
||||||
|
|
||||||
|
browserWithExtension: async ({ mcpBrowser, pathToExtension }, use, testInfo) => {
|
||||||
|
// The flags no longer work in Chrome since
|
||||||
|
// https://chromium.googlesource.com/chromium/src/+/290ed8046692651ce76088914750cb659b65fb17%5E%21/chrome/browser/extensions/extension_service.cc?pli=1#
|
||||||
|
test.skip('chromium' !== mcpBrowser, '--load-extension is not supported for official builds of Chromium');
|
||||||
|
|
||||||
|
let browserContext: BrowserContext | undefined;
|
||||||
|
const userDataDir = testInfo.outputPath('extension-user-data-dir');
|
||||||
|
await use({
|
||||||
|
userDataDir,
|
||||||
|
launch: async (mode?: 'disable-extension') => {
|
||||||
|
browserContext = await chromium.launchPersistentContext(userDataDir, {
|
||||||
|
channel: mcpBrowser,
|
||||||
|
// Opening the browser singleton only works in headed.
|
||||||
|
headless: false,
|
||||||
|
// Automation disables singleton browser process behavior, which is necessary for the extension.
|
||||||
|
ignoreDefaultArgs: ['--enable-automation'],
|
||||||
|
args: mode === 'disable-extension' ? [] : [
|
||||||
|
`--disable-extensions-except=${pathToExtension}`,
|
||||||
|
`--load-extension=${pathToExtension}`,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// for manifest v3:
|
||||||
|
let [serviceWorker] = browserContext.serviceWorkers();
|
||||||
|
if (!serviceWorker)
|
||||||
|
serviceWorker = await browserContext.waitForEvent('serviceworker');
|
||||||
|
|
||||||
|
return browserContext;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await browserContext?.close();
|
||||||
|
},
|
||||||
|
|
||||||
|
useShortConnectionTimeout: async ({}, use) => {
|
||||||
|
await use((timeoutMs: number) => {
|
||||||
|
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = timeoutMs.toString();
|
||||||
|
});
|
||||||
|
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
async function startAndCallConnectTool(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> {
|
||||||
|
const { client } = await startClient({
|
||||||
|
args: [`--connect-tool`],
|
||||||
|
config: {
|
||||||
|
browser: {
|
||||||
|
userDataDir: browserWithExtension.userDataDir,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_connect',
|
||||||
|
arguments: {
|
||||||
|
name: 'extension'
|
||||||
|
}
|
||||||
|
})).toHaveResponse({
|
||||||
|
result: 'Successfully changed connection method.',
|
||||||
|
});
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startWithExtensionFlag(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> {
|
||||||
|
const { client } = await startClient({
|
||||||
|
args: [`--extension`],
|
||||||
|
config: {
|
||||||
|
browser: {
|
||||||
|
userDataDir: browserWithExtension.userDataDir,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
const testWithOldVersion = test.extend({
|
||||||
|
pathToExtension: async ({}, use, testInfo) => {
|
||||||
|
const extensionDir = testInfo.outputPath('extension');
|
||||||
|
const oldPath = fileURLToPath(new URL('../dist', import.meta.url));
|
||||||
|
|
||||||
|
await fs.promises.cp(oldPath, extensionDir, { recursive: true });
|
||||||
|
const manifestPath = path.join(extensionDir, 'manifest.json');
|
||||||
|
const manifest = JSON.parse(await fs.promises.readFile(manifestPath, 'utf8'));
|
||||||
|
manifest.version = '0.0.1';
|
||||||
|
await fs.promises.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
||||||
|
|
||||||
|
await use(extensionDir);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [mode, startClientMethod] of [
|
||||||
|
['connect-tool', startAndCallConnectTool],
|
||||||
|
['extension-flag', startWithExtensionFlag],
|
||||||
|
] as const) {
|
||||||
|
|
||||||
|
test(`navigate with extension (${mode})`, async ({ browserWithExtension, startClient, server }) => {
|
||||||
|
const browserContext = await browserWithExtension.launch();
|
||||||
|
|
||||||
|
const client = await startClientMethod(browserWithExtension, startClient);
|
||||||
|
|
||||||
|
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
|
||||||
|
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigateResponse = client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectorPage = await confirmationPagePromise;
|
||||||
|
// For browser_navigate command, the UI shows Allow/Reject buttons instead of tab selector
|
||||||
|
await selectorPage.getByRole('button', { name: 'Allow' }).click();
|
||||||
|
|
||||||
|
expect(await navigateResponse).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`snapshot of an existing page (${mode})`, async ({ browserWithExtension, startClient, server }) => {
|
||||||
|
const browserContext = await browserWithExtension.launch();
|
||||||
|
|
||||||
|
const page = await browserContext.newPage();
|
||||||
|
await page.goto(server.HELLO_WORLD);
|
||||||
|
|
||||||
|
// Another empty page.
|
||||||
|
await browserContext.newPage();
|
||||||
|
expect(browserContext.pages()).toHaveLength(3);
|
||||||
|
|
||||||
|
const client = await startClientMethod(browserWithExtension, startClient);
|
||||||
|
expect(browserContext.pages()).toHaveLength(3);
|
||||||
|
|
||||||
|
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
|
||||||
|
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigateResponse = client.callTool({
|
||||||
|
name: 'browser_snapshot',
|
||||||
|
arguments: { },
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectorPage = await confirmationPagePromise;
|
||||||
|
expect(browserContext.pages()).toHaveLength(4);
|
||||||
|
|
||||||
|
await selectorPage.locator('.tab-item', { hasText: 'Title' }).getByRole('button', { name: 'Connect' }).click();
|
||||||
|
|
||||||
|
expect(await navigateResponse).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(browserContext.pages()).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`extension not installed timeout (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
|
||||||
|
useShortConnectionTimeout(100);
|
||||||
|
|
||||||
|
const browserContext = await browserWithExtension.launch();
|
||||||
|
|
||||||
|
const client = await startClientMethod(browserWithExtension, startClient);
|
||||||
|
|
||||||
|
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
|
||||||
|
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
})).toHaveResponse({
|
||||||
|
result: expect.stringContaining('Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed.'),
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await confirmationPagePromise;
|
||||||
|
});
|
||||||
|
|
||||||
|
testWithOldVersion(`extension version mismatch (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
|
||||||
|
useShortConnectionTimeout(500);
|
||||||
|
|
||||||
|
// Prelaunch the browser, so that it is properly closed after the test.
|
||||||
|
const browserContext = await browserWithExtension.launch();
|
||||||
|
|
||||||
|
const client = await startClientMethod(browserWithExtension, startClient);
|
||||||
|
|
||||||
|
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
|
||||||
|
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigateResponse = client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmationPage = await confirmationPagePromise;
|
||||||
|
await expect(confirmationPage.locator('.status-banner')).toHaveText(`Incompatible Playwright MCP version: ${packageJSON.version} (extension version: 0.0.1). Click here to download the matching extension, then drag and drop it into the Chrome Extensions page. See installation instructions for more details.`);
|
||||||
|
|
||||||
|
expect(await navigateResponse).toHaveResponse({
|
||||||
|
result: expect.stringContaining('Extension connection timeout.'),
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const downloadPromise = confirmationPage.waitForEvent('download');
|
||||||
|
await confirmationPage.locator('.status-banner').getByRole('button', { name: 'Click here' }).click();
|
||||||
|
const download = await downloadPromise;
|
||||||
|
expect(download.url()).toBe(`https://github.com/microsoft/playwright-mcp/releases/download/v0.0.1/playwright-mcp-extension-v0.0.1.zip`);
|
||||||
|
await download.cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
@@ -6,10 +6,16 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"outDir": "./lib",
|
"outDir": "./dist/lib",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
|
"types": ["chrome"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "react"
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src",
|
"src",
|
||||||
],
|
],
|
||||||
|
"exclude": [
|
||||||
|
"src/ui",
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
19
extension/tsconfig.ui.json
Normal file
19
extension/tsconfig.ui.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"strict": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "./lib",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": ["chrome"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "react",
|
||||||
|
"noEmit": true,
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/ui",
|
||||||
|
],
|
||||||
|
}
|
||||||
54
extension/vite.config.ts
Normal file
54
extension/vite.config.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* 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 { resolve } from 'path';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
viteStaticCopy({
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
src: '../../icons/*',
|
||||||
|
dest: 'icons'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '../../manifest.json',
|
||||||
|
dest: '.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
],
|
||||||
|
root: resolve(__dirname, 'src/ui'),
|
||||||
|
build: {
|
||||||
|
outDir: resolve(__dirname, 'dist/'),
|
||||||
|
emptyOutDir: false,
|
||||||
|
minify: false,
|
||||||
|
rollupOptions: {
|
||||||
|
input: ['src/ui/connect.html', 'src/ui/status.html'],
|
||||||
|
output: {
|
||||||
|
manualChunks: undefined,
|
||||||
|
entryFileNames: 'lib/ui/[name].js',
|
||||||
|
chunkFileNames: 'lib/ui/[name].js',
|
||||||
|
assetFileNames: 'lib/ui/[name].[ext]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
506
package-lock.json
generated
506
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.32",
|
"version": "0.0.34",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.32",
|
"version": "0.0.34",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.16.0",
|
"@modelcontextprotocol/sdk": "^1.16.0",
|
||||||
@@ -14,9 +14,10 @@
|
|||||||
"debug": "^4.4.1",
|
"debug": "^4.4.1",
|
||||||
"dotenv": "^17.2.0",
|
"dotenv": "^17.2.0",
|
||||||
"mime": "^4.0.7",
|
"mime": "^4.0.7",
|
||||||
"playwright": "1.55.0-alpha-1752701791000",
|
"playwright": "1.55.0-alpha-2025-08-12",
|
||||||
"playwright-core": "1.55.0-alpha-1752701791000",
|
"playwright-core": "1.55.0-alpha-2025-08-12",
|
||||||
"ws": "^8.18.1",
|
"ws": "^8.18.1",
|
||||||
|
"zod": "^3.24.1",
|
||||||
"zod-to-json-schema": "^3.24.4"
|
"zod-to-json-schema": "^3.24.4"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -26,15 +27,15 @@
|
|||||||
"@anthropic-ai/sdk": "^0.57.0",
|
"@anthropic-ai/sdk": "^0.57.0",
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.19.0",
|
||||||
"@playwright/test": "1.55.0-alpha-1752701791000",
|
"@playwright/test": "1.55.0-alpha-2025-08-12",
|
||||||
"@stylistic/eslint-plugin": "^3.0.1",
|
"@stylistic/eslint-plugin": "^3.0.1",
|
||||||
"@types/chrome": "^0.0.315",
|
|
||||||
"@types/debug": "^4.1.12",
|
"@types/debug": "^4.1.12",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||||
"@typescript-eslint/parser": "^8.26.1",
|
"@typescript-eslint/parser": "^8.26.1",
|
||||||
"@typescript-eslint/utils": "^8.26.1",
|
"@typescript-eslint/utils": "^8.26.1",
|
||||||
|
"esbuild": "^0.20.1",
|
||||||
"eslint": "^9.19.0",
|
"eslint": "^9.19.0",
|
||||||
"eslint-plugin-import": "^2.31.0",
|
"eslint-plugin-import": "^2.31.0",
|
||||||
"eslint-plugin-notice": "^1.0.0",
|
"eslint-plugin-notice": "^1.0.0",
|
||||||
@@ -55,6 +56,397 @@
|
|||||||
"anthropic-ai-sdk": "bin/cli"
|
"anthropic-ai-sdk": "bin/cli"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@eslint-community/eslint-utils": {
|
"node_modules/@eslint-community/eslint-utils": {
|
||||||
"version": "4.5.1",
|
"version": "4.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.5.1.tgz",
|
||||||
@@ -311,13 +703,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.55.0-alpha-1752701791000",
|
"version": "1.55.0-alpha-2025-08-12",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0-alpha-1752701791000.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0-alpha-2025-08-12.tgz",
|
||||||
"integrity": "sha512-mnitdsjXKPyKTjQQDJ78Or1xZSGcaoDzZVD/0BWFCvygn3nyNmGmiias/Mlfvzvgz9UWBbPeZYxU/bd2Lu+OrQ==",
|
"integrity": "sha512-lyq9MDSd4UcOWx5292AYLBfbYYCstg8iLb+lk6LdM69ps6bwmPloZO3Ol3JO3FQQ63qAuW9VD0w+ZYKL0lRmQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.55.0-alpha-1752701791000"
|
"playwright": "1.55.0-alpha-2025-08-12"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -379,17 +771,6 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/@types/debug": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||||
@@ -401,33 +782,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||||
"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,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -1469,6 +1826,45 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.20.2",
|
||||||
|
"@esbuild/android-arm": "0.20.2",
|
||||||
|
"@esbuild/android-arm64": "0.20.2",
|
||||||
|
"@esbuild/android-x64": "0.20.2",
|
||||||
|
"@esbuild/darwin-arm64": "0.20.2",
|
||||||
|
"@esbuild/darwin-x64": "0.20.2",
|
||||||
|
"@esbuild/freebsd-arm64": "0.20.2",
|
||||||
|
"@esbuild/freebsd-x64": "0.20.2",
|
||||||
|
"@esbuild/linux-arm": "0.20.2",
|
||||||
|
"@esbuild/linux-arm64": "0.20.2",
|
||||||
|
"@esbuild/linux-ia32": "0.20.2",
|
||||||
|
"@esbuild/linux-loong64": "0.20.2",
|
||||||
|
"@esbuild/linux-mips64el": "0.20.2",
|
||||||
|
"@esbuild/linux-ppc64": "0.20.2",
|
||||||
|
"@esbuild/linux-riscv64": "0.20.2",
|
||||||
|
"@esbuild/linux-s390x": "0.20.2",
|
||||||
|
"@esbuild/linux-x64": "0.20.2",
|
||||||
|
"@esbuild/netbsd-x64": "0.20.2",
|
||||||
|
"@esbuild/openbsd-x64": "0.20.2",
|
||||||
|
"@esbuild/sunos-x64": "0.20.2",
|
||||||
|
"@esbuild/win32-arm64": "0.20.2",
|
||||||
|
"@esbuild/win32-ia32": "0.20.2",
|
||||||
|
"@esbuild/win32-x64": "0.20.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escape-html": {
|
"node_modules/escape-html": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
@@ -3349,12 +3745,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.55.0-alpha-1752701791000",
|
"version": "1.55.0-alpha-2025-08-12",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0-alpha-1752701791000.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0-alpha-2025-08-12.tgz",
|
||||||
"integrity": "sha512-PA3TvDz7uQ+Pde0uaii5/WpU5vntRJsYFsaSPoBzywIqzYFO1ugk1ZZ0q6z4/xHq0ha1UClvsv3P77B+u1fi+w==",
|
"integrity": "sha512-daZPM5gX0VTG6ae3/qOpEKc9NxoavkM2lfL0UIzTG0k+yK8ZeSPYo63iewZhVANsWRm0BT+XQ1NniAUOwWQ+xA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.55.0-alpha-1752701791000"
|
"playwright-core": "1.55.0-alpha-2025-08-12"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -3367,9 +3763,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.55.0-alpha-1752701791000",
|
"version": "1.55.0-alpha-2025-08-12",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0-alpha-1752701791000.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0-alpha-2025-08-12.tgz",
|
||||||
"integrity": "sha512-mQhzhjJMiqnGNnYZv7M4yk1OcNTt1E72jrTLO7EqZuoeat4+qpcU0/mbK+RcTEass5a9YheoVFh6OIhruFMGVg==",
|
"integrity": "sha512-4uxOd9xmeF6gqdsORzzlXd7p795vcACOiAGVHHEiTuFXsD83LYH+0C/SYLWB0Z+fAq4LdKGsy0qEfTm0JkY8Ig==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
|
|||||||
18
package.json
18
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.32",
|
"version": "0.0.34",
|
||||||
"description": "Playwright Tools for MCP",
|
"description": "Playwright Tools for MCP",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -17,18 +17,17 @@
|
|||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"build:extension": "tsc --project extension",
|
"lint": "npm run update-readme && npm run check-deps && eslint . && tsc --noEmit",
|
||||||
"lint": "npm run update-readme && eslint . && tsc --noEmit",
|
|
||||||
"lint-fix": "eslint . --fix",
|
"lint-fix": "eslint . --fix",
|
||||||
|
"check-deps": "node utils/check-deps.js",
|
||||||
"update-readme": "node utils/update-readme.js",
|
"update-readme": "node utils/update-readme.js",
|
||||||
"watch": "tsc --watch",
|
"watch": "tsc --watch",
|
||||||
"watch:extension": "tsc --watch --project extension",
|
|
||||||
"test": "playwright test",
|
"test": "playwright test",
|
||||||
"ctest": "playwright test --project=chrome",
|
"ctest": "playwright test --project=chrome",
|
||||||
"ftest": "playwright test --project=firefox",
|
"ftest": "playwright test --project=firefox",
|
||||||
"wtest": "playwright test --project=webkit",
|
"wtest": "playwright test --project=webkit",
|
||||||
"run-server": "node lib/browserServer.js",
|
"run-server": "node lib/browserServer.js",
|
||||||
"clean": "rm -rf lib extension/lib",
|
"clean": "rm -rf lib",
|
||||||
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
|
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -44,24 +43,25 @@
|
|||||||
"debug": "^4.4.1",
|
"debug": "^4.4.1",
|
||||||
"dotenv": "^17.2.0",
|
"dotenv": "^17.2.0",
|
||||||
"mime": "^4.0.7",
|
"mime": "^4.0.7",
|
||||||
"playwright": "1.55.0-alpha-1752701791000",
|
"playwright": "1.55.0-alpha-2025-08-12",
|
||||||
"playwright-core": "1.55.0-alpha-1752701791000",
|
"playwright-core": "1.55.0-alpha-2025-08-12",
|
||||||
"ws": "^8.18.1",
|
"ws": "^8.18.1",
|
||||||
|
"zod": "^3.24.1",
|
||||||
"zod-to-json-schema": "^3.24.4"
|
"zod-to-json-schema": "^3.24.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.57.0",
|
"@anthropic-ai/sdk": "^0.57.0",
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.19.0",
|
||||||
"@playwright/test": "1.55.0-alpha-1752701791000",
|
"@playwright/test": "1.55.0-alpha-2025-08-12",
|
||||||
"@stylistic/eslint-plugin": "^3.0.1",
|
"@stylistic/eslint-plugin": "^3.0.1",
|
||||||
"@types/chrome": "^0.0.315",
|
|
||||||
"@types/debug": "^4.1.12",
|
"@types/debug": "^4.1.12",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||||
"@typescript-eslint/parser": "^8.26.1",
|
"@typescript-eslint/parser": "^8.26.1",
|
||||||
"@typescript-eslint/utils": "^8.26.1",
|
"@typescript-eslint/utils": "^8.26.1",
|
||||||
|
"esbuild": "^0.20.1",
|
||||||
"eslint": "^9.19.0",
|
"eslint": "^9.19.0",
|
||||||
"eslint-plugin-import": "^2.31.0",
|
"eslint-plugin-import": "^2.31.0",
|
||||||
"eslint-plugin-notice": "^1.0.0",
|
"eslint-plugin-notice": "^1.0.0",
|
||||||
|
|||||||
@@ -22,12 +22,10 @@ export default defineConfig<TestOptions>({
|
|||||||
testDir: './tests',
|
testDir: './tests',
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
workers: process.env.CI ? 2 : undefined,
|
||||||
workers: process.env.CI ? 1 : undefined,
|
|
||||||
reporter: 'list',
|
reporter: 'list',
|
||||||
projects: [
|
projects: [
|
||||||
{ name: 'chrome' },
|
{ name: 'chrome' },
|
||||||
{ name: 'msedge', use: { mcpBrowser: 'msedge' } },
|
|
||||||
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
||||||
...process.env.MCP_IN_DOCKER ? [{
|
...process.env.MCP_IN_DOCKER ? [{
|
||||||
name: 'chromium-docker',
|
name: 'chromium-docker',
|
||||||
@@ -39,5 +37,6 @@ export default defineConfig<TestOptions>({
|
|||||||
}] : [],
|
}] : [],
|
||||||
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
||||||
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
||||||
|
... process.platform === 'win32' ? [{ name: 'msedge', use: { mcpBrowser: 'msedge' } }] : [],
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
7
src/DEPS.list
Normal file
7
src/DEPS.list
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[*]
|
||||||
|
./tools/
|
||||||
|
./mcp/
|
||||||
|
./utils/
|
||||||
|
|
||||||
|
[program.ts]
|
||||||
|
***
|
||||||
172
src/actions.d.ts
vendored
Normal file
172
src/actions.d.ts
vendored
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Point = { x: number, y: number };
|
||||||
|
|
||||||
|
export type ActionName =
|
||||||
|
'check' |
|
||||||
|
'click' |
|
||||||
|
'closePage' |
|
||||||
|
'fill' |
|
||||||
|
'navigate' |
|
||||||
|
'openPage' |
|
||||||
|
'press' |
|
||||||
|
'select' |
|
||||||
|
'uncheck' |
|
||||||
|
'setInputFiles' |
|
||||||
|
'assertText' |
|
||||||
|
'assertValue' |
|
||||||
|
'assertChecked' |
|
||||||
|
'assertVisible' |
|
||||||
|
'assertSnapshot';
|
||||||
|
|
||||||
|
export type ActionBase = {
|
||||||
|
name: ActionName,
|
||||||
|
signals: Signal[],
|
||||||
|
ariaSnapshot?: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActionWithSelector = ActionBase & {
|
||||||
|
selector: string,
|
||||||
|
ref?: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClickAction = ActionWithSelector & {
|
||||||
|
name: 'click',
|
||||||
|
button: 'left' | 'middle' | 'right',
|
||||||
|
modifiers: number,
|
||||||
|
clickCount: number,
|
||||||
|
position?: Point,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CheckAction = ActionWithSelector & {
|
||||||
|
name: 'check',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UncheckAction = ActionWithSelector & {
|
||||||
|
name: 'uncheck',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FillAction = ActionWithSelector & {
|
||||||
|
name: 'fill',
|
||||||
|
text: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NavigateAction = ActionBase & {
|
||||||
|
name: 'navigate',
|
||||||
|
url: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpenPageAction = ActionBase & {
|
||||||
|
name: 'openPage',
|
||||||
|
url: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClosesPageAction = ActionBase & {
|
||||||
|
name: 'closePage',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PressAction = ActionWithSelector & {
|
||||||
|
name: 'press',
|
||||||
|
key: string,
|
||||||
|
modifiers: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SelectAction = ActionWithSelector & {
|
||||||
|
name: 'select',
|
||||||
|
options: string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SetInputFilesAction = ActionWithSelector & {
|
||||||
|
name: 'setInputFiles',
|
||||||
|
files: string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssertTextAction = ActionWithSelector & {
|
||||||
|
name: 'assertText',
|
||||||
|
text: string,
|
||||||
|
substring: boolean,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssertValueAction = ActionWithSelector & {
|
||||||
|
name: 'assertValue',
|
||||||
|
value: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssertCheckedAction = ActionWithSelector & {
|
||||||
|
name: 'assertChecked',
|
||||||
|
checked: boolean,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssertVisibleAction = ActionWithSelector & {
|
||||||
|
name: 'assertVisible',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssertSnapshotAction = ActionWithSelector & {
|
||||||
|
name: 'assertSnapshot',
|
||||||
|
ariaSnapshot: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction | AssertTextAction | AssertValueAction | AssertCheckedAction | AssertVisibleAction | AssertSnapshotAction;
|
||||||
|
export type AssertAction = AssertCheckedAction | AssertValueAction | AssertTextAction | AssertVisibleAction | AssertSnapshotAction;
|
||||||
|
export type PerformOnRecordAction = ClickAction | CheckAction | UncheckAction | PressAction | SelectAction;
|
||||||
|
|
||||||
|
// Signals.
|
||||||
|
|
||||||
|
export type BaseSignal = {
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NavigationSignal = BaseSignal & {
|
||||||
|
name: 'navigation',
|
||||||
|
url: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PopupSignal = BaseSignal & {
|
||||||
|
name: 'popup',
|
||||||
|
popupAlias: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DownloadSignal = BaseSignal & {
|
||||||
|
name: 'download',
|
||||||
|
downloadAlias: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DialogSignal = BaseSignal & {
|
||||||
|
name: 'dialog',
|
||||||
|
dialogAlias: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Signal = NavigationSignal | PopupSignal | DownloadSignal | DialogSignal;
|
||||||
|
|
||||||
|
export type FrameDescription = {
|
||||||
|
pageGuid: string;
|
||||||
|
pageAlias: string;
|
||||||
|
framePath: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActionInContext = {
|
||||||
|
frame: FrameDescription;
|
||||||
|
description?: string;
|
||||||
|
action: Action;
|
||||||
|
startTime: number;
|
||||||
|
endTime?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SignalInContext = {
|
||||||
|
frame: FrameDescription;
|
||||||
|
signal: Signal;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
@@ -14,46 +14,52 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'node:fs';
|
import fs from 'fs';
|
||||||
import net from 'node:net';
|
import net from 'net';
|
||||||
import path from 'node:path';
|
import path from 'path';
|
||||||
import os from 'node:os';
|
|
||||||
|
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
|
// @ts-ignore
|
||||||
import { logUnhandledError, testDebug } from './log.js';
|
import { registryDirectory } from 'playwright-core/lib/server/registry/index';
|
||||||
|
// @ts-ignore
|
||||||
|
import { startTraceViewerServer } from 'playwright-core/lib/server';
|
||||||
|
import { logUnhandledError, testDebug } from './utils/log.js';
|
||||||
|
import { createHash } from './utils/guid.js';
|
||||||
|
import { outputFile } from './config.js';
|
||||||
|
|
||||||
import type { FullConfig } from './config.js';
|
import type { FullConfig } from './config.js';
|
||||||
|
|
||||||
export function contextFactory(browserConfig: FullConfig['browser']): BrowserContextFactory {
|
export function contextFactory(config: FullConfig): BrowserContextFactory {
|
||||||
if (browserConfig.remoteEndpoint)
|
if (config.browser.remoteEndpoint)
|
||||||
return new RemoteContextFactory(browserConfig);
|
return new RemoteContextFactory(config);
|
||||||
if (browserConfig.cdpEndpoint)
|
if (config.browser.cdpEndpoint)
|
||||||
return new CdpContextFactory(browserConfig);
|
return new CdpContextFactory(config);
|
||||||
if (browserConfig.isolated)
|
if (config.browser.isolated)
|
||||||
return new IsolatedContextFactory(browserConfig);
|
return new IsolatedContextFactory(config);
|
||||||
return new PersistentContextFactory(browserConfig);
|
return new PersistentContextFactory(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ClientInfo = { name?: string, version?: string, rootPath?: string };
|
||||||
|
|
||||||
export interface BrowserContextFactory {
|
export interface BrowserContextFactory {
|
||||||
createContext(clientInfo: { name: string, version: string }): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
|
createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
class BaseContextFactory implements BrowserContextFactory {
|
class BaseContextFactory implements BrowserContextFactory {
|
||||||
readonly browserConfig: FullConfig['browser'];
|
readonly config: FullConfig;
|
||||||
|
private _logName: string;
|
||||||
protected _browserPromise: Promise<playwright.Browser> | undefined;
|
protected _browserPromise: Promise<playwright.Browser> | undefined;
|
||||||
readonly name: string;
|
|
||||||
|
|
||||||
constructor(name: string, browserConfig: FullConfig['browser']) {
|
constructor(name: string, config: FullConfig) {
|
||||||
this.name = name;
|
this._logName = name;
|
||||||
this.browserConfig = browserConfig;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async _obtainBrowser(): Promise<playwright.Browser> {
|
protected async _obtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
|
||||||
if (this._browserPromise)
|
if (this._browserPromise)
|
||||||
return this._browserPromise;
|
return this._browserPromise;
|
||||||
testDebug(`obtain browser (${this.name})`);
|
testDebug(`obtain browser (${this._logName})`);
|
||||||
this._browserPromise = this._doObtainBrowser();
|
this._browserPromise = this._doObtainBrowser(clientInfo);
|
||||||
void this._browserPromise.then(browser => {
|
void this._browserPromise.then(browser => {
|
||||||
browser.on('disconnected', () => {
|
browser.on('disconnected', () => {
|
||||||
this._browserPromise = undefined;
|
this._browserPromise = undefined;
|
||||||
@@ -64,13 +70,13 @@ class BaseContextFactory implements BrowserContextFactory {
|
|||||||
return this._browserPromise;
|
return this._browserPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async _doObtainBrowser(): Promise<playwright.Browser> {
|
protected async _doObtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
|
||||||
throw new Error('Not implemented');
|
throw new Error('Not implemented');
|
||||||
}
|
}
|
||||||
|
|
||||||
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||||
testDebug(`create browser context (${this.name})`);
|
testDebug(`create browser context (${this._logName})`);
|
||||||
const browser = await this._obtainBrowser();
|
const browser = await this._obtainBrowser(clientInfo);
|
||||||
const browserContext = await this._doCreateContext(browser);
|
const browserContext = await this._doCreateContext(browser);
|
||||||
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
|
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
|
||||||
}
|
}
|
||||||
@@ -80,27 +86,28 @@ class BaseContextFactory implements BrowserContextFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser) {
|
private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser) {
|
||||||
testDebug(`close browser context (${this.name})`);
|
testDebug(`close browser context (${this._logName})`);
|
||||||
if (browser.contexts().length === 1)
|
if (browser.contexts().length === 1)
|
||||||
this._browserPromise = undefined;
|
this._browserPromise = undefined;
|
||||||
await browserContext.close().catch(logUnhandledError);
|
await browserContext.close().catch(logUnhandledError);
|
||||||
if (browser.contexts().length === 0) {
|
if (browser.contexts().length === 0) {
|
||||||
testDebug(`close browser (${this.name})`);
|
testDebug(`close browser (${this._logName})`);
|
||||||
await browser.close().catch(logUnhandledError);
|
await browser.close().catch(logUnhandledError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class IsolatedContextFactory extends BaseContextFactory {
|
class IsolatedContextFactory extends BaseContextFactory {
|
||||||
constructor(browserConfig: FullConfig['browser']) {
|
constructor(config: FullConfig) {
|
||||||
super('isolated', browserConfig);
|
super('isolated', config);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
protected override async _doObtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
|
||||||
await injectCdpPort(this.browserConfig);
|
await injectCdpPort(this.config.browser);
|
||||||
const browserType = playwright[this.browserConfig.browserName];
|
const browserType = playwright[this.config.browser.browserName];
|
||||||
return browserType.launch({
|
return browserType.launch({
|
||||||
...this.browserConfig.launchOptions,
|
tracesDir: await startTraceServer(this.config, clientInfo.rootPath),
|
||||||
|
...this.config.browser.launchOptions,
|
||||||
handleSIGINT: false,
|
handleSIGINT: false,
|
||||||
handleSIGTERM: false,
|
handleSIGTERM: false,
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
@@ -111,35 +118,35 @@ class IsolatedContextFactory extends BaseContextFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||||
return browser.newContext(this.browserConfig.contextOptions);
|
return browser.newContext(this.config.browser.contextOptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class CdpContextFactory extends BaseContextFactory {
|
class CdpContextFactory extends BaseContextFactory {
|
||||||
constructor(browserConfig: FullConfig['browser']) {
|
constructor(config: FullConfig) {
|
||||||
super('cdp', browserConfig);
|
super('cdp', config);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||||
return playwright.chromium.connectOverCDP(this.browserConfig.cdpEndpoint!);
|
return playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint!);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||||
return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
|
return this.config.browser.isolated ? await browser.newContext() : browser.contexts()[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class RemoteContextFactory extends BaseContextFactory {
|
class RemoteContextFactory extends BaseContextFactory {
|
||||||
constructor(browserConfig: FullConfig['browser']) {
|
constructor(config: FullConfig) {
|
||||||
super('remote', browserConfig);
|
super('remote', config);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||||
const url = new URL(this.browserConfig.remoteEndpoint!);
|
const url = new URL(this.config.browser.remoteEndpoint!);
|
||||||
url.searchParams.set('browser', this.browserConfig.browserName);
|
url.searchParams.set('browser', this.config.browser.browserName);
|
||||||
if (this.browserConfig.launchOptions)
|
if (this.config.browser.launchOptions)
|
||||||
url.searchParams.set('launch-options', JSON.stringify(this.browserConfig.launchOptions));
|
url.searchParams.set('launch-options', JSON.stringify(this.config.browser.launchOptions));
|
||||||
return playwright[this.browserConfig.browserName].connect(String(url));
|
return playwright[this.config.browser.browserName].connect(String(url));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||||
@@ -148,27 +155,32 @@ class RemoteContextFactory extends BaseContextFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class PersistentContextFactory implements BrowserContextFactory {
|
class PersistentContextFactory implements BrowserContextFactory {
|
||||||
readonly browserConfig: FullConfig['browser'];
|
readonly config: FullConfig;
|
||||||
|
readonly name = 'persistent';
|
||||||
|
readonly description = 'Create a new persistent browser context';
|
||||||
|
|
||||||
private _userDataDirs = new Set<string>();
|
private _userDataDirs = new Set<string>();
|
||||||
|
|
||||||
constructor(browserConfig: FullConfig['browser']) {
|
constructor(config: FullConfig) {
|
||||||
this.browserConfig = browserConfig;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||||
await injectCdpPort(this.browserConfig);
|
await injectCdpPort(this.config.browser);
|
||||||
testDebug('create browser context (persistent)');
|
testDebug('create browser context (persistent)');
|
||||||
const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir();
|
const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo.rootPath);
|
||||||
|
const tracesDir = await startTraceServer(this.config, clientInfo.rootPath);
|
||||||
|
|
||||||
this._userDataDirs.add(userDataDir);
|
this._userDataDirs.add(userDataDir);
|
||||||
testDebug('lock user data dir', userDataDir);
|
testDebug('lock user data dir', userDataDir);
|
||||||
|
|
||||||
const browserType = playwright[this.browserConfig.browserName];
|
const browserType = playwright[this.config.browser.browserName];
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
try {
|
try {
|
||||||
const browserContext = await browserType.launchPersistentContext(userDataDir, {
|
const browserContext = await browserType.launchPersistentContext(userDataDir, {
|
||||||
...this.browserConfig.launchOptions,
|
tracesDir,
|
||||||
...this.browserConfig.contextOptions,
|
...this.config.browser.launchOptions,
|
||||||
|
...this.config.browser.contextOptions,
|
||||||
handleSIGINT: false,
|
handleSIGINT: false,
|
||||||
handleSIGTERM: false,
|
handleSIGTERM: false,
|
||||||
});
|
});
|
||||||
@@ -196,17 +208,12 @@ class PersistentContextFactory implements BrowserContextFactory {
|
|||||||
testDebug('close browser context complete (persistent)');
|
testDebug('close browser context complete (persistent)');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _createUserDataDir() {
|
private async _createUserDataDir(rootPath: string | undefined) {
|
||||||
let cacheDirectory: string;
|
const dir = process.env.PWMCP_PROFILES_DIR_FOR_TEST ?? registryDirectory;
|
||||||
if (process.platform === 'linux')
|
const browserToken = this.config.browser.launchOptions?.channel ?? this.config.browser?.browserName;
|
||||||
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
// Hesitant putting hundreds of files into the user's workspace, so using it for hashing instead.
|
||||||
else if (process.platform === 'darwin')
|
const rootPathToken = rootPath ? `-${createHash(rootPath)}` : '';
|
||||||
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
|
const result = path.join(dir, `mcp-${browserToken}${rootPathToken}`);
|
||||||
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 });
|
await fs.promises.mkdir(result, { recursive: true });
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -227,3 +234,16 @@ async function findFreePort(): Promise<number> {
|
|||||||
server.on('error', reject);
|
server.on('error', reject);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function startTraceServer(config: FullConfig, rootPath: string | undefined): Promise<string | undefined> {
|
||||||
|
if (!config.saveTrace)
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
const tracesDir = await outputFile(config, rootPath, `traces-${Date.now()}`);
|
||||||
|
const server = await startTraceViewerServer();
|
||||||
|
const urlPrefix = server.urlPrefix('human-readable');
|
||||||
|
const url = urlPrefix + '/trace/index.html?trace=' + tracesDir + '/trace.json';
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('\nTrace viewer listening on ' + url);
|
||||||
|
return tracesDir;
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,56 +14,75 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
import { FullConfig } from './config.js';
|
import { FullConfig } from './config.js';
|
||||||
import { Context } from './context.js';
|
import { Context } from './context.js';
|
||||||
import { logUnhandledError } from './log.js';
|
import { logUnhandledError } from './utils/log.js';
|
||||||
import { Response } from './response.js';
|
import { Response } from './response.js';
|
||||||
import { SessionLog } from './sessionLog.js';
|
import { SessionLog } from './sessionLog.js';
|
||||||
import { filteredTools } from './tools.js';
|
import { filteredTools } from './tools.js';
|
||||||
import { packageJSON } from './package.js';
|
import { toMcpTool } from './mcp/tool.js';
|
||||||
|
|
||||||
|
import type { Tool } from './tools/tool.js';
|
||||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||||
import type * as mcpServer from './mcp/server.js';
|
import type * as mcpServer from './mcp/server.js';
|
||||||
import type { ServerBackend } from './mcp/server.js';
|
import type { ServerBackend } from './mcp/server.js';
|
||||||
import type { Tool } from './tools/tool.js';
|
|
||||||
|
|
||||||
export class BrowserServerBackend implements ServerBackend {
|
export class BrowserServerBackend implements ServerBackend {
|
||||||
name = 'Playwright';
|
|
||||||
version = packageJSON.version;
|
|
||||||
onclose?: () => void;
|
|
||||||
|
|
||||||
private _tools: Tool[];
|
private _tools: Tool[];
|
||||||
private _context: Context;
|
private _context: Context | undefined;
|
||||||
private _sessionLog: SessionLog | undefined;
|
private _sessionLog: SessionLog | undefined;
|
||||||
|
private _config: FullConfig;
|
||||||
|
private _browserContextFactory: BrowserContextFactory;
|
||||||
|
|
||||||
constructor(config: FullConfig, browserContextFactory: BrowserContextFactory) {
|
constructor(config: FullConfig, factory: BrowserContextFactory) {
|
||||||
|
this._config = config;
|
||||||
|
this._browserContextFactory = factory;
|
||||||
this._tools = filteredTools(config);
|
this._tools = filteredTools(config);
|
||||||
this._context = new Context(this._tools, config, browserContextFactory);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize() {
|
async initialize(server: mcpServer.Server, clientVersion: mcpServer.ClientVersion, roots: mcpServer.Root[]): Promise<void> {
|
||||||
this._sessionLog = this._context.config.saveSession ? await SessionLog.create(this._context.config) : undefined;
|
let rootPath: string | undefined;
|
||||||
|
if (roots.length > 0) {
|
||||||
|
const firstRootUri = roots[0]?.uri;
|
||||||
|
const url = firstRootUri ? new URL(firstRootUri) : undefined;
|
||||||
|
rootPath = url ? fileURLToPath(url) : undefined;
|
||||||
|
}
|
||||||
|
this._sessionLog = this._config.saveSession ? await SessionLog.create(this._config, rootPath) : undefined;
|
||||||
|
this._context = new Context({
|
||||||
|
tools: this._tools,
|
||||||
|
config: this._config,
|
||||||
|
browserContextFactory: this._browserContextFactory,
|
||||||
|
sessionLog: this._sessionLog,
|
||||||
|
clientInfo: { ...clientVersion, rootPath },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
tools(): mcpServer.ToolSchema<any>[] {
|
async listTools(): Promise<mcpServer.Tool[]> {
|
||||||
return this._tools.map(tool => tool.schema);
|
return this._tools.map(tool => toMcpTool(tool.schema));
|
||||||
}
|
}
|
||||||
|
|
||||||
async callTool(schema: mcpServer.ToolSchema<any>, parsedArguments: any) {
|
async callTool(name: string, rawArguments: mcpServer.CallToolRequest['params']['arguments']) {
|
||||||
const response = new Response(this._context, schema.name, parsedArguments);
|
const tool = this._tools.find(tool => tool.schema.name === name)!;
|
||||||
const tool = this._tools.find(tool => tool.schema.name === schema.name)!;
|
if (!tool)
|
||||||
await tool.handle(this._context, parsedArguments, response);
|
throw new Error(`Tool "${name}" not found`);
|
||||||
if (this._sessionLog)
|
const parsedArguments = tool.schema.inputSchema.parse(rawArguments || {});
|
||||||
await this._sessionLog.log(response);
|
const context = this._context!;
|
||||||
return await response.serialize();
|
const response = new Response(context, name, parsedArguments);
|
||||||
}
|
context.setRunningTool(name);
|
||||||
|
try {
|
||||||
serverInitialized(version: mcpServer.ClientVersion | undefined) {
|
await tool.handle(context, parsedArguments, response);
|
||||||
this._context.clientVersion = version;
|
await response.finish();
|
||||||
|
this._sessionLog?.logResponse(response);
|
||||||
|
} catch (error: any) {
|
||||||
|
response.addError(String(error));
|
||||||
|
} finally {
|
||||||
|
context.setRunningTool(undefined);
|
||||||
|
}
|
||||||
|
return response.serialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
serverClosed() {
|
serverClosed() {
|
||||||
this.onclose?.();
|
void this._context?.dispose().catch(logUnhandledError);
|
||||||
void this._context.dispose().catch(logUnhandledError);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import fs from 'fs';
|
|||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { devices } from 'playwright';
|
import { devices } from 'playwright';
|
||||||
import { sanitizeForFilePath } from './tools/utils.js';
|
import { sanitizeForFilePath } from './utils/fileUtils.js';
|
||||||
|
|
||||||
import type { Config, ToolCapability } from '../config.js';
|
import type { Config, ToolCapability } from '../config.js';
|
||||||
import type { BrowserContextOptions, LaunchOptions } from 'playwright';
|
import type { BrowserContextOptions, LaunchOptions } from 'playwright';
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ const defaultConfig: FullConfig = {
|
|||||||
blockedOrigins: undefined,
|
blockedOrigins: undefined,
|
||||||
},
|
},
|
||||||
server: {},
|
server: {},
|
||||||
outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())),
|
saveTrace: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
type BrowserUserConfig = NonNullable<Config['browser']>;
|
type BrowserUserConfig = NonNullable<Config['browser']>;
|
||||||
@@ -79,7 +80,7 @@ export type FullConfig = Config & {
|
|||||||
contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
|
contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
|
||||||
},
|
},
|
||||||
network: NonNullable<Config['network']>,
|
network: NonNullable<Config['network']>,
|
||||||
outputDir: string;
|
saveTrace: boolean;
|
||||||
server: NonNullable<Config['server']>,
|
server: NonNullable<Config['server']>,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,9 +96,6 @@ export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConf
|
|||||||
result = mergeConfig(result, configInFile);
|
result = mergeConfig(result, configInFile);
|
||||||
result = mergeConfig(result, envOverrides);
|
result = mergeConfig(result, envOverrides);
|
||||||
result = mergeConfig(result, cliOverrides);
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,10 +238,14 @@ async function loadConfig(configFile: string | undefined): Promise<Config> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function outputFile(config: FullConfig, name: string): Promise<string> {
|
export async function outputFile(config: FullConfig, rootPath: string | undefined, name: string): Promise<string> {
|
||||||
await fs.promises.mkdir(config.outputDir, { recursive: true });
|
const outputDir = config.outputDir
|
||||||
|
?? (rootPath ? path.join(rootPath, '.playwright-mcp') : undefined)
|
||||||
|
?? path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString()));
|
||||||
|
|
||||||
|
await fs.promises.mkdir(outputDir, { recursive: true });
|
||||||
const fileName = sanitizeForFilePath(name);
|
const fileName = sanitizeForFilePath(name);
|
||||||
return path.join(config.outputDir, fileName);
|
return path.join(outputDir, fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
|
function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
|
||||||
|
|||||||
128
src/context.ts
128
src/context.ts
@@ -17,31 +17,49 @@
|
|||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
|
|
||||||
import { logUnhandledError } from './log.js';
|
import { logUnhandledError } from './utils/log.js';
|
||||||
import { Tab } from './tab.js';
|
import { Tab } from './tab.js';
|
||||||
|
import { outputFile } from './config.js';
|
||||||
|
|
||||||
import type { Tool } from './tools/tool.js';
|
|
||||||
import type { FullConfig } from './config.js';
|
import type { FullConfig } from './config.js';
|
||||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
import type { Tool } from './tools/tool.js';
|
||||||
|
import type { BrowserContextFactory, ClientInfo } from './browserContextFactory.js';
|
||||||
|
import type * as actions from './actions.js';
|
||||||
|
import type { SessionLog } from './sessionLog.js';
|
||||||
|
|
||||||
const testDebug = debug('pw:mcp:test');
|
const testDebug = debug('pw:mcp:test');
|
||||||
|
|
||||||
|
type ContextOptions = {
|
||||||
|
tools: Tool[];
|
||||||
|
config: FullConfig;
|
||||||
|
browserContextFactory: BrowserContextFactory;
|
||||||
|
sessionLog: SessionLog | undefined;
|
||||||
|
clientInfo: ClientInfo;
|
||||||
|
};
|
||||||
|
|
||||||
export class Context {
|
export class Context {
|
||||||
readonly tools: Tool[];
|
readonly tools: Tool[];
|
||||||
readonly config: FullConfig;
|
readonly config: FullConfig;
|
||||||
|
readonly sessionLog: SessionLog | undefined;
|
||||||
|
readonly options: ContextOptions;
|
||||||
private _browserContextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> | undefined;
|
private _browserContextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> | undefined;
|
||||||
private _browserContextFactory: BrowserContextFactory;
|
private _browserContextFactory: BrowserContextFactory;
|
||||||
private _tabs: Tab[] = [];
|
private _tabs: Tab[] = [];
|
||||||
private _currentTab: Tab | undefined;
|
private _currentTab: Tab | undefined;
|
||||||
clientVersion: { name: string; version: string; } | undefined;
|
private _clientInfo: ClientInfo;
|
||||||
|
|
||||||
private static _allContexts: Set<Context> = new Set();
|
private static _allContexts: Set<Context> = new Set();
|
||||||
private _closeBrowserContextPromise: Promise<void> | undefined;
|
private _closeBrowserContextPromise: Promise<void> | undefined;
|
||||||
|
private _runningToolName: string | undefined;
|
||||||
|
private _abortController = new AbortController();
|
||||||
|
|
||||||
constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) {
|
constructor(options: ContextOptions) {
|
||||||
this.tools = tools;
|
this.tools = options.tools;
|
||||||
this.config = config;
|
this.config = options.config;
|
||||||
this._browserContextFactory = browserContextFactory;
|
this.sessionLog = options.sessionLog;
|
||||||
|
this.options = options;
|
||||||
|
this._browserContextFactory = options.browserContextFactory;
|
||||||
|
this._clientInfo = options.clientInfo;
|
||||||
testDebug('create context');
|
testDebug('create context');
|
||||||
Context._allContexts.add(this);
|
Context._allContexts.add(this);
|
||||||
}
|
}
|
||||||
@@ -87,30 +105,6 @@ export class Context {
|
|||||||
return this._currentTab!;
|
return this._currentTab!;
|
||||||
}
|
}
|
||||||
|
|
||||||
async listTabsMarkdown(force: boolean = false): Promise<string[]> {
|
|
||||||
if (this._tabs.length === 1 && !force)
|
|
||||||
return [];
|
|
||||||
|
|
||||||
if (!this._tabs.length) {
|
|
||||||
return [
|
|
||||||
'### No open tabs',
|
|
||||||
'Use the "browser_navigate" tool to navigate to a page first.',
|
|
||||||
'',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines: string[] = ['### Open tabs'];
|
|
||||||
for (let i = 0; i < this._tabs.length; i++) {
|
|
||||||
const tab = this._tabs[i];
|
|
||||||
const title = await tab.title();
|
|
||||||
const url = tab.page.url();
|
|
||||||
const current = tab === this._currentTab ? ' (current)' : '';
|
|
||||||
lines.push(`- ${i}:${current} [${title}] (${url})`);
|
|
||||||
}
|
|
||||||
lines.push('');
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
async closeTab(index: number | undefined): Promise<string> {
|
async closeTab(index: number | undefined): Promise<string> {
|
||||||
const tab = index === undefined ? this._currentTab : this._tabs[index];
|
const tab = index === undefined ? this._currentTab : this._tabs[index];
|
||||||
if (!tab)
|
if (!tab)
|
||||||
@@ -120,6 +114,10 @@ export class Context {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async outputFile(name: string): Promise<string> {
|
||||||
|
return outputFile(this.config, this._clientInfo.rootPath, name);
|
||||||
|
}
|
||||||
|
|
||||||
private _onPageCreated(page: playwright.Page) {
|
private _onPageCreated(page: playwright.Page) {
|
||||||
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
|
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
|
||||||
this._tabs.push(tab);
|
this._tabs.push(tab);
|
||||||
@@ -146,6 +144,14 @@ export class Context {
|
|||||||
this._closeBrowserContextPromise = undefined;
|
this._closeBrowserContextPromise = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isRunningTool() {
|
||||||
|
return this._runningToolName !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRunningTool(name: string | undefined) {
|
||||||
|
this._runningToolName = name;
|
||||||
|
}
|
||||||
|
|
||||||
private async _closeBrowserContextImpl() {
|
private async _closeBrowserContextImpl() {
|
||||||
if (!this._browserContextPromise)
|
if (!this._browserContextPromise)
|
||||||
return;
|
return;
|
||||||
@@ -163,6 +169,7 @@ export class Context {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async dispose() {
|
async dispose() {
|
||||||
|
this._abortController.abort('MCP context disposed');
|
||||||
await this.closeBrowserContext();
|
await this.closeBrowserContext();
|
||||||
Context._allContexts.delete(this);
|
Context._allContexts.delete(this);
|
||||||
}
|
}
|
||||||
@@ -195,9 +202,11 @@ export class Context {
|
|||||||
if (this._closeBrowserContextPromise)
|
if (this._closeBrowserContextPromise)
|
||||||
throw new Error('Another browser context is being closed.');
|
throw new Error('Another browser context is being closed.');
|
||||||
// TODO: move to the browser context factory to make it based on isolation mode.
|
// TODO: move to the browser context factory to make it based on isolation mode.
|
||||||
const result = await this._browserContextFactory.createContext(this.clientVersion!);
|
const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal, this._runningToolName);
|
||||||
const { browserContext } = result;
|
const { browserContext } = result;
|
||||||
await this._setupRequestInterception(browserContext);
|
await this._setupRequestInterception(browserContext);
|
||||||
|
if (this.sessionLog)
|
||||||
|
await InputRecorder.create(this, browserContext);
|
||||||
for (const page of browserContext.pages())
|
for (const page of browserContext.pages())
|
||||||
this._onPageCreated(page);
|
this._onPageCreated(page);
|
||||||
browserContext.on('page', page => this._onPageCreated(page));
|
browserContext.on('page', page => this._onPageCreated(page));
|
||||||
@@ -212,3 +221,56 @@ export class Context {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class InputRecorder {
|
||||||
|
private _context: Context;
|
||||||
|
private _browserContext: playwright.BrowserContext;
|
||||||
|
|
||||||
|
private constructor(context: Context, browserContext: playwright.BrowserContext) {
|
||||||
|
this._context = context;
|
||||||
|
this._browserContext = browserContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(context: Context, browserContext: playwright.BrowserContext) {
|
||||||
|
const recorder = new InputRecorder(context, browserContext);
|
||||||
|
await recorder._initialize();
|
||||||
|
return recorder;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _initialize() {
|
||||||
|
const sessionLog = this._context.sessionLog!;
|
||||||
|
await (this._browserContext as any)._enableRecorder({
|
||||||
|
mode: 'recording',
|
||||||
|
recorderMode: 'api',
|
||||||
|
}, {
|
||||||
|
actionAdded: (page: playwright.Page, data: actions.ActionInContext, code: string) => {
|
||||||
|
if (this._context.isRunningTool())
|
||||||
|
return;
|
||||||
|
const tab = Tab.forPage(page);
|
||||||
|
if (tab)
|
||||||
|
sessionLog.logUserAction(data.action, tab, code, false);
|
||||||
|
},
|
||||||
|
actionUpdated: (page: playwright.Page, data: actions.ActionInContext, code: string) => {
|
||||||
|
if (this._context.isRunningTool())
|
||||||
|
return;
|
||||||
|
const tab = Tab.forPage(page);
|
||||||
|
if (tab)
|
||||||
|
sessionLog.logUserAction(data.action, tab, code, true);
|
||||||
|
},
|
||||||
|
signalAdded: (page: playwright.Page, data: actions.SignalInContext) => {
|
||||||
|
if (this._context.isRunningTool())
|
||||||
|
return;
|
||||||
|
if (data.signal.name !== 'navigation')
|
||||||
|
return;
|
||||||
|
const tab = Tab.forPage(page);
|
||||||
|
const navigateAction: actions.Action = {
|
||||||
|
name: 'navigate',
|
||||||
|
url: data.signal.url,
|
||||||
|
signals: [],
|
||||||
|
};
|
||||||
|
if (tab)
|
||||||
|
sessionLog.logUserAction(navigateAction, tab, `await page.goto('${data.signal.url}');`, false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
3
src/extension/DEPS.list
Normal file
3
src/extension/DEPS.list
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[*]
|
||||||
|
../mcp/
|
||||||
|
../utils/
|
||||||
@@ -22,18 +22,20 @@
|
|||||||
* - /extension/guid - Extension connection for chrome.debugger forwarding
|
* - /extension/guid - Extension connection for chrome.debugger forwarding
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import http from 'http';
|
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { WebSocket, WebSocketServer } from 'ws';
|
import http from 'http';
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import * as playwright from 'playwright';
|
import { WebSocket, WebSocketServer } from 'ws';
|
||||||
|
import { httpAddressToString } from '../mcp/http.js';
|
||||||
|
import { logUnhandledError } from '../utils/log.js';
|
||||||
|
import { ManualPromise } from '../mcp/manualPromise.js';
|
||||||
|
import { packageJSON } from '../utils/package.js';
|
||||||
|
|
||||||
|
import type websocket from 'ws';
|
||||||
|
import type { ClientInfo } from '../browserContextFactory.js';
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const { registry } = await import('playwright-core/lib/server/registry/index');
|
const { registry } = await import('playwright-core/lib/server/registry/index');
|
||||||
import { httpAddressToString, startHttpServer } from '../httpServer.js';
|
|
||||||
import { logUnhandledError } from '../log.js';
|
|
||||||
import { ManualPromise } from '../manualPromise.js';
|
|
||||||
import type { BrowserContextFactory } from '../browserContextFactory.js';
|
|
||||||
import type websocket from 'ws';
|
|
||||||
|
|
||||||
const debugLogger = debug('pw:mcp:relay');
|
const debugLogger = debug('pw:mcp:relay');
|
||||||
|
|
||||||
@@ -56,6 +58,7 @@ type CDPResponse = {
|
|||||||
export class CDPRelayServer {
|
export class CDPRelayServer {
|
||||||
private _wsHost: string;
|
private _wsHost: string;
|
||||||
private _browserChannel: string;
|
private _browserChannel: string;
|
||||||
|
private _userDataDir?: string;
|
||||||
private _cdpPath: string;
|
private _cdpPath: string;
|
||||||
private _extensionPath: string;
|
private _extensionPath: string;
|
||||||
private _wss: WebSocketServer;
|
private _wss: WebSocketServer;
|
||||||
@@ -69,9 +72,10 @@ export class CDPRelayServer {
|
|||||||
private _nextSessionId: number = 1;
|
private _nextSessionId: number = 1;
|
||||||
private _extensionConnectionPromise!: ManualPromise<void>;
|
private _extensionConnectionPromise!: ManualPromise<void>;
|
||||||
|
|
||||||
constructor(server: http.Server, browserChannel: string) {
|
constructor(server: http.Server, browserChannel: string, userDataDir?: string) {
|
||||||
this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
|
this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
|
||||||
this._browserChannel = browserChannel;
|
this._browserChannel = browserChannel;
|
||||||
|
this._userDataDir = userDataDir;
|
||||||
|
|
||||||
const uuid = crypto.randomUUID();
|
const uuid = crypto.randomUUID();
|
||||||
this._cdpPath = `/cdp/${uuid}`;
|
this._cdpPath = `/cdp/${uuid}`;
|
||||||
@@ -90,22 +94,35 @@ export class CDPRelayServer {
|
|||||||
return `${this._wsHost}${this._extensionPath}`;
|
return `${this._wsHost}${this._extensionPath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureExtensionConnectionForMCPContext(clientInfo: { name: string, version: string }) {
|
async ensureExtensionConnectionForMCPContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined) {
|
||||||
debugLogger('Ensuring extension connection for MCP context');
|
debugLogger('Ensuring extension connection for MCP context');
|
||||||
if (this._extensionConnection)
|
if (this._extensionConnection)
|
||||||
return;
|
return;
|
||||||
await this._connectBrowser(clientInfo);
|
this._connectBrowser(clientInfo, toolName);
|
||||||
debugLogger('Waiting for incoming extension connection');
|
debugLogger('Waiting for incoming extension connection');
|
||||||
await this._extensionConnectionPromise;
|
await Promise.race([
|
||||||
|
this._extensionConnectionPromise,
|
||||||
|
new Promise((_, reject) => setTimeout(() => {
|
||||||
|
reject(new Error(`Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed. See https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md for installation instructions.`));
|
||||||
|
}, process.env.PWMCP_TEST_CONNECTION_TIMEOUT ? parseInt(process.env.PWMCP_TEST_CONNECTION_TIMEOUT, 10) : 5_000)),
|
||||||
|
new Promise((_, reject) => abortSignal.addEventListener('abort', reject))
|
||||||
|
]);
|
||||||
debugLogger('Extension connection established');
|
debugLogger('Extension connection established');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _connectBrowser(clientInfo: { name: string, version: string }) {
|
private _connectBrowser(clientInfo: ClientInfo, toolName: string | undefined) {
|
||||||
const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
|
const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
|
||||||
// Need to specify "key" in the manifest.json to make the id stable when loading from file.
|
// Need to specify "key" in the manifest.json to make the id stable when loading from file.
|
||||||
const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
||||||
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
|
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
|
||||||
url.searchParams.set('client', JSON.stringify(clientInfo));
|
const client = {
|
||||||
|
name: clientInfo.name,
|
||||||
|
version: clientInfo.version,
|
||||||
|
};
|
||||||
|
url.searchParams.set('client', JSON.stringify(client));
|
||||||
|
url.searchParams.set('pwMcpVersion', packageJSON.version);
|
||||||
|
if (toolName)
|
||||||
|
url.searchParams.set('newTab', String(toolName === 'browser_navigate'));
|
||||||
const href = url.toString();
|
const href = url.toString();
|
||||||
const executableInfo = registry.findExecutable(this._browserChannel);
|
const executableInfo = registry.findExecutable(this._browserChannel);
|
||||||
if (!executableInfo)
|
if (!executableInfo)
|
||||||
@@ -114,7 +131,12 @@ export class CDPRelayServer {
|
|||||||
if (!executablePath)
|
if (!executablePath)
|
||||||
throw new Error(`"${this._browserChannel}" executable not found. Make sure it is installed at a standard location.`);
|
throw new Error(`"${this._browserChannel}" executable not found. Make sure it is installed at a standard location.`);
|
||||||
|
|
||||||
spawn(executablePath, [href], {
|
const args: string[] = [];
|
||||||
|
if (this._userDataDir)
|
||||||
|
args.push(`--user-data-dir=${this._userDataDir}`);
|
||||||
|
args.push(href);
|
||||||
|
|
||||||
|
spawn(executablePath, args, {
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
detached: true,
|
detached: true,
|
||||||
shell: false,
|
shell: false,
|
||||||
@@ -300,51 +322,6 @@ export class CDPRelayServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ExtensionContextFactory implements BrowserContextFactory {
|
|
||||||
private _relay: CDPRelayServer;
|
|
||||||
private _browserPromise: Promise<playwright.Browser> | undefined;
|
|
||||||
|
|
||||||
constructor(relay: CDPRelayServer) {
|
|
||||||
this._relay = relay;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createContext(clientInfo: { name: string, version: string }): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
|
||||||
// First call will establish the connection to the extension.
|
|
||||||
if (!this._browserPromise)
|
|
||||||
this._browserPromise = this._obtainBrowser(clientInfo);
|
|
||||||
const browser = await this._browserPromise;
|
|
||||||
return {
|
|
||||||
browserContext: browser.contexts()[0],
|
|
||||||
close: async () => {
|
|
||||||
debugLogger('close() called for browser context, ignoring');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
clientDisconnected() {
|
|
||||||
this._relay.closeConnections('MCP client disconnected');
|
|
||||||
this._browserPromise = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _obtainBrowser(clientInfo: { name: string, version: string }): Promise<playwright.Browser> {
|
|
||||||
await this._relay.ensureExtensionConnectionForMCPContext(clientInfo);
|
|
||||||
const browser = await playwright.chromium.connectOverCDP(this._relay.cdpEndpoint());
|
|
||||||
browser.on('disconnected', () => {
|
|
||||||
this._browserPromise = undefined;
|
|
||||||
debugLogger('Browser disconnected');
|
|
||||||
});
|
|
||||||
return browser;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function startCDPRelayServer(browserChannel: string, abortController: AbortController) {
|
|
||||||
const httpServer = await startHttpServer({});
|
|
||||||
const cdpRelayServer = new CDPRelayServer(httpServer, browserChannel);
|
|
||||||
abortController.signal.addEventListener('abort', () => cdpRelayServer.stop());
|
|
||||||
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
|
|
||||||
return new ExtensionContextFactory(cdpRelayServer);
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExtensionResponse = {
|
type ExtensionResponse = {
|
||||||
id?: number;
|
id?: number;
|
||||||
method?: string;
|
method?: string;
|
||||||
|
|||||||
63
src/extension/extensionContextFactory.ts
Normal file
63
src/extension/extensionContextFactory.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* 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 debug from 'debug';
|
||||||
|
import * as playwright from 'playwright';
|
||||||
|
import { startHttpServer } from '../mcp/http.js';
|
||||||
|
import { CDPRelayServer } from './cdpRelay.js';
|
||||||
|
|
||||||
|
import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js';
|
||||||
|
|
||||||
|
const debugLogger = debug('pw:mcp:relay');
|
||||||
|
|
||||||
|
export class ExtensionContextFactory implements BrowserContextFactory {
|
||||||
|
private _browserChannel: string;
|
||||||
|
private _userDataDir?: string;
|
||||||
|
|
||||||
|
constructor(browserChannel: string, userDataDir: string | undefined) {
|
||||||
|
this._browserChannel = browserChannel;
|
||||||
|
this._userDataDir = userDataDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||||
|
const browser = await this._obtainBrowser(clientInfo, abortSignal, toolName);
|
||||||
|
return {
|
||||||
|
browserContext: browser.contexts()[0],
|
||||||
|
close: async () => {
|
||||||
|
debugLogger('close() called for browser context');
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _obtainBrowser(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<playwright.Browser> {
|
||||||
|
const relay = await this._startRelay(abortSignal);
|
||||||
|
await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal, toolName);
|
||||||
|
return await playwright.chromium.connectOverCDP(relay.cdpEndpoint());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _startRelay(abortSignal: AbortSignal) {
|
||||||
|
const httpServer = await startHttpServer({});
|
||||||
|
if (abortSignal.aborted) {
|
||||||
|
httpServer.close();
|
||||||
|
throw new Error(abortSignal.reason);
|
||||||
|
}
|
||||||
|
const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel, this._userDataDir);
|
||||||
|
abortSignal.addEventListener('abort', () => cdpRelayServer.stop());
|
||||||
|
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
|
||||||
|
return cdpRelayServer;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,39 +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 { startCDPRelayServer } from './cdpRelay.js';
|
|
||||||
import { BrowserServerBackend } from '../browserServerBackend.js';
|
|
||||||
import * as mcpTransport from '../mcp/transport.js';
|
|
||||||
|
|
||||||
import type { FullConfig } from '../config.js';
|
|
||||||
|
|
||||||
export async function runWithExtension(config: FullConfig, abortController: AbortController) {
|
|
||||||
const contextFactory = await startCDPRelayServer(config.browser.launchOptions.channel || 'chrome', abortController);
|
|
||||||
|
|
||||||
let backend: BrowserServerBackend | undefined;
|
|
||||||
const serverBackendFactory = () => {
|
|
||||||
if (backend)
|
|
||||||
throw new Error('Another MCP client is still connected. Only one connection at a time is allowed.');
|
|
||||||
backend = new BrowserServerBackend(config, contextFactory);
|
|
||||||
backend.onclose = () => {
|
|
||||||
contextFactory.clientDisconnected();
|
|
||||||
backend = undefined;
|
|
||||||
};
|
|
||||||
return backend;
|
|
||||||
};
|
|
||||||
|
|
||||||
await mcpTransport.start(serverBackendFactory, config.server);
|
|
||||||
}
|
|
||||||
@@ -1,44 +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 assert from 'assert';
|
|
||||||
import http from 'http';
|
|
||||||
|
|
||||||
import type * as net from 'net';
|
|
||||||
|
|
||||||
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 httpAddressToString(address: string | net.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}`;
|
|
||||||
}
|
|
||||||
@@ -18,6 +18,7 @@ import { BrowserServerBackend } from './browserServerBackend.js';
|
|||||||
import { resolveConfig } from './config.js';
|
import { resolveConfig } from './config.js';
|
||||||
import { contextFactory } from './browserContextFactory.js';
|
import { contextFactory } from './browserContextFactory.js';
|
||||||
import * as mcpServer from './mcp/server.js';
|
import * as mcpServer from './mcp/server.js';
|
||||||
|
import { packageJSON } from './utils/package.js';
|
||||||
|
|
||||||
import type { Config } from '../config.js';
|
import type { Config } from '../config.js';
|
||||||
import type { BrowserContext } from 'playwright';
|
import type { BrowserContext } from 'playwright';
|
||||||
@@ -26,11 +27,14 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|||||||
|
|
||||||
export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Server> {
|
export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Server> {
|
||||||
const config = await resolveConfig(userConfig);
|
const config = await resolveConfig(userConfig);
|
||||||
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config.browser);
|
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config);
|
||||||
return mcpServer.createServer(new BrowserServerBackend(config, factory), false);
|
return mcpServer.createServer('Playwright', packageJSON.version, new BrowserServerBackend(config, factory), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
class SimpleBrowserContextFactory implements BrowserContextFactory {
|
class SimpleBrowserContextFactory implements BrowserContextFactory {
|
||||||
|
name = 'custom';
|
||||||
|
description = 'Connect to a browser using a custom context getter';
|
||||||
|
|
||||||
private readonly _contextGetter: () => Promise<BrowserContext>;
|
private readonly _contextGetter: () => Promise<BrowserContext>;
|
||||||
|
|
||||||
constructor(contextGetter: () => Promise<BrowserContext>) {
|
constructor(contextGetter: () => Promise<BrowserContext>) {
|
||||||
|
|||||||
5
src/loopTools/DEPS.list
Normal file
5
src/loopTools/DEPS.list
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[*]
|
||||||
|
../
|
||||||
|
../loop/
|
||||||
|
../mcp/
|
||||||
|
../utils/
|
||||||
@@ -23,6 +23,7 @@ import { OpenAIDelegate } from '../loop/loopOpenAI.js';
|
|||||||
import { ClaudeDelegate } from '../loop/loopClaude.js';
|
import { ClaudeDelegate } from '../loop/loopClaude.js';
|
||||||
import { InProcessTransport } from '../mcp/inProcessTransport.js';
|
import { InProcessTransport } from '../mcp/inProcessTransport.js';
|
||||||
import * as mcpServer from '../mcp/server.js';
|
import * as mcpServer from '../mcp/server.js';
|
||||||
|
import { packageJSON } from '../utils/package.js';
|
||||||
|
|
||||||
import type { LLMDelegate } from '../loop/loop.js';
|
import type { LLMDelegate } from '../loop/loop.js';
|
||||||
import type { FullConfig } from '../config.js';
|
import type { FullConfig } from '../config.js';
|
||||||
@@ -44,15 +45,15 @@ export class Context {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async create(config: FullConfig) {
|
static async create(config: FullConfig) {
|
||||||
const client = new Client({ name: 'Playwright Proxy', version: '1.0.0' });
|
const client = new Client({ name: 'Playwright Proxy', version: packageJSON.version });
|
||||||
const browserContextFactory = contextFactory(config.browser);
|
const browserContextFactory = contextFactory(config);
|
||||||
const server = mcpServer.createServer(new BrowserServerBackend(config, browserContextFactory), false);
|
const server = mcpServer.createServer('Playwright Subagent', packageJSON.version, new BrowserServerBackend(config, browserContextFactory), false);
|
||||||
await client.connect(new InProcessTransport(server));
|
await client.connect(new InProcessTransport(server));
|
||||||
await client.ping();
|
await client.ping();
|
||||||
return new Context(config, client);
|
return new Context(config, client);
|
||||||
}
|
}
|
||||||
|
|
||||||
async runTask(task: string, oneShot: boolean = false): Promise<mcpServer.ToolResponse> {
|
async runTask(task: string, oneShot: boolean = false): Promise<mcpServer.CallToolResult> {
|
||||||
const messages = await runTask(this._delegate, this._client!, task, oneShot);
|
const messages = await runTask(this._delegate, this._client!, task, oneShot);
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -17,11 +17,11 @@
|
|||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
import * as mcpServer from '../mcp/server.js';
|
import * as mcpServer from '../mcp/server.js';
|
||||||
import * as mcpTransport from '../mcp/transport.js';
|
import { packageJSON } from '../utils/package.js';
|
||||||
import { packageJSON } from '../package.js';
|
|
||||||
import { Context } from './context.js';
|
import { Context } from './context.js';
|
||||||
import { perform } from './perform.js';
|
import { perform } from './perform.js';
|
||||||
import { snapshot } from './snapshot.js';
|
import { snapshot } from './snapshot.js';
|
||||||
|
import { toMcpTool } from '../mcp/tool.js';
|
||||||
|
|
||||||
import type { FullConfig } from '../config.js';
|
import type { FullConfig } from '../config.js';
|
||||||
import type { ServerBackend } from '../mcp/server.js';
|
import type { ServerBackend } from '../mcp/server.js';
|
||||||
@@ -29,13 +29,16 @@ import type { Tool } from './tool.js';
|
|||||||
|
|
||||||
export async function runLoopTools(config: FullConfig) {
|
export async function runLoopTools(config: FullConfig) {
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
const serverBackendFactory = () => new LoopToolsServerBackend(config);
|
const serverBackendFactory = {
|
||||||
await mcpTransport.start(serverBackendFactory, config.server);
|
name: 'Playwright',
|
||||||
|
nameInConfig: 'playwright-loop',
|
||||||
|
version: packageJSON.version,
|
||||||
|
create: () => new LoopToolsServerBackend(config)
|
||||||
|
};
|
||||||
|
await mcpServer.start(serverBackendFactory, config.server);
|
||||||
}
|
}
|
||||||
|
|
||||||
class LoopToolsServerBackend implements ServerBackend {
|
class LoopToolsServerBackend implements ServerBackend {
|
||||||
readonly name = 'Playwright';
|
|
||||||
readonly version = packageJSON.version;
|
|
||||||
private _config: FullConfig;
|
private _config: FullConfig;
|
||||||
private _context: Context | undefined;
|
private _context: Context | undefined;
|
||||||
private _tools: Tool<any>[] = [perform, snapshot];
|
private _tools: Tool<any>[] = [perform, snapshot];
|
||||||
@@ -48,12 +51,13 @@ class LoopToolsServerBackend implements ServerBackend {
|
|||||||
this._context = await Context.create(this._config);
|
this._context = await Context.create(this._config);
|
||||||
}
|
}
|
||||||
|
|
||||||
tools(): mcpServer.ToolSchema<any>[] {
|
async listTools(): Promise<mcpServer.Tool[]> {
|
||||||
return this._tools.map(tool => tool.schema);
|
return this._tools.map(tool => toMcpTool(tool.schema));
|
||||||
}
|
}
|
||||||
|
|
||||||
async callTool(schema: mcpServer.ToolSchema<any>, parsedArguments: any): Promise<mcpServer.ToolResponse> {
|
async callTool(name: string, args: mcpServer.CallToolRequest['params']['arguments']): Promise<mcpServer.CallToolResult> {
|
||||||
const tool = this._tools.find(tool => tool.schema.name === schema.name)!;
|
const tool = this._tools.find(tool => tool.schema.name === name)!;
|
||||||
|
const parsedArguments = tool.schema.inputSchema.parse(args || {});
|
||||||
return await tool.handle(this._context!, parsedArguments);
|
return await tool.handle(this._context!, parsedArguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,11 +17,12 @@
|
|||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
import type * as mcpServer from '../mcp/server.js';
|
import type * as mcpServer from '../mcp/server.js';
|
||||||
import type { Context } from './context.js';
|
import type { Context } from './context.js';
|
||||||
|
import type { ToolSchema } from '../mcp/tool.js';
|
||||||
|
|
||||||
|
|
||||||
export type Tool<Input extends z.Schema = z.Schema> = {
|
export type Tool<Input extends z.Schema = z.Schema> = {
|
||||||
schema: mcpServer.ToolSchema<Input>;
|
schema: ToolSchema<Input>;
|
||||||
handle: (context: Context, params: z.output<Input>) => Promise<mcpServer.ToolResponse>;
|
handle: (context: Context, params: z.output<Input>) => Promise<mcpServer.CallToolResult>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function defineTool<Input extends z.Schema>(tool: Tool<Input>): Tool<Input> {
|
export function defineTool<Input extends z.Schema>(tool: Tool<Input>): Tool<Input> {
|
||||||
|
|||||||
1
src/mcp/DEPS.list
Normal file
1
src/mcp/DEPS.list
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[*]
|
||||||
@@ -1 +1 @@
|
|||||||
- Generic MCP utils, no dependencies on Playwright here.
|
- Generic MCP utils, no dependencies on anything.
|
||||||
|
|||||||
@@ -14,33 +14,62 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import assert from 'assert';
|
||||||
|
import net from 'net';
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
|
|
||||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
||||||
import { httpAddressToString, startHttpServer } from '../httpServer.js';
|
|
||||||
import * as mcpServer from './server.js';
|
import * as mcpServer from './server.js';
|
||||||
|
|
||||||
import type { ServerBackendFactory } from './server.js';
|
import type { ServerBackendFactory } from './server.js';
|
||||||
|
|
||||||
export async function start(serverBackendFactory: ServerBackendFactory, options: { host?: string; port?: number }) {
|
|
||||||
if (options.port !== undefined) {
|
|
||||||
const httpServer = await startHttpServer(options);
|
|
||||||
startHttpTransport(httpServer, serverBackendFactory);
|
|
||||||
} else {
|
|
||||||
await startStdioTransport(serverBackendFactory);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startStdioTransport(serverBackendFactory: ServerBackendFactory) {
|
|
||||||
await mcpServer.connect(serverBackendFactory, new StdioServerTransport(), false);
|
|
||||||
}
|
|
||||||
|
|
||||||
const testDebug = debug('pw:mcp:test');
|
const testDebug = debug('pw:mcp:test');
|
||||||
|
|
||||||
|
export async function startHttpServer(config: { host?: string, port?: number }, abortSignal?: AbortSignal): Promise<http.Server> {
|
||||||
|
const { host, port } = config;
|
||||||
|
const httpServer = http.createServer();
|
||||||
|
decorateServer(httpServer);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
httpServer.on('error', reject);
|
||||||
|
abortSignal?.addEventListener('abort', () => {
|
||||||
|
httpServer.close();
|
||||||
|
reject(new Error('Aborted'));
|
||||||
|
});
|
||||||
|
httpServer.listen(port, host, () => {
|
||||||
|
resolve();
|
||||||
|
httpServer.removeListener('error', reject);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return httpServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function httpAddressToString(address: string | net.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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installHttpTransport(httpServer: http.Server, serverBackendFactory: ServerBackendFactory) {
|
||||||
|
const sseSessions = new Map();
|
||||||
|
const streamableSessions = new Map();
|
||||||
|
httpServer.on('request', async (req, res) => {
|
||||||
|
const url = new URL(`http://localhost${req.url}`);
|
||||||
|
if (url.pathname.startsWith('/sse'))
|
||||||
|
await handleSSE(serverBackendFactory, req, res, url, sseSessions);
|
||||||
|
else
|
||||||
|
await handleStreamable(serverBackendFactory, req, res, streamableSessions);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSSE(serverBackendFactory: ServerBackendFactory, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>) {
|
async function handleSSE(serverBackendFactory: ServerBackendFactory, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>) {
|
||||||
if (req.method === 'POST') {
|
if (req.method === 'POST') {
|
||||||
const sessionId = url.searchParams.get('sessionId');
|
const sessionId = url.searchParams.get('sessionId');
|
||||||
@@ -109,29 +138,18 @@ async function handleStreamable(serverBackendFactory: ServerBackendFactory, req:
|
|||||||
res.end('Invalid request');
|
res.end('Invalid request');
|
||||||
}
|
}
|
||||||
|
|
||||||
function startHttpTransport(httpServer: http.Server, serverBackendFactory: ServerBackendFactory) {
|
function decorateServer(server: net.Server) {
|
||||||
const sseSessions = new Map();
|
const sockets = new Set<net.Socket>();
|
||||||
const streamableSessions = new Map();
|
server.on('connection', socket => {
|
||||||
httpServer.on('request', async (req, res) => {
|
sockets.add(socket);
|
||||||
const url = new URL(`http://localhost${req.url}`);
|
socket.once('close', () => sockets.delete(socket));
|
||||||
if (url.pathname.startsWith('/sse'))
|
|
||||||
await handleSSE(serverBackendFactory, req, res, url, sseSessions);
|
|
||||||
else
|
|
||||||
await handleStreamable(serverBackendFactory, req, res, streamableSessions);
|
|
||||||
});
|
});
|
||||||
const url = httpAddressToString(httpServer.address());
|
|
||||||
const message = [
|
const close = server.close;
|
||||||
`Listening on ${url}`,
|
server.close = (callback?: (err?: Error) => void) => {
|
||||||
'Put this in your client config:',
|
for (const socket of sockets)
|
||||||
JSON.stringify({
|
socket.destroy();
|
||||||
'mcpServers': {
|
sockets.clear();
|
||||||
'playwright': {
|
return close.call(server, callback);
|
||||||
'url': `${url}/mcp`
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
}, undefined, 2),
|
|
||||||
'For legacy SSE transport support, you can use the /sse endpoint instead.',
|
|
||||||
].join('\n');
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error(message);
|
|
||||||
}
|
}
|
||||||
239
src/mcp/mdb.ts
Normal file
239
src/mcp/mdb.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
/**
|
||||||
|
* 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 debug from 'debug';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { PingRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
|
|
||||||
|
import { defineToolSchema } from './tool.js';
|
||||||
|
import * as mcpServer from './server.js';
|
||||||
|
import * as mcpHttp from './http.js';
|
||||||
|
import { wrapInProcess } from './server.js';
|
||||||
|
import { ManualPromise } from './manualPromise.js';
|
||||||
|
|
||||||
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
|
|
||||||
|
const mdbDebug = debug('pw:mcp:mdb');
|
||||||
|
const errorsDebug = debug('pw:mcp:errors');
|
||||||
|
|
||||||
|
export class MDBBackend implements mcpServer.ServerBackend {
|
||||||
|
private _stack: { client: Client, toolNames: string[], resultPromise: ManualPromise<mcpServer.CallToolResult> | undefined }[] = [];
|
||||||
|
private _interruptPromise: ManualPromise<mcpServer.CallToolResult> | undefined;
|
||||||
|
private _topLevelBackend: mcpServer.ServerBackend;
|
||||||
|
private _initialized = false;
|
||||||
|
|
||||||
|
constructor(topLevelBackend: mcpServer.ServerBackend) {
|
||||||
|
this._topLevelBackend = topLevelBackend;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(server: mcpServer.Server): Promise<void> {
|
||||||
|
if (this._initialized)
|
||||||
|
return;
|
||||||
|
this._initialized = true;
|
||||||
|
const transport = await wrapInProcess(this._topLevelBackend);
|
||||||
|
await this._pushClient(transport);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTools(): Promise<mcpServer.Tool[]> {
|
||||||
|
const response = await this._client().listTools();
|
||||||
|
return response.tools;
|
||||||
|
}
|
||||||
|
|
||||||
|
async callTool(name: string, args: mcpServer.CallToolRequest['params']['arguments']): Promise<mcpServer.CallToolResult> {
|
||||||
|
if (name === pushToolsSchema.name)
|
||||||
|
return await this._pushTools(pushToolsSchema.inputSchema.parse(args || {}));
|
||||||
|
|
||||||
|
const interruptPromise = new ManualPromise<mcpServer.CallToolResult>();
|
||||||
|
this._interruptPromise = interruptPromise;
|
||||||
|
let [entry] = this._stack;
|
||||||
|
|
||||||
|
// Pop the client while the tool is not found.
|
||||||
|
while (entry && !entry.toolNames.includes(name)) {
|
||||||
|
mdbDebug('popping client from stack for ', name);
|
||||||
|
this._stack.shift();
|
||||||
|
await entry.client.close();
|
||||||
|
entry = this._stack[0];
|
||||||
|
}
|
||||||
|
if (!entry)
|
||||||
|
throw new Error(`Tool ${name} not found in the tool stack`);
|
||||||
|
|
||||||
|
const resultPromise = new ManualPromise<mcpServer.CallToolResult>();
|
||||||
|
entry.resultPromise = resultPromise;
|
||||||
|
|
||||||
|
this._client().callTool({
|
||||||
|
name,
|
||||||
|
arguments: args,
|
||||||
|
}).then(result => {
|
||||||
|
resultPromise.resolve(result as mcpServer.CallToolResult);
|
||||||
|
}).catch(e => {
|
||||||
|
mdbDebug('error in client call', e);
|
||||||
|
if (this._stack.length < 2)
|
||||||
|
throw e;
|
||||||
|
this._stack.shift();
|
||||||
|
const prevEntry = this._stack[0];
|
||||||
|
void prevEntry.resultPromise!.then(result => resultPromise.resolve(result));
|
||||||
|
});
|
||||||
|
const result = await Promise.race([interruptPromise, resultPromise]);
|
||||||
|
if (interruptPromise.isDone())
|
||||||
|
mdbDebug('client call intercepted', result);
|
||||||
|
else
|
||||||
|
mdbDebug('client call result', result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _client(): Client {
|
||||||
|
const [entry] = this._stack;
|
||||||
|
if (!entry)
|
||||||
|
throw new Error('No debugging backend available');
|
||||||
|
return entry.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _pushTools(params: { mcpUrl: string, introMessage?: string }): Promise<mcpServer.CallToolResult> {
|
||||||
|
mdbDebug('pushing tools to the stack', params.mcpUrl);
|
||||||
|
const transport = new StreamableHTTPClientTransport(new URL(params.mcpUrl));
|
||||||
|
await this._pushClient(transport, params.introMessage);
|
||||||
|
return { content: [{ type: 'text', text: 'Tools pushed' }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _pushClient(transport: Transport, introMessage?: string): Promise<mcpServer.CallToolResult> {
|
||||||
|
mdbDebug('pushing client to the stack');
|
||||||
|
const client = new Client({ name: 'Internal client', version: '0.0.0' });
|
||||||
|
client.setRequestHandler(PingRequestSchema, () => ({}));
|
||||||
|
await client.connect(transport);
|
||||||
|
mdbDebug('connected to the new client');
|
||||||
|
const { tools } = await client.listTools();
|
||||||
|
this._stack.unshift({ client, toolNames: tools.map(tool => tool.name), resultPromise: undefined });
|
||||||
|
mdbDebug('new tools added to the stack:', tools.map(tool => tool.name));
|
||||||
|
mdbDebug('interrupting current call:', !!this._interruptPromise);
|
||||||
|
this._interruptPromise?.resolve({
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: introMessage || '',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
this._interruptPromise = undefined;
|
||||||
|
return { content: [{ type: 'text', text: 'Tools pushed' }] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pushToolsSchema = defineToolSchema({
|
||||||
|
name: 'mdb_push_tools',
|
||||||
|
title: 'Push MCP tools to the tools stack',
|
||||||
|
description: 'Push MCP tools to the tools stack',
|
||||||
|
inputSchema: z.object({
|
||||||
|
mcpUrl: z.string(),
|
||||||
|
introMessage: z.string().optional(),
|
||||||
|
}),
|
||||||
|
type: 'readOnly',
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ServerBackendOnPause = mcpServer.ServerBackend & {
|
||||||
|
requestSelfDestruct?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function runMainBackend(backendFactory: mcpServer.ServerBackendFactory, options?: { port?: number }): Promise<string | undefined> {
|
||||||
|
const mdbBackend = new MDBBackend(backendFactory.create());
|
||||||
|
// Start HTTP unconditionally.
|
||||||
|
const factory: mcpServer.ServerBackendFactory = {
|
||||||
|
...backendFactory,
|
||||||
|
create: () => mdbBackend
|
||||||
|
};
|
||||||
|
const url = await startAsHttp(factory, { port: options?.port || 0 });
|
||||||
|
process.env.PLAYWRIGHT_DEBUGGER_MCP = url;
|
||||||
|
|
||||||
|
if (options?.port !== undefined)
|
||||||
|
return url;
|
||||||
|
|
||||||
|
// Start stdio conditionally.
|
||||||
|
await mcpServer.connect(factory, new StdioServerTransport(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runOnPauseBackendLoop(mdbUrl: string, backend: ServerBackendOnPause, introMessage: string) {
|
||||||
|
const wrappedBackend = new OnceTimeServerBackendWrapper(backend);
|
||||||
|
|
||||||
|
const factory = {
|
||||||
|
name: 'on-pause-backend',
|
||||||
|
nameInConfig: 'on-pause-backend',
|
||||||
|
version: '0.0.0',
|
||||||
|
create: () => wrappedBackend,
|
||||||
|
};
|
||||||
|
|
||||||
|
const httpServer = await mcpHttp.startHttpServer({ port: 0 });
|
||||||
|
await mcpHttp.installHttpTransport(httpServer, factory);
|
||||||
|
const url = mcpHttp.httpAddressToString(httpServer.address());
|
||||||
|
|
||||||
|
const client = new Client({ name: 'Internal client', version: '0.0.0' });
|
||||||
|
client.setRequestHandler(PingRequestSchema, () => ({}));
|
||||||
|
const transport = new StreamableHTTPClientTransport(new URL(mdbUrl));
|
||||||
|
await client.connect(transport);
|
||||||
|
|
||||||
|
const pushToolsResult = await client.callTool({
|
||||||
|
name: pushToolsSchema.name,
|
||||||
|
arguments: {
|
||||||
|
mcpUrl: url,
|
||||||
|
introMessage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (pushToolsResult.isError)
|
||||||
|
errorsDebug('Failed to push tools', pushToolsResult.content);
|
||||||
|
await transport.terminateSession();
|
||||||
|
await client.close();
|
||||||
|
|
||||||
|
await wrappedBackend.waitForClosed();
|
||||||
|
httpServer.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startAsHttp(backendFactory: mcpServer.ServerBackendFactory, options: { port: number }) {
|
||||||
|
const httpServer = await mcpHttp.startHttpServer(options);
|
||||||
|
await mcpHttp.installHttpTransport(httpServer, backendFactory);
|
||||||
|
return mcpHttp.httpAddressToString(httpServer.address());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OnceTimeServerBackendWrapper implements mcpServer.ServerBackend {
|
||||||
|
private _backend: ServerBackendOnPause;
|
||||||
|
private _selfDestructPromise = new ManualPromise<void>();
|
||||||
|
|
||||||
|
constructor(backend: ServerBackendOnPause) {
|
||||||
|
this._backend = backend;
|
||||||
|
this._backend.requestSelfDestruct = () => this._selfDestructPromise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(server: mcpServer.Server, clientVersion: mcpServer.ClientVersion, roots: mcpServer.Root[]): Promise<void> {
|
||||||
|
await this._backend.initialize?.(server, clientVersion, roots);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTools(): Promise<mcpServer.Tool[]> {
|
||||||
|
return this._backend.listTools();
|
||||||
|
}
|
||||||
|
|
||||||
|
async callTool(name: string, args: mcpServer.CallToolRequest['params']['arguments']): Promise<mcpServer.CallToolResult> {
|
||||||
|
return this._backend.callTool(name, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
serverClosed(server: mcpServer.Server) {
|
||||||
|
this._backend.serverClosed?.(server);
|
||||||
|
this._selfDestructPromise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForClosed() {
|
||||||
|
await this._selfDestructPromise;
|
||||||
|
}
|
||||||
|
}
|
||||||
128
src/mcp/proxyBackend.ts
Normal file
128
src/mcp/proxyBackend.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* 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 debug from 'debug';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||||
|
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { ListRootsRequestSchema, PingRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
|
import type { ServerBackend, ClientVersion, Root, Server } from './server.js';
|
||||||
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
|
import type { Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
|
export type MCPProvider = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
connect(): Promise<Transport>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorsDebug = debug('pw:mcp:errors');
|
||||||
|
|
||||||
|
export class ProxyBackend implements ServerBackend {
|
||||||
|
private _mcpProviders: MCPProvider[];
|
||||||
|
private _currentClient: Client | undefined;
|
||||||
|
private _contextSwitchTool: Tool;
|
||||||
|
private _roots: Root[] = [];
|
||||||
|
|
||||||
|
constructor(mcpProviders: MCPProvider[]) {
|
||||||
|
this._mcpProviders = mcpProviders;
|
||||||
|
this._contextSwitchTool = this._defineContextSwitchTool();
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(server: Server, clientVersion: ClientVersion, roots: Root[]): Promise<void> {
|
||||||
|
this._roots = roots;
|
||||||
|
await this._setCurrentClient(this._mcpProviders[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTools(): Promise<Tool[]> {
|
||||||
|
const response = await this._currentClient!.listTools();
|
||||||
|
if (this._mcpProviders.length === 1)
|
||||||
|
return response.tools;
|
||||||
|
return [
|
||||||
|
...response.tools,
|
||||||
|
this._contextSwitchTool,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult> {
|
||||||
|
if (name === this._contextSwitchTool.name)
|
||||||
|
return this._callContextSwitchTool(args);
|
||||||
|
return await this._currentClient!.callTool({
|
||||||
|
name,
|
||||||
|
arguments: args,
|
||||||
|
}) as CallToolResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
serverClosed?(): void {
|
||||||
|
void this._currentClient?.close().catch(errorsDebug);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _callContextSwitchTool(params: any): Promise<CallToolResult> {
|
||||||
|
try {
|
||||||
|
const factory = this._mcpProviders.find(factory => factory.name === params.name);
|
||||||
|
if (!factory)
|
||||||
|
throw new Error('Unknown connection method: ' + params.name);
|
||||||
|
|
||||||
|
await this._setCurrentClient(factory);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: '### Result\nSuccessfully changed connection method.\n' }],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `### Result\nError: ${error}\n` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _defineContextSwitchTool(): Tool {
|
||||||
|
return {
|
||||||
|
name: 'browser_connect',
|
||||||
|
description: [
|
||||||
|
'Connect to a browser using one of the available methods:',
|
||||||
|
...this._mcpProviders.map(factory => `- "${factory.name}": ${factory.description}`),
|
||||||
|
].join('\n'),
|
||||||
|
inputSchema: zodToJsonSchema(z.object({
|
||||||
|
name: z.enum(this._mcpProviders.map(factory => factory.name) as [string, ...string[]]).default(this._mcpProviders[0].name).describe('The method to use to connect to the browser'),
|
||||||
|
}), { strictUnions: true }) as Tool['inputSchema'],
|
||||||
|
annotations: {
|
||||||
|
title: 'Connect to a browser context',
|
||||||
|
readOnlyHint: true,
|
||||||
|
openWorldHint: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _setCurrentClient(factory: MCPProvider) {
|
||||||
|
await this._currentClient?.close();
|
||||||
|
this._currentClient = undefined;
|
||||||
|
|
||||||
|
const client = new Client({ name: 'Playwright MCP Proxy', version: '0.0.0' });
|
||||||
|
client.registerCapabilities({
|
||||||
|
roots: {
|
||||||
|
listRoots: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
client.setRequestHandler(ListRootsRequestSchema, () => ({ roots: this._roots }));
|
||||||
|
client.setRequestHandler(PingRequestSchema, () => ({}));
|
||||||
|
|
||||||
|
const transport = await factory.connect();
|
||||||
|
await client.connect(transport);
|
||||||
|
this._currentClient = client;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,96 +14,99 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import debug from 'debug';
|
||||||
|
|
||||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
|
import { httpAddressToString, installHttpTransport, startHttpServer } from './http.js';
|
||||||
|
import { InProcessTransport } from './inProcessTransport.js';
|
||||||
|
|
||||||
import type { ImageContent, Implementation, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
import type { Tool, CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
|
export type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
|
export type { Tool, CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
export type ClientVersion = Implementation;
|
const serverDebug = debug('pw:mcp:server');
|
||||||
|
const errorsDebug = debug('pw:mcp:errors');
|
||||||
|
|
||||||
export type ToolResponse = {
|
export type ClientVersion = { name: string, version: string };
|
||||||
content: (TextContent | ImageContent)[];
|
|
||||||
isError?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ToolSchema<Input extends z.Schema> = {
|
|
||||||
name: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
inputSchema: Input;
|
|
||||||
type: 'readOnly' | 'destructive';
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ToolHandler = (toolName: string, params: any) => Promise<ToolResponse>;
|
|
||||||
|
|
||||||
export interface ServerBackend {
|
export interface ServerBackend {
|
||||||
name: string;
|
initialize?(server: Server, clientVersion: ClientVersion, roots: Root[]): Promise<void>;
|
||||||
version: string;
|
listTools(): Promise<Tool[]>;
|
||||||
initialize?(): Promise<void>;
|
callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult>;
|
||||||
tools(): ToolSchema<any>[];
|
serverClosed?(server: Server): void;
|
||||||
callTool(schema: ToolSchema<any>, parsedArguments: any): Promise<ToolResponse>;
|
|
||||||
serverInitialized?(version: ClientVersion | undefined): void;
|
|
||||||
serverClosed?(): void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServerBackendFactory = () => ServerBackend;
|
export type ServerBackendFactory = {
|
||||||
|
name: string;
|
||||||
|
nameInConfig: string;
|
||||||
|
version: string;
|
||||||
|
create: () => ServerBackend;
|
||||||
|
};
|
||||||
|
|
||||||
export async function connect(serverBackendFactory: ServerBackendFactory, transport: Transport, runHeartbeat: boolean) {
|
export async function connect(factory: ServerBackendFactory, transport: Transport, runHeartbeat: boolean) {
|
||||||
const backend = serverBackendFactory();
|
const server = createServer(factory.name, factory.version, factory.create(), runHeartbeat);
|
||||||
await backend.initialize?.();
|
|
||||||
const server = createServer(backend, runHeartbeat);
|
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createServer(backend: ServerBackend, runHeartbeat: boolean): Server {
|
export async function wrapInProcess(backend: ServerBackend): Promise<Transport> {
|
||||||
const server = new Server({ name: backend.name, version: backend.version }, {
|
const server = createServer('Internal', '0.0.0', backend, false);
|
||||||
|
return new InProcessTransport(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServer(name: string, version: string, backend: ServerBackend, runHeartbeat: boolean): Server {
|
||||||
|
let initializedPromiseResolve = () => {};
|
||||||
|
const initializedPromise = new Promise<void>(resolve => initializedPromiseResolve = resolve);
|
||||||
|
const server = new Server({ name, version }, {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
tools: {},
|
tools: {},
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const tools = backend.tools();
|
|
||||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||||
return { tools: tools.map(tool => ({
|
serverDebug('listTools');
|
||||||
name: tool.name,
|
await initializedPromise;
|
||||||
description: tool.description,
|
const tools = await backend.listTools();
|
||||||
inputSchema: zodToJsonSchema(tool.inputSchema),
|
return { tools };
|
||||||
annotations: {
|
|
||||||
title: tool.title,
|
|
||||||
readOnlyHint: tool.type === 'readOnly',
|
|
||||||
destructiveHint: tool.type === 'destructive',
|
|
||||||
openWorldHint: true,
|
|
||||||
},
|
|
||||||
})) };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let heartbeatRunning = false;
|
let heartbeatRunning = false;
|
||||||
server.setRequestHandler(CallToolRequestSchema, async request => {
|
server.setRequestHandler(CallToolRequestSchema, async request => {
|
||||||
|
serverDebug('callTool', request);
|
||||||
|
await initializedPromise;
|
||||||
|
|
||||||
if (runHeartbeat && !heartbeatRunning) {
|
if (runHeartbeat && !heartbeatRunning) {
|
||||||
heartbeatRunning = true;
|
heartbeatRunning = true;
|
||||||
startHeartbeat(server);
|
startHeartbeat(server);
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorResult = (...messages: string[]) => ({
|
|
||||||
content: [{ type: 'text', text: messages.join('\n') }],
|
|
||||||
isError: true,
|
|
||||||
});
|
|
||||||
const tool = tools.find(tool => tool.name === request.params.name) as ToolSchema<any>;
|
|
||||||
if (!tool)
|
|
||||||
return errorResult(`Tool "${request.params.name}" not found`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await backend.callTool(tool, tool.inputSchema.parse(request.params.arguments || {}));
|
return await backend.callTool(request.params.name, request.params.arguments || {});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResult(String(error));
|
return {
|
||||||
|
content: [{ type: 'text', text: '### Result\n' + String(error) }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
addServerListener(server, 'initialized', async () => {
|
||||||
addServerListener(server, 'initialized', () => backend.serverInitialized?.(server.getClientVersion()));
|
try {
|
||||||
addServerListener(server, 'close', () => backend.serverClosed?.());
|
const capabilities = server.getClientCapabilities();
|
||||||
|
let clientRoots: Root[] = [];
|
||||||
|
if (capabilities?.roots) {
|
||||||
|
const { roots } = await server.listRoots(undefined, { timeout: 2_000 }).catch(() => ({ roots: [] }));
|
||||||
|
clientRoots = roots;
|
||||||
|
}
|
||||||
|
const clientVersion = server.getClientVersion() ?? { name: 'unknown', version: 'unknown' };
|
||||||
|
await backend.initialize?.(server, clientVersion, clientRoots);
|
||||||
|
initializedPromiseResolve();
|
||||||
|
} catch (e) {
|
||||||
|
errorsDebug(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
addServerListener(server, 'close', () => backend.serverClosed?.(server));
|
||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,3 +132,27 @@ function addServerListener(server: Server, event: 'close' | 'initialized', liste
|
|||||||
listener();
|
listener();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function start(serverBackendFactory: ServerBackendFactory, options: { host?: string; port?: number }) {
|
||||||
|
if (options.port === undefined) {
|
||||||
|
await connect(serverBackendFactory, new StdioServerTransport(), false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const httpServer = await startHttpServer(options);
|
||||||
|
await installHttpTransport(httpServer, serverBackendFactory);
|
||||||
|
const url = httpAddressToString(httpServer.address());
|
||||||
|
|
||||||
|
const mcpConfig: any = { mcpServers: { } };
|
||||||
|
mcpConfig.mcpServers[serverBackendFactory.nameInConfig] = {
|
||||||
|
url: `${url}/mcp`
|
||||||
|
};
|
||||||
|
const message = [
|
||||||
|
`Listening on ${url}`,
|
||||||
|
'Put this in your client config:',
|
||||||
|
JSON.stringify(mcpConfig, undefined, 2),
|
||||||
|
'For legacy SSE transport support, you can use the /sse endpoint instead.',
|
||||||
|
].join('\n');
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(message);
|
||||||
|
}
|
||||||
|
|||||||
46
src/mcp/tool.ts
Normal file
46
src/mcp/tool.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||||
|
|
||||||
|
import type { z } from 'zod';
|
||||||
|
import type * as mcpServer from './server.js';
|
||||||
|
|
||||||
|
export type ToolSchema<Input extends z.Schema> = {
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
inputSchema: Input;
|
||||||
|
type: 'readOnly' | 'destructive';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function toMcpTool(tool: ToolSchema<any>): mcpServer.Tool {
|
||||||
|
return {
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
inputSchema: zodToJsonSchema(tool.inputSchema, { strictUnions: true }) as mcpServer.Tool['inputSchema'],
|
||||||
|
annotations: {
|
||||||
|
title: tool.title,
|
||||||
|
readOnlyHint: tool.type === 'readOnly',
|
||||||
|
destructiveHint: tool.type === 'destructive',
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defineToolSchema<Input extends z.Schema>(tool: ToolSchema<Input>): ToolSchema<Input> {
|
||||||
|
return tool;
|
||||||
|
}
|
||||||
@@ -15,17 +15,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { program, Option } from 'commander';
|
import { program, Option } from 'commander';
|
||||||
// @ts-ignore
|
import * as mcpServer from './mcp/server.js';
|
||||||
import { startTraceViewerServer } from 'playwright-core/lib/server';
|
|
||||||
|
|
||||||
import * as mcpTransport from './mcp/transport.js';
|
|
||||||
import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js';
|
import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js';
|
||||||
import { packageJSON } from './package.js';
|
import { packageJSON } from './utils/package.js';
|
||||||
import { runWithExtension } from './extension/main.js';
|
|
||||||
import { BrowserServerBackend } from './browserServerBackend.js';
|
|
||||||
import { Context } from './context.js';
|
import { Context } from './context.js';
|
||||||
import { contextFactory } from './browserContextFactory.js';
|
import { contextFactory } from './browserContextFactory.js';
|
||||||
import { runLoopTools } from './loopTools/main.js';
|
import { runLoopTools } from './loopTools/main.js';
|
||||||
|
import { ProxyBackend } from './mcp/proxyBackend.js';
|
||||||
|
import { BrowserServerBackend } from './browserServerBackend.js';
|
||||||
|
import { ExtensionContextFactory } from './extension/extensionContextFactory.js';
|
||||||
|
|
||||||
|
import { runVSCodeTools } from './vscode/host.js';
|
||||||
|
import type { MCPProvider } from './mcp/proxyBackend.js';
|
||||||
|
|
||||||
program
|
program
|
||||||
.version('Version ' + packageJSON.version)
|
.version('Version ' + packageJSON.version)
|
||||||
@@ -39,6 +40,7 @@ program
|
|||||||
.option('--config <path>', 'path to the configuration file.')
|
.option('--config <path>', 'path to the configuration file.')
|
||||||
.option('--device <device>', 'device to emulate, for example: "iPhone 15"')
|
.option('--device <device>', 'device to emulate, for example: "iPhone 15"')
|
||||||
.option('--executable-path <path>', 'path to the browser executable.')
|
.option('--executable-path <path>', 'path to the browser executable.')
|
||||||
|
.option('--extension', 'Connect to a running browser instance (Edge/Chrome only). Requires the "Playwright MCP Bridge" browser extension to be installed.')
|
||||||
.option('--headless', 'run browser in headless mode, headed by default')
|
.option('--headless', 'run browser in headless mode, headed by default')
|
||||||
.option('--host <host>', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
|
.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('--ignore-https-errors', 'ignore https errors')
|
||||||
@@ -55,51 +57,83 @@ program
|
|||||||
.option('--user-agent <ua string>', 'specify user agent string')
|
.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('--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('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"')
|
||||||
.addOption(new Option('--extension', 'Connect to a running browser instance (Edge/Chrome only). Requires the "Playwright MCP Bridge" browser extension to be installed.').hideHelp())
|
.addOption(new Option('--connect-tool', 'Allow to switch between different browser connection methods.').hideHelp())
|
||||||
|
.addOption(new Option('--vscode', 'VS Code tools.').hideHelp())
|
||||||
.addOption(new Option('--loop-tools', 'Run loop tools').hideHelp())
|
.addOption(new Option('--loop-tools', 'Run loop tools').hideHelp())
|
||||||
.addOption(new Option('--vision', 'Legacy option, use --caps=vision instead').hideHelp())
|
.addOption(new Option('--vision', 'Legacy option, use --caps=vision instead').hideHelp())
|
||||||
.action(async options => {
|
.action(async options => {
|
||||||
const abortController = setupExitWatchdog();
|
setupExitWatchdog();
|
||||||
|
|
||||||
if (options.vision) {
|
if (options.vision) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error('The --vision option is deprecated, use --caps=vision instead');
|
console.error('The --vision option is deprecated, use --caps=vision instead');
|
||||||
options.caps = 'vision';
|
options.caps = 'vision';
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await resolveCLIConfig(options);
|
const config = await resolveCLIConfig(options);
|
||||||
|
const browserContextFactory = contextFactory(config);
|
||||||
|
const extensionContextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir);
|
||||||
|
|
||||||
if (options.extension) {
|
if (options.extension) {
|
||||||
await runWithExtension(config, abortController);
|
const serverBackendFactory: mcpServer.ServerBackendFactory = {
|
||||||
|
name: 'Playwright w/ extension',
|
||||||
|
nameInConfig: 'playwright-extension',
|
||||||
|
version: packageJSON.version,
|
||||||
|
create: () => new BrowserServerBackend(config, extensionContextFactory)
|
||||||
|
};
|
||||||
|
await mcpServer.start(serverBackendFactory, config.server);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.vscode) {
|
||||||
|
await runVSCodeTools(config);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (options.loopTools) {
|
if (options.loopTools) {
|
||||||
await runLoopTools(config);
|
await runLoopTools(config);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const browserContextFactory = contextFactory(config.browser);
|
if (options.connectTool) {
|
||||||
const serverBackendFactory = () => new BrowserServerBackend(config, browserContextFactory);
|
const providers: MCPProvider[] = [
|
||||||
await mcpTransport.start(serverBackendFactory, config.server);
|
{
|
||||||
|
name: 'default',
|
||||||
if (config.saveTrace) {
|
description: 'Starts standalone browser',
|
||||||
const server = await startTraceViewerServer();
|
connect: () => mcpServer.wrapInProcess(new BrowserServerBackend(config, browserContextFactory)),
|
||||||
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
|
name: 'extension',
|
||||||
console.error('\nTrace viewer listening on ' + url);
|
description: 'Connect to a browser using the Playwright MCP extension',
|
||||||
|
connect: () => mcpServer.wrapInProcess(new BrowserServerBackend(config, extensionContextFactory)),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const factory: mcpServer.ServerBackendFactory = {
|
||||||
|
name: 'Playwright w/ switch',
|
||||||
|
nameInConfig: 'playwright-switch',
|
||||||
|
version: packageJSON.version,
|
||||||
|
create: () => new ProxyBackend(providers),
|
||||||
|
};
|
||||||
|
await mcpServer.start(factory, config.server);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const factory: mcpServer.ServerBackendFactory = {
|
||||||
|
name: 'Playwright',
|
||||||
|
nameInConfig: 'playwright',
|
||||||
|
version: packageJSON.version,
|
||||||
|
create: () => new BrowserServerBackend(config, browserContextFactory)
|
||||||
|
};
|
||||||
|
await mcpServer.start(factory, config.server);
|
||||||
});
|
});
|
||||||
|
|
||||||
function setupExitWatchdog() {
|
function setupExitWatchdog() {
|
||||||
const abortController = new AbortController();
|
|
||||||
|
|
||||||
let isExiting = false;
|
let isExiting = false;
|
||||||
const handleExit = async () => {
|
const handleExit = async () => {
|
||||||
if (isExiting)
|
if (isExiting)
|
||||||
return;
|
return;
|
||||||
isExiting = true;
|
isExiting = true;
|
||||||
setTimeout(() => process.exit(0), 15000);
|
setTimeout(() => process.exit(0), 15000);
|
||||||
abortController.abort('Process exiting');
|
|
||||||
await Context.disposeAll();
|
await Context.disposeAll();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
};
|
};
|
||||||
@@ -107,8 +141,6 @@ function setupExitWatchdog() {
|
|||||||
process.stdin.on('close', handleExit);
|
process.stdin.on('close', handleExit);
|
||||||
process.on('SIGINT', handleExit);
|
process.on('SIGINT', handleExit);
|
||||||
process.on('SIGTERM', handleExit);
|
process.on('SIGTERM', handleExit);
|
||||||
|
|
||||||
return abortController;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void program.parseAsync(process.argv);
|
void program.parseAsync(process.argv);
|
||||||
|
|||||||
110
src/response.ts
110
src/response.ts
@@ -14,7 +14,10 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
import { renderModalStates } from './tab.js';
|
||||||
|
|
||||||
|
import type { Tab, TabSnapshot } from './tab.js';
|
||||||
|
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import type { Context } from './context.js';
|
import type { Context } from './context.js';
|
||||||
|
|
||||||
export class Response {
|
export class Response {
|
||||||
@@ -24,10 +27,11 @@ export class Response {
|
|||||||
private _context: Context;
|
private _context: Context;
|
||||||
private _includeSnapshot = false;
|
private _includeSnapshot = false;
|
||||||
private _includeTabs = false;
|
private _includeTabs = false;
|
||||||
private _snapshot: string | undefined;
|
private _tabSnapshot: TabSnapshot | undefined;
|
||||||
|
|
||||||
readonly toolName: string;
|
readonly toolName: string;
|
||||||
readonly toolArgs: Record<string, any>;
|
readonly toolArgs: Record<string, any>;
|
||||||
|
private _isError: boolean | undefined;
|
||||||
|
|
||||||
constructor(context: Context, toolName: string, toolArgs: Record<string, any>) {
|
constructor(context: Context, toolName: string, toolArgs: Record<string, any>) {
|
||||||
this._context = context;
|
this._context = context;
|
||||||
@@ -39,6 +43,15 @@ export class Response {
|
|||||||
this._result.push(result);
|
this._result.push(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addError(error: string) {
|
||||||
|
this._result.push(error);
|
||||||
|
this._isError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
isError() {
|
||||||
|
return this._isError;
|
||||||
|
}
|
||||||
|
|
||||||
result() {
|
result() {
|
||||||
return this._result.join('\n');
|
return this._result.join('\n');
|
||||||
}
|
}
|
||||||
@@ -67,17 +80,20 @@ export class Response {
|
|||||||
this._includeTabs = true;
|
this._includeTabs = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async snapshot(): Promise<string> {
|
async finish() {
|
||||||
if (this._snapshot !== undefined)
|
// All the async snapshotting post-action is happening here.
|
||||||
return this._snapshot;
|
// Everything below should race against modal states.
|
||||||
if (this._includeSnapshot && this._context.currentTab())
|
if (this._includeSnapshot && this._context.currentTab())
|
||||||
this._snapshot = await this._context.currentTabOrDie().captureSnapshot();
|
this._tabSnapshot = await this._context.currentTabOrDie().captureSnapshot();
|
||||||
else
|
for (const tab of this._context.tabs())
|
||||||
this._snapshot = '';
|
await tab.updateTitle();
|
||||||
return this._snapshot;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async serialize(): Promise<{ content: (TextContent | ImageContent)[] }> {
|
tabSnapshot(): TabSnapshot | undefined {
|
||||||
|
return this._tabSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
serialize(): { content: (TextContent | ImageContent)[], isError?: boolean } {
|
||||||
const response: string[] = [];
|
const response: string[] = [];
|
||||||
|
|
||||||
// Start with command result.
|
// Start with command result.
|
||||||
@@ -98,12 +114,16 @@ ${this._code.join('\n')}
|
|||||||
|
|
||||||
// List browser tabs.
|
// List browser tabs.
|
||||||
if (this._includeSnapshot || this._includeTabs)
|
if (this._includeSnapshot || this._includeTabs)
|
||||||
response.push(...(await this._context.listTabsMarkdown(this._includeTabs)));
|
response.push(...renderTabsMarkdown(this._context.tabs(), this._includeTabs));
|
||||||
|
|
||||||
// Add snapshot if provided.
|
// Add snapshot if provided.
|
||||||
const snapshot = await this.snapshot();
|
if (this._tabSnapshot?.modalStates.length) {
|
||||||
if (snapshot)
|
response.push(...renderModalStates(this._context, this._tabSnapshot.modalStates));
|
||||||
response.push(snapshot, '');
|
response.push('');
|
||||||
|
} else if (this._tabSnapshot) {
|
||||||
|
response.push(renderTabSnapshot(this._tabSnapshot));
|
||||||
|
response.push('');
|
||||||
|
}
|
||||||
|
|
||||||
// Main response part
|
// Main response part
|
||||||
const content: (TextContent | ImageContent)[] = [
|
const content: (TextContent | ImageContent)[] = [
|
||||||
@@ -116,6 +136,66 @@ ${this._code.join('\n')}
|
|||||||
content.push({ type: 'image', data: image.data.toString('base64'), mimeType: image.contentType });
|
content.push({ type: 'image', data: image.data.toString('base64'), mimeType: image.contentType });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { content };
|
return { content, isError: this._isError };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderTabSnapshot(tabSnapshot: TabSnapshot): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
if (tabSnapshot.consoleMessages.length) {
|
||||||
|
lines.push(`### New console messages`);
|
||||||
|
for (const message of tabSnapshot.consoleMessages)
|
||||||
|
lines.push(`- ${trim(message.toString(), 100)}`);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabSnapshot.downloads.length) {
|
||||||
|
lines.push(`### Downloads`);
|
||||||
|
for (const entry of tabSnapshot.downloads) {
|
||||||
|
if (entry.finished)
|
||||||
|
lines.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
|
||||||
|
else
|
||||||
|
lines.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`### Page state`);
|
||||||
|
lines.push(`- Page URL: ${tabSnapshot.url}`);
|
||||||
|
lines.push(`- Page Title: ${tabSnapshot.title}`);
|
||||||
|
lines.push(`- Page Snapshot:`);
|
||||||
|
lines.push('```yaml');
|
||||||
|
lines.push(tabSnapshot.ariaSnapshot);
|
||||||
|
lines.push('```');
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTabsMarkdown(tabs: Tab[], force: boolean = false): string[] {
|
||||||
|
if (tabs.length === 1 && !force)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
if (!tabs.length) {
|
||||||
|
return [
|
||||||
|
'### Open tabs',
|
||||||
|
'No open tabs. Use the "browser_navigate" tool to navigate to a page first.',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = ['### Open tabs'];
|
||||||
|
for (let i = 0; i < tabs.length; i++) {
|
||||||
|
const tab = tabs[i];
|
||||||
|
const current = tab.isCurrentTab() ? ' (current)' : '';
|
||||||
|
lines.push(`- ${i}:${current} [${tab.lastTitle()}] (${tab.page.url()})`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trim(text: string, maxLength: number) {
|
||||||
|
if (text.length <= maxLength)
|
||||||
|
return text;
|
||||||
|
return text.slice(0, maxLength) + '...';
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,76 +17,160 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { outputFile } from './config.js';
|
|
||||||
import { Response } from './response.js';
|
import { Response } from './response.js';
|
||||||
import type { FullConfig } from './config.js';
|
import { logUnhandledError } from './utils/log.js';
|
||||||
|
import { outputFile } from './config.js';
|
||||||
|
|
||||||
let sessionOrdinal = 0;
|
import type { FullConfig } from './config.js';
|
||||||
|
import type * as actions from './actions.js';
|
||||||
|
import type { Tab, TabSnapshot } from './tab.js';
|
||||||
|
|
||||||
|
type LogEntry = {
|
||||||
|
timestamp: number;
|
||||||
|
toolCall?: {
|
||||||
|
toolName: string;
|
||||||
|
toolArgs: Record<string, any>;
|
||||||
|
result: string;
|
||||||
|
isError?: boolean;
|
||||||
|
};
|
||||||
|
userAction?: actions.Action;
|
||||||
|
code: string;
|
||||||
|
tabSnapshot?: TabSnapshot;
|
||||||
|
};
|
||||||
|
|
||||||
export class SessionLog {
|
export class SessionLog {
|
||||||
private _folder: string;
|
private _folder: string;
|
||||||
private _file: string;
|
private _file: string;
|
||||||
private _ordinal = 0;
|
private _ordinal = 0;
|
||||||
|
private _pendingEntries: LogEntry[] = [];
|
||||||
|
private _sessionFileQueue = Promise.resolve();
|
||||||
|
private _flushEntriesTimeout: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
constructor(sessionFolder: string) {
|
constructor(sessionFolder: string) {
|
||||||
this._folder = sessionFolder;
|
this._folder = sessionFolder;
|
||||||
this._file = path.join(this._folder, 'session.md');
|
this._file = path.join(this._folder, 'session.md');
|
||||||
}
|
}
|
||||||
|
|
||||||
static async create(config: FullConfig): Promise<SessionLog> {
|
static async create(config: FullConfig, rootPath: string | undefined): Promise<SessionLog> {
|
||||||
const sessionFolder = await outputFile(config, `session-${(++sessionOrdinal).toString().padStart(3, '0')}`);
|
const sessionFolder = await outputFile(config, rootPath, `session-${Date.now()}`);
|
||||||
await fs.promises.mkdir(sessionFolder, { recursive: true });
|
await fs.promises.mkdir(sessionFolder, { recursive: true });
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error(`Session: ${sessionFolder}`);
|
console.error(`Session: ${sessionFolder}`);
|
||||||
return new SessionLog(sessionFolder);
|
return new SessionLog(sessionFolder);
|
||||||
}
|
}
|
||||||
|
|
||||||
async log(response: Response) {
|
logResponse(response: Response) {
|
||||||
const prefix = `${(++this._ordinal).toString().padStart(3, '0')}`;
|
const entry: LogEntry = {
|
||||||
const lines: string[] = [
|
timestamp: performance.now(),
|
||||||
`### Tool: ${response.toolName}`,
|
toolCall: {
|
||||||
``,
|
toolName: response.toolName,
|
||||||
`- Args`,
|
toolArgs: response.toolArgs,
|
||||||
'```json',
|
result: response.result(),
|
||||||
JSON.stringify(response.toolArgs, null, 2),
|
isError: response.isError(),
|
||||||
'```',
|
},
|
||||||
];
|
code: response.code(),
|
||||||
if (response.result()) {
|
tabSnapshot: response.tabSnapshot(),
|
||||||
lines.push(
|
};
|
||||||
`- Result`,
|
this._appendEntry(entry);
|
||||||
'```',
|
}
|
||||||
response.result(),
|
|
||||||
'```');
|
logUserAction(action: actions.Action, tab: Tab, code: string, isUpdate: boolean) {
|
||||||
|
code = code.trim();
|
||||||
|
if (isUpdate) {
|
||||||
|
const lastEntry = this._pendingEntries[this._pendingEntries.length - 1];
|
||||||
|
if (lastEntry.userAction?.name === action.name) {
|
||||||
|
lastEntry.userAction = action;
|
||||||
|
lastEntry.code = code;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (action.name === 'navigate') {
|
||||||
|
// Already logged at this location.
|
||||||
|
const lastEntry = this._pendingEntries[this._pendingEntries.length - 1];
|
||||||
|
if (lastEntry?.tabSnapshot?.url === action.url)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entry: LogEntry = {
|
||||||
|
timestamp: performance.now(),
|
||||||
|
userAction: action,
|
||||||
|
code,
|
||||||
|
tabSnapshot: {
|
||||||
|
url: tab.page.url(),
|
||||||
|
title: '',
|
||||||
|
ariaSnapshot: action.ariaSnapshot || '',
|
||||||
|
modalStates: [],
|
||||||
|
consoleMessages: [],
|
||||||
|
downloads: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this._appendEntry(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _appendEntry(entry: LogEntry) {
|
||||||
|
this._pendingEntries.push(entry);
|
||||||
|
if (this._flushEntriesTimeout)
|
||||||
|
clearTimeout(this._flushEntriesTimeout);
|
||||||
|
this._flushEntriesTimeout = setTimeout(() => this._flushEntries(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _flushEntries() {
|
||||||
|
clearTimeout(this._flushEntriesTimeout);
|
||||||
|
const entries = this._pendingEntries;
|
||||||
|
this._pendingEntries = [];
|
||||||
|
const lines: string[] = [''];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const ordinal = (++this._ordinal).toString().padStart(3, '0');
|
||||||
|
if (entry.toolCall) {
|
||||||
|
lines.push(
|
||||||
|
`### Tool call: ${entry.toolCall.toolName}`,
|
||||||
|
`- Args`,
|
||||||
|
'```json',
|
||||||
|
JSON.stringify(entry.toolCall.toolArgs, null, 2),
|
||||||
|
'```',
|
||||||
|
);
|
||||||
|
if (entry.toolCall.result) {
|
||||||
|
lines.push(
|
||||||
|
entry.toolCall.isError ? `- Error` : `- Result`,
|
||||||
|
'```',
|
||||||
|
entry.toolCall.result,
|
||||||
|
'```',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.userAction) {
|
||||||
|
const actionData = { ...entry.userAction } as any;
|
||||||
|
delete actionData.ariaSnapshot;
|
||||||
|
delete actionData.selector;
|
||||||
|
delete actionData.signals;
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
`### User action: ${entry.userAction.name}`,
|
||||||
|
`- Args`,
|
||||||
|
'```json',
|
||||||
|
JSON.stringify(actionData, null, 2),
|
||||||
|
'```',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.code) {
|
||||||
|
lines.push(
|
||||||
|
`- Code`,
|
||||||
|
'```js',
|
||||||
|
entry.code,
|
||||||
|
'```');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.tabSnapshot) {
|
||||||
|
const fileName = `${ordinal}.snapshot.yml`;
|
||||||
|
fs.promises.writeFile(path.join(this._folder, fileName), entry.tabSnapshot.ariaSnapshot).catch(logUnhandledError);
|
||||||
|
lines.push(`- Snapshot: ${fileName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.code()) {
|
this._sessionFileQueue = this._sessionFileQueue.then(() => fs.promises.appendFile(this._file, lines.join('\n')));
|
||||||
lines.push(
|
|
||||||
`- Code`,
|
|
||||||
'```js',
|
|
||||||
response.code(),
|
|
||||||
'```');
|
|
||||||
}
|
|
||||||
|
|
||||||
const snapshot = await response.snapshot();
|
|
||||||
if (snapshot) {
|
|
||||||
const fileName = `${prefix}.snapshot.yml`;
|
|
||||||
await fs.promises.writeFile(path.join(this._folder, fileName), snapshot);
|
|
||||||
lines.push(`- Snapshot: ${fileName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const image of response.images()) {
|
|
||||||
const fileName = `${prefix}.screenshot.${extension(image.contentType)}`;
|
|
||||||
await fs.promises.writeFile(path.join(this._folder, fileName), image.data);
|
|
||||||
lines.push(`- Screenshot: ${fileName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push('', '');
|
|
||||||
await fs.promises.appendFile(this._file, lines.join('\n'));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function extension(contentType: string): 'jpg' | 'png' {
|
|
||||||
if (contentType === 'image/jpeg')
|
|
||||||
return 'jpg';
|
|
||||||
return 'png';
|
|
||||||
}
|
|
||||||
|
|||||||
139
src/tab.ts
139
src/tab.ts
@@ -17,10 +17,9 @@
|
|||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
|
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
|
||||||
import { logUnhandledError } from './log.js';
|
import { logUnhandledError } from './utils/log.js';
|
||||||
import { ManualPromise } from './manualPromise.js';
|
import { ManualPromise } from './mcp/manualPromise.js';
|
||||||
import { ModalState } from './tools/tool.js';
|
import { ModalState } from './tools/tool.js';
|
||||||
import { outputFile } from './config.js';
|
|
||||||
|
|
||||||
import type { Context } from './context.js';
|
import type { Context } from './context.js';
|
||||||
|
|
||||||
@@ -36,9 +35,19 @@ export type TabEventsInterface = {
|
|||||||
[TabEvents.modalState]: [modalState: ModalState];
|
[TabEvents.modalState]: [modalState: ModalState];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TabSnapshot = {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
ariaSnapshot: string;
|
||||||
|
modalStates: ModalState[];
|
||||||
|
consoleMessages: ConsoleMessage[];
|
||||||
|
downloads: { download: playwright.Download, finished: boolean, outputFile: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
export class Tab extends EventEmitter<TabEventsInterface> {
|
export class Tab extends EventEmitter<TabEventsInterface> {
|
||||||
readonly context: Context;
|
readonly context: Context;
|
||||||
readonly page: playwright.Page;
|
readonly page: playwright.Page;
|
||||||
|
private _lastTitle = 'about:blank';
|
||||||
private _consoleMessages: ConsoleMessage[] = [];
|
private _consoleMessages: ConsoleMessage[] = [];
|
||||||
private _recentConsoleMessages: ConsoleMessage[] = [];
|
private _recentConsoleMessages: ConsoleMessage[] = [];
|
||||||
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
|
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
|
||||||
@@ -69,6 +78,11 @@ export class Tab extends EventEmitter<TabEventsInterface> {
|
|||||||
});
|
});
|
||||||
page.setDefaultNavigationTimeout(60000);
|
page.setDefaultNavigationTimeout(60000);
|
||||||
page.setDefaultTimeout(5000);
|
page.setDefaultTimeout(5000);
|
||||||
|
(page as any)[tabSymbol] = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
static forPage(page: playwright.Page): Tab | undefined {
|
||||||
|
return (page as any)[tabSymbol];
|
||||||
}
|
}
|
||||||
|
|
||||||
modalStates(): ModalState[] {
|
modalStates(): ModalState[] {
|
||||||
@@ -85,14 +99,7 @@ export class Tab extends EventEmitter<TabEventsInterface> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
modalStatesMarkdown(): string[] {
|
modalStatesMarkdown(): string[] {
|
||||||
const result: string[] = ['### Modal state'];
|
return renderModalStates(this.context, this.modalStates());
|
||||||
if (this._modalStates.length === 0)
|
|
||||||
result.push('- There is no modal state present');
|
|
||||||
for (const state of this._modalStates) {
|
|
||||||
const tool = this.context.tools.filter(tool => 'clearsModalState' in tool).find(tool => tool.clearsModalState === state.type);
|
|
||||||
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _dialogShown(dialog: playwright.Dialog) {
|
private _dialogShown(dialog: playwright.Dialog) {
|
||||||
@@ -107,7 +114,7 @@ export class Tab extends EventEmitter<TabEventsInterface> {
|
|||||||
const entry = {
|
const entry = {
|
||||||
download,
|
download,
|
||||||
finished: false,
|
finished: false,
|
||||||
outputFile: await outputFile(this.context.config, download.suggestedFilename())
|
outputFile: await this.context.outputFile(download.suggestedFilename())
|
||||||
};
|
};
|
||||||
this._downloads.push(entry);
|
this._downloads.push(entry);
|
||||||
await download.saveAs(entry.outputFile);
|
await download.saveAs(entry.outputFile);
|
||||||
@@ -130,8 +137,18 @@ export class Tab extends EventEmitter<TabEventsInterface> {
|
|||||||
this._onPageClose(this);
|
this._onPageClose(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
async title(): Promise<string> {
|
async updateTitle() {
|
||||||
return await callOnPageNoTrace(this.page, page => page.title());
|
await this._raceAgainstModalStates(async () => {
|
||||||
|
this._lastTitle = await callOnPageNoTrace(this.page, page => page.title());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
lastTitle(): string {
|
||||||
|
return this._lastTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
isCurrentTab(): boolean {
|
||||||
|
return this === this.context.currentTab();
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise<void> {
|
async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise<void> {
|
||||||
@@ -175,71 +192,50 @@ export class Tab extends EventEmitter<TabEventsInterface> {
|
|||||||
return this._requests;
|
return this._requests;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _takeRecentConsoleMarkdown(): string[] {
|
async captureSnapshot(): Promise<TabSnapshot> {
|
||||||
if (!this._recentConsoleMessages.length)
|
let tabSnapshot: TabSnapshot | undefined;
|
||||||
return [];
|
const modalStates = await this._raceAgainstModalStates(async () => {
|
||||||
const result = this._recentConsoleMessages.map(message => {
|
|
||||||
return `- ${trim(message.toString(), 100)}`;
|
|
||||||
});
|
|
||||||
return [`### New console messages`, ...result, ''];
|
|
||||||
}
|
|
||||||
|
|
||||||
private _listDownloadsMarkdown(): string[] {
|
|
||||||
if (!this._downloads.length)
|
|
||||||
return [];
|
|
||||||
|
|
||||||
const result: string[] = ['### Downloads'];
|
|
||||||
for (const entry of this._downloads) {
|
|
||||||
if (entry.finished)
|
|
||||||
result.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
|
|
||||||
else
|
|
||||||
result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
|
|
||||||
}
|
|
||||||
result.push('');
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async captureSnapshot(): Promise<string> {
|
|
||||||
const result: string[] = [];
|
|
||||||
if (this.modalStates().length) {
|
|
||||||
result.push(...this.modalStatesMarkdown());
|
|
||||||
return result.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push(...this._takeRecentConsoleMarkdown());
|
|
||||||
result.push(...this._listDownloadsMarkdown());
|
|
||||||
|
|
||||||
await this._raceAgainstModalStates(async () => {
|
|
||||||
const snapshot = await (this.page as PageEx)._snapshotForAI();
|
const snapshot = await (this.page as PageEx)._snapshotForAI();
|
||||||
result.push(
|
tabSnapshot = {
|
||||||
`### Page state`,
|
url: this.page.url(),
|
||||||
`- Page URL: ${this.page.url()}`,
|
title: await this.page.title(),
|
||||||
`- Page Title: ${await this.page.title()}`,
|
ariaSnapshot: snapshot,
|
||||||
`- Page Snapshot:`,
|
modalStates: [],
|
||||||
'```yaml',
|
consoleMessages: [],
|
||||||
snapshot,
|
downloads: this._downloads,
|
||||||
'```',
|
};
|
||||||
);
|
|
||||||
});
|
});
|
||||||
return result.join('\n');
|
if (tabSnapshot) {
|
||||||
|
// Assign console message late so that we did not lose any to modal state.
|
||||||
|
tabSnapshot.consoleMessages = this._recentConsoleMessages;
|
||||||
|
this._recentConsoleMessages = [];
|
||||||
|
}
|
||||||
|
return tabSnapshot ?? {
|
||||||
|
url: this.page.url(),
|
||||||
|
title: '',
|
||||||
|
ariaSnapshot: '',
|
||||||
|
modalStates,
|
||||||
|
consoleMessages: [],
|
||||||
|
downloads: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private _javaScriptBlocked(): boolean {
|
private _javaScriptBlocked(): boolean {
|
||||||
return this._modalStates.some(state => state.type === 'dialog');
|
return this._modalStates.some(state => state.type === 'dialog');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _raceAgainstModalStates(action: () => Promise<void>): Promise<ModalState | undefined> {
|
private async _raceAgainstModalStates(action: () => Promise<void>): Promise<ModalState[]> {
|
||||||
if (this.modalStates().length)
|
if (this.modalStates().length)
|
||||||
return this.modalStates()[0];
|
return this.modalStates();
|
||||||
|
|
||||||
const promise = new ManualPromise<ModalState>();
|
const promise = new ManualPromise<ModalState[]>();
|
||||||
const listener = (modalState: ModalState) => promise.resolve(modalState);
|
const listener = (modalState: ModalState) => promise.resolve([modalState]);
|
||||||
this.once(TabEvents.modalState, listener);
|
this.once(TabEvents.modalState, listener);
|
||||||
|
|
||||||
return await Promise.race([
|
return await Promise.race([
|
||||||
action().then(() => {
|
action().then(() => {
|
||||||
this.off(TabEvents.modalState, listener);
|
this.off(TabEvents.modalState, listener);
|
||||||
return undefined;
|
return [];
|
||||||
}),
|
}),
|
||||||
promise,
|
promise,
|
||||||
]);
|
]);
|
||||||
@@ -303,8 +299,15 @@ function pageErrorToConsoleMessage(errorOrValue: Error | any): ConsoleMessage {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function trim(text: string, maxLength: number) {
|
export function renderModalStates(context: Context, modalStates: ModalState[]): string[] {
|
||||||
if (text.length <= maxLength)
|
const result: string[] = ['### Modal state'];
|
||||||
return text;
|
if (modalStates.length === 0)
|
||||||
return text.slice(0, maxLength) + '...';
|
result.push('- There is no modal state present');
|
||||||
|
for (const state of modalStates) {
|
||||||
|
const tool = context.tools.filter(tool => 'clearsModalState' in tool).find(tool => tool.clearsModalState === state.type);
|
||||||
|
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tabSymbol = Symbol('tabSymbol');
|
||||||
|
|||||||
114
src/test.ts
Normal file
114
src/test.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* 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 { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||||
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
|
import { ListRootsRequestSchema, PingRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
|
export async function connectMCP() {
|
||||||
|
// const transport = new StreamableHTTPClientTransport(new URL('http://localhost:4242/mcp'));
|
||||||
|
|
||||||
|
const transport = new StdioClientTransport({
|
||||||
|
command: 'node',
|
||||||
|
env: process.env as any,
|
||||||
|
args: [
|
||||||
|
'/Users/yurys/playwright-mcp/cli.js',
|
||||||
|
// '--browser=chrome-canary',
|
||||||
|
// '--extension'
|
||||||
|
// '--browser=chromium',
|
||||||
|
// '--no-sandbox',
|
||||||
|
// '--isolated',
|
||||||
|
],
|
||||||
|
stderr: 'inherit',
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
console.error('will create client');
|
||||||
|
const client = new Client({ name: 'Visual Studio Code', version: '1.0.0' });
|
||||||
|
client.setRequestHandler(PingRequestSchema, async () => ({}));
|
||||||
|
|
||||||
|
console.error('Will connect');
|
||||||
|
try {
|
||||||
|
await client.connect(transport);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Connection error:', error);
|
||||||
|
}
|
||||||
|
console.error('Connected');
|
||||||
|
|
||||||
|
// const tools = await client.listTools();
|
||||||
|
// console.log('Available tools:', tools.tools.length);
|
||||||
|
|
||||||
|
// await client.ping();
|
||||||
|
// console.error('Pinged');
|
||||||
|
|
||||||
|
{
|
||||||
|
const response = await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: 'https://amazon.com/'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log('Navigated to Amazon', response.isError ? 'error' : '', response.error ? response.error : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// const r = await client.callTool({
|
||||||
|
// name: 'browser_connect',
|
||||||
|
// arguments: {
|
||||||
|
// name: 'extension'
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// console.log('Connected to extension', r.isError ? 'error' : '', r.content);
|
||||||
|
|
||||||
|
const response = await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: 'https://google.com/'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log('Navigated to Google', response.isError ? 'error' : '', response.isError ? response : '');
|
||||||
|
|
||||||
|
if (response.isError)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const response2 = await client.callTool({
|
||||||
|
name: 'browser_type',
|
||||||
|
arguments: {
|
||||||
|
text: 'Browser MCP',
|
||||||
|
submit: true,
|
||||||
|
element: 'combobox "Search" [active] [ref=e44]',
|
||||||
|
ref: 'e44',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log('Typed text', response2.isError ? response2.content : '');
|
||||||
|
|
||||||
|
// console.log('Closing browser...');
|
||||||
|
// const response3 = await client.callTool({
|
||||||
|
// name: 'browser_close',
|
||||||
|
// arguments: {}
|
||||||
|
// });
|
||||||
|
// console.log('Closed browser');
|
||||||
|
// console.log(response3.isError ? 'error' : '', response3.error ? response3.error : '');
|
||||||
|
|
||||||
|
|
||||||
|
// await new Promise(resolve => setTimeout(resolve, 5_000));
|
||||||
|
|
||||||
|
// await transport.terminateSession();
|
||||||
|
await client.close();
|
||||||
|
console.log('Closed MCP client');
|
||||||
|
}
|
||||||
|
|
||||||
|
void connectMCP();
|
||||||
2
src/tools/DEPS.list
Normal file
2
src/tools/DEPS.list
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[*]
|
||||||
|
../utils/
|
||||||
@@ -49,7 +49,6 @@ const resize = defineTabTool({
|
|||||||
},
|
},
|
||||||
|
|
||||||
handle: async (tab, params, response) => {
|
handle: async (tab, params, response) => {
|
||||||
response.addCode(`// Resize browser window to ${params.width}x${params.height}`);
|
|
||||||
response.addCode(`await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`);
|
response.addCode(`await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`);
|
||||||
|
|
||||||
await tab.waitForCompletion(async () => {
|
await tab.waitForCompletion(async () => {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { defineTabTool } from './tool.js';
|
import { defineTabTool } from './tool.js';
|
||||||
import * as javascript from '../javascript.js';
|
import * as javascript from '../utils/codegen.js';
|
||||||
import { generateLocator } from './utils.js';
|
import { generateLocator } from './utils.js';
|
||||||
|
|
||||||
import type * as playwright from 'playwright';
|
import type * as playwright from 'playwright';
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ const uploadFile = defineTabTool({
|
|||||||
if (!modalState)
|
if (!modalState)
|
||||||
throw new Error('No file chooser visible');
|
throw new Error('No file chooser visible');
|
||||||
|
|
||||||
response.addCode(`// Select files for upload`);
|
|
||||||
response.addCode(`await fileChooser.setFiles(${JSON.stringify(params.paths)})`);
|
response.addCode(`await fileChooser.setFiles(${JSON.stringify(params.paths)})`);
|
||||||
|
|
||||||
tab.clearModalState(modalState);
|
tab.clearModalState(modalState);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { z } from 'zod';
|
|||||||
import { defineTabTool } from './tool.js';
|
import { defineTabTool } from './tool.js';
|
||||||
import { elementSchema } from './snapshot.js';
|
import { elementSchema } from './snapshot.js';
|
||||||
import { generateLocator } from './utils.js';
|
import { generateLocator } from './utils.js';
|
||||||
import * as javascript from '../javascript.js';
|
import * as javascript from '../utils/codegen.js';
|
||||||
|
|
||||||
const pressKey = defineTabTool({
|
const pressKey = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
@@ -67,11 +67,9 @@ const type = defineTabTool({
|
|||||||
await tab.waitForCompletion(async () => {
|
await tab.waitForCompletion(async () => {
|
||||||
if (params.slowly) {
|
if (params.slowly) {
|
||||||
response.setIncludeSnapshot();
|
response.setIncludeSnapshot();
|
||||||
response.addCode(`// Press "${params.text}" sequentially into "${params.element}"`);
|
|
||||||
response.addCode(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`);
|
response.addCode(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`);
|
||||||
await locator.pressSequentially(params.text);
|
await locator.pressSequentially(params.text);
|
||||||
} else {
|
} else {
|
||||||
response.addCode(`// Fill "${params.text}" into "${params.element}"`);
|
|
||||||
response.addCode(`await page.${await generateLocator(locator)}.fill(${javascript.quote(params.text)});`);
|
response.addCode(`await page.${await generateLocator(locator)}.fill(${javascript.quote(params.text)});`);
|
||||||
await locator.fill(params.text);
|
await locator.fill(params.text);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ const navigate = defineTool({
|
|||||||
await tab.navigate(params.url);
|
await tab.navigate(params.url);
|
||||||
|
|
||||||
response.setIncludeSnapshot();
|
response.setIncludeSnapshot();
|
||||||
response.addCode(`// Navigate to ${params.url}`);
|
|
||||||
response.addCode(`await page.goto('${params.url}');`);
|
response.addCode(`await page.goto('${params.url}');`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -53,30 +52,11 @@ const goBack = defineTabTool({
|
|||||||
handle: async (tab, params, response) => {
|
handle: async (tab, params, response) => {
|
||||||
await tab.page.goBack();
|
await tab.page.goBack();
|
||||||
response.setIncludeSnapshot();
|
response.setIncludeSnapshot();
|
||||||
response.addCode(`// Navigate back`);
|
|
||||||
response.addCode(`await page.goBack();`);
|
response.addCode(`await page.goBack();`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const goForward = defineTabTool({
|
|
||||||
capability: 'core',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_navigate_forward',
|
|
||||||
title: 'Go forward',
|
|
||||||
description: 'Go forward to the next page',
|
|
||||||
inputSchema: z.object({}),
|
|
||||||
type: 'readOnly',
|
|
||||||
},
|
|
||||||
handle: async (tab, params, response) => {
|
|
||||||
await tab.page.goForward();
|
|
||||||
response.setIncludeSnapshot();
|
|
||||||
response.addCode(`// Navigate forward`);
|
|
||||||
response.addCode(`await page.goForward();`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
navigate,
|
navigate,
|
||||||
goBack,
|
goBack,
|
||||||
goForward,
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -17,8 +17,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTabTool } from './tool.js';
|
import { defineTabTool } from './tool.js';
|
||||||
|
|
||||||
import * as javascript from '../javascript.js';
|
import * as javascript from '../utils/codegen.js';
|
||||||
import { outputFile } from '../config.js';
|
|
||||||
|
|
||||||
const pdfSchema = z.object({
|
const pdfSchema = z.object({
|
||||||
filename: z.string().optional().describe('File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.'),
|
filename: z.string().optional().describe('File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.'),
|
||||||
@@ -36,8 +35,7 @@ const pdf = defineTabTool({
|
|||||||
},
|
},
|
||||||
|
|
||||||
handle: async (tab, params, response) => {
|
handle: async (tab, params, response) => {
|
||||||
const fileName = await outputFile(tab.context.config, params.filename ?? `page-${new Date().toISOString()}.pdf`);
|
const fileName = await tab.context.outputFile(params.filename ?? `page-${new Date().toISOString()}.pdf`);
|
||||||
response.addCode(`// Save page as ${fileName}`);
|
|
||||||
response.addCode(`await page.pdf(${javascript.formatObject({ path: fileName })});`);
|
response.addCode(`await page.pdf(${javascript.formatObject({ path: fileName })});`);
|
||||||
response.addResult(`Saved page as ${fileName}`);
|
response.addResult(`Saved page as ${fileName}`);
|
||||||
await tab.page.pdf({ path: fileName });
|
await tab.page.pdf({ path: fileName });
|
||||||
|
|||||||
@@ -17,14 +17,13 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { defineTabTool } from './tool.js';
|
import { defineTabTool } from './tool.js';
|
||||||
import * as javascript from '../javascript.js';
|
import * as javascript from '../utils/codegen.js';
|
||||||
import { outputFile } from '../config.js';
|
|
||||||
import { generateLocator } from './utils.js';
|
import { generateLocator } from './utils.js';
|
||||||
|
|
||||||
import type * as playwright from 'playwright';
|
import type * as playwright from 'playwright';
|
||||||
|
|
||||||
const screenshotSchema = z.object({
|
const screenshotSchema = z.object({
|
||||||
raw: z.boolean().optional().describe('Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.'),
|
type: z.enum(['png', 'jpeg']).default('png').describe('Image format for the screenshot. Default is png.'),
|
||||||
filename: z.string().optional().describe('File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.'),
|
filename: z.string().optional().describe('File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.'),
|
||||||
element: z.string().optional().describe('Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.'),
|
element: z.string().optional().describe('Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.'),
|
||||||
ref: z.string().optional().describe('Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.'),
|
ref: z.string().optional().describe('Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.'),
|
||||||
@@ -52,11 +51,11 @@ const screenshot = defineTabTool({
|
|||||||
},
|
},
|
||||||
|
|
||||||
handle: async (tab, params, response) => {
|
handle: async (tab, params, response) => {
|
||||||
const fileType = params.raw ? 'png' : 'jpeg';
|
const fileType = params.type || 'png';
|
||||||
const fileName = await outputFile(tab.context.config, params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
|
const fileName = await tab.context.outputFile(params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
|
||||||
const options: playwright.PageScreenshotOptions = {
|
const options: playwright.PageScreenshotOptions = {
|
||||||
type: fileType,
|
type: fileType,
|
||||||
quality: fileType === 'png' ? undefined : 50,
|
quality: fileType === 'png' ? undefined : 90,
|
||||||
scale: 'css',
|
scale: 'css',
|
||||||
path: fileName,
|
path: fileName,
|
||||||
...(params.fullPage !== undefined && { fullPage: params.fullPage })
|
...(params.fullPage !== undefined && { fullPage: params.fullPage })
|
||||||
@@ -76,10 +75,15 @@ const screenshot = defineTabTool({
|
|||||||
|
|
||||||
const buffer = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
|
const buffer = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
|
||||||
response.addResult(`Took the ${screenshotTarget} screenshot and saved it as ${fileName}`);
|
response.addResult(`Took the ${screenshotTarget} screenshot and saved it as ${fileName}`);
|
||||||
response.addImage({
|
|
||||||
contentType: fileType === 'png' ? 'image/png' : 'image/jpeg',
|
// https://github.com/microsoft/playwright-mcp/issues/817
|
||||||
data: buffer
|
// Never return large images to LLM, saving them to the file system is enough.
|
||||||
});
|
if (!params.fullPage) {
|
||||||
|
response.addImage({
|
||||||
|
contentType: fileType === 'png' ? 'image/png' : 'image/jpeg',
|
||||||
|
data: buffer
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { defineTabTool, defineTool } from './tool.js';
|
import { defineTabTool, defineTool } from './tool.js';
|
||||||
import * as javascript from '../javascript.js';
|
import * as javascript from '../utils/codegen.js';
|
||||||
import { generateLocator } from './utils.js';
|
import { generateLocator } from './utils.js';
|
||||||
|
|
||||||
const snapshot = defineTool({
|
const snapshot = defineTool({
|
||||||
@@ -63,13 +63,11 @@ const click = defineTabTool({
|
|||||||
const button = params.button;
|
const button = params.button;
|
||||||
const buttonAttr = button ? `{ button: '${button}' }` : '';
|
const buttonAttr = button ? `{ button: '${button}' }` : '';
|
||||||
|
|
||||||
if (params.doubleClick) {
|
if (params.doubleClick)
|
||||||
response.addCode(`// Double click ${params.element}`);
|
|
||||||
response.addCode(`await page.${await generateLocator(locator)}.dblclick(${buttonAttr});`);
|
response.addCode(`await page.${await generateLocator(locator)}.dblclick(${buttonAttr});`);
|
||||||
} else {
|
else
|
||||||
response.addCode(`// Click ${params.element}`);
|
|
||||||
response.addCode(`await page.${await generateLocator(locator)}.click(${buttonAttr});`);
|
response.addCode(`await page.${await generateLocator(locator)}.click(${buttonAttr});`);
|
||||||
}
|
|
||||||
|
|
||||||
await tab.waitForCompletion(async () => {
|
await tab.waitForCompletion(async () => {
|
||||||
if (params.doubleClick)
|
if (params.doubleClick)
|
||||||
@@ -151,7 +149,6 @@ const selectOption = defineTabTool({
|
|||||||
response.setIncludeSnapshot();
|
response.setIncludeSnapshot();
|
||||||
|
|
||||||
const locator = await tab.refLocator(params);
|
const locator = await tab.refLocator(params);
|
||||||
response.addCode(`// Select options [${params.values.join(', ')}] in ${params.element}`);
|
|
||||||
response.addCode(`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`);
|
response.addCode(`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`);
|
||||||
|
|
||||||
await tab.waitForCompletion(async () => {
|
await tab.waitForCompletion(async () => {
|
||||||
|
|||||||
@@ -17,85 +17,48 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool } from './tool.js';
|
import { defineTool } from './tool.js';
|
||||||
|
|
||||||
const listTabs = defineTool({
|
const browserTabs = defineTool({
|
||||||
capability: 'core-tabs',
|
capability: 'core-tabs',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_tab_list',
|
name: 'browser_tabs',
|
||||||
title: 'List tabs',
|
title: 'Manage tabs',
|
||||||
description: 'List browser tabs',
|
description: 'List, create, close, or select a browser tab.',
|
||||||
inputSchema: z.object({}),
|
|
||||||
type: 'readOnly',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params, response) => {
|
|
||||||
await context.ensureTab();
|
|
||||||
response.setIncludeTabs();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectTab = defineTool({
|
|
||||||
capability: 'core-tabs',
|
|
||||||
|
|
||||||
schema: {
|
|
||||||
name: 'browser_tab_select',
|
|
||||||
title: 'Select a tab',
|
|
||||||
description: 'Select a tab by index',
|
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
index: z.number().describe('The index of the tab to select'),
|
action: z.enum(['list', 'new', 'close', 'select']).describe('Operation to perform'),
|
||||||
}),
|
index: z.number().optional().describe('Tab index, used for close/select. If omitted for close, current tab is closed.'),
|
||||||
type: 'readOnly',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params, response) => {
|
|
||||||
await context.selectTab(params.index);
|
|
||||||
response.setIncludeSnapshot();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const newTab = defineTool({
|
|
||||||
capability: 'core-tabs',
|
|
||||||
|
|
||||||
schema: {
|
|
||||||
name: 'browser_tab_new',
|
|
||||||
title: 'Open a new tab',
|
|
||||||
description: 'Open a new tab',
|
|
||||||
inputSchema: z.object({
|
|
||||||
url: z.string().optional().describe('The URL to navigate to in the new tab. If not provided, the new tab will be blank.'),
|
|
||||||
}),
|
|
||||||
type: 'readOnly',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params, response) => {
|
|
||||||
const tab = await context.newTab();
|
|
||||||
if (params.url)
|
|
||||||
await tab.navigate(params.url);
|
|
||||||
response.setIncludeSnapshot();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const closeTab = defineTool({
|
|
||||||
capability: 'core-tabs',
|
|
||||||
|
|
||||||
schema: {
|
|
||||||
name: 'browser_tab_close',
|
|
||||||
title: 'Close a tab',
|
|
||||||
description: 'Close a tab',
|
|
||||||
inputSchema: z.object({
|
|
||||||
index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
|
|
||||||
}),
|
}),
|
||||||
type: 'destructive',
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params, response) => {
|
handle: async (context, params, response) => {
|
||||||
await context.closeTab(params.index);
|
switch (params.action) {
|
||||||
response.setIncludeSnapshot();
|
case 'list': {
|
||||||
|
await context.ensureTab();
|
||||||
|
response.setIncludeTabs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'new': {
|
||||||
|
await context.newTab();
|
||||||
|
response.setIncludeTabs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'close': {
|
||||||
|
await context.closeTab(params.index);
|
||||||
|
response.setIncludeSnapshot();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'select': {
|
||||||
|
if (!params.index)
|
||||||
|
throw new Error('Tab index is required');
|
||||||
|
await context.selectTab(params.index);
|
||||||
|
response.setIncludeSnapshot();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
listTabs,
|
browserTabs,
|
||||||
newTab,
|
|
||||||
selectTab,
|
|
||||||
closeTab,
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import type * as playwright from 'playwright';
|
|||||||
import type { ToolCapability } from '../../config.js';
|
import type { ToolCapability } from '../../config.js';
|
||||||
import type { Tab } from '../tab.js';
|
import type { Tab } from '../tab.js';
|
||||||
import type { Response } from '../response.js';
|
import type { Response } from '../response.js';
|
||||||
import type { ToolSchema } from '../mcp/server.js';
|
import type { ToolSchema } from '../mcp/tool.js';
|
||||||
|
|
||||||
export type FileUploadModalState = {
|
export type FileUploadModalState = {
|
||||||
type: 'fileChooser';
|
type: 'fileChooser';
|
||||||
@@ -60,10 +60,11 @@ export function defineTabTool<Input extends z.Schema>(tool: TabTool<Input>): Too
|
|||||||
const tab = context.currentTabOrDie();
|
const tab = context.currentTabOrDie();
|
||||||
const modalStates = tab.modalStates().map(state => state.type);
|
const modalStates = tab.modalStates().map(state => state.type);
|
||||||
if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState))
|
if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState))
|
||||||
throw new Error(`The tool "${tool.schema.name}" can only be used when there is related modal state present.\n` + tab.modalStatesMarkdown().join('\n'));
|
response.addError(`Error: The tool "${tool.schema.name}" can only be used when there is related modal state present.\n` + tab.modalStatesMarkdown().join('\n'));
|
||||||
if (!tool.clearsModalState && modalStates.length)
|
else if (!tool.clearsModalState && modalStates.length)
|
||||||
throw new Error(`Tool "${tool.schema.name}" does not handle the modal state.\n` + tab.modalStatesMarkdown().join('\n'));
|
response.addError(`Error: Tool "${tool.schema.name}" does not handle the modal state.\n` + tab.modalStatesMarkdown().join('\n'));
|
||||||
return tool.handle(tab, params, response);
|
else
|
||||||
|
return tool.handle(tab, params, response);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,14 +71,6 @@ export async function waitForCompletion<R>(tab: Tab, callback: () => Promise<R>)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeForFilePath(s: string) {
|
|
||||||
const sanitize = (s: string) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
|
|
||||||
const separator = s.lastIndexOf('.');
|
|
||||||
if (separator === -1)
|
|
||||||
return sanitize(s);
|
|
||||||
return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const { resolvedSelector } = await (locator as any)._resolveSelector();
|
const { resolvedSelector } = await (locator as any)._resolveSelector();
|
||||||
|
|||||||
@@ -36,10 +36,8 @@ const wait = defineTool({
|
|||||||
if (!params.text && !params.textGone && !params.time)
|
if (!params.text && !params.textGone && !params.time)
|
||||||
throw new Error('Either time, text or textGone must be provided');
|
throw new Error('Either time, text or textGone must be provided');
|
||||||
|
|
||||||
const code: string[] = [];
|
|
||||||
|
|
||||||
if (params.time) {
|
if (params.time) {
|
||||||
code.push(`await new Promise(f => setTimeout(f, ${params.time!} * 1000));`);
|
response.addCode(`await new Promise(f => setTimeout(f, ${params.time!} * 1000));`);
|
||||||
await new Promise(f => setTimeout(f, Math.min(30000, params.time! * 1000)));
|
await new Promise(f => setTimeout(f, Math.min(30000, params.time! * 1000)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,12 +46,12 @@ const wait = defineTool({
|
|||||||
const goneLocator = params.textGone ? tab.page.getByText(params.textGone).first() : undefined;
|
const goneLocator = params.textGone ? tab.page.getByText(params.textGone).first() : undefined;
|
||||||
|
|
||||||
if (goneLocator) {
|
if (goneLocator) {
|
||||||
code.push(`await page.getByText(${JSON.stringify(params.textGone)}).first().waitFor({ state: 'hidden' });`);
|
response.addCode(`await page.getByText(${JSON.stringify(params.textGone)}).first().waitFor({ state: 'hidden' });`);
|
||||||
await goneLocator.waitFor({ state: 'hidden' });
|
await goneLocator.waitFor({ state: 'hidden' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (locator) {
|
if (locator) {
|
||||||
code.push(`await page.getByText(${JSON.stringify(params.text)}).first().waitFor({ state: 'visible' });`);
|
response.addCode(`await page.getByText(${JSON.stringify(params.text)}).first().waitFor({ state: 'visible' });`);
|
||||||
await locator.waitFor({ state: 'visible' });
|
await locator.waitFor({ state: 'visible' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function escapeWithQuotes(text: string, char: string = '\'') {
|
|||||||
if (char === '"')
|
if (char === '"')
|
||||||
return char + escapedText.replace(/["]/g, '\\"') + char;
|
return char + escapedText.replace(/["]/g, '\\"') + char;
|
||||||
if (char === '`')
|
if (char === '`')
|
||||||
return char + escapedText.replace(/[`]/g, '`') + char;
|
return char + escapedText.replace(/[`]/g, '\\`') + char;
|
||||||
throw new Error('Invalid escape char');
|
throw new Error('Invalid escape char');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,8 +17,6 @@
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import type { FullConfig } from './config.js';
|
|
||||||
|
|
||||||
export function cacheDir() {
|
export function cacheDir() {
|
||||||
let cacheDirectory: string;
|
let cacheDirectory: string;
|
||||||
if (process.platform === 'linux')
|
if (process.platform === 'linux')
|
||||||
@@ -32,6 +30,10 @@ export function cacheDir() {
|
|||||||
return path.join(cacheDirectory, 'ms-playwright');
|
return path.join(cacheDirectory, 'ms-playwright');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function userDataDir(browserConfig: FullConfig['browser']) {
|
export function sanitizeForFilePath(s: string) {
|
||||||
return path.join(cacheDir(), 'ms-playwright', `mcp-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
|
const sanitize = (s: string) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
|
||||||
|
const separator = s.lastIndexOf('.');
|
||||||
|
if (separator === -1)
|
||||||
|
return sanitize(s);
|
||||||
|
return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
|
||||||
}
|
}
|
||||||
25
src/utils/guid.ts
Normal file
25
src/utils/guid.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* 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 crypto from 'crypto';
|
||||||
|
|
||||||
|
export function createGuid(): string {
|
||||||
|
return crypto.randomBytes(16).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHash(data: string): string {
|
||||||
|
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 7);
|
||||||
|
}
|
||||||
@@ -19,4 +19,4 @@ import path from 'path';
|
|||||||
import url from 'url';
|
import url from 'url';
|
||||||
|
|
||||||
const __filename = url.fileURLToPath(import.meta.url);
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8'));
|
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', '..', 'package.json'), 'utf8'));
|
||||||
6
src/vscode/DEPS.list
Normal file
6
src/vscode/DEPS.list
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[*]
|
||||||
|
../mcp/
|
||||||
|
../utils/
|
||||||
|
../config.js
|
||||||
|
../browserServerBackend.js
|
||||||
|
../browserContextFactory.js
|
||||||
149
src/vscode/host.ts
Normal file
149
src/vscode/host.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* 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 { fileURLToPath } from 'url';
|
||||||
|
import path from 'path';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||||
|
|
||||||
|
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { ListRootsRequestSchema, PingRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||||
|
import * as mcpServer from '../mcp/server.js';
|
||||||
|
import { logUnhandledError } from '../utils/log.js';
|
||||||
|
import { packageJSON } from '../utils/package.js';
|
||||||
|
|
||||||
|
import { FullConfig } from '../config.js';
|
||||||
|
import { BrowserServerBackend } from '../browserServerBackend.js';
|
||||||
|
import { contextFactory } from '../browserContextFactory.js';
|
||||||
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
|
import type { ClientVersion, ServerBackend } from '../mcp/server.js';
|
||||||
|
import type { Root, Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
|
const contextSwitchOptions = z.object({
|
||||||
|
connectionString: z.string().optional().describe('The connection string to use to connect to the browser'),
|
||||||
|
lib: z.string().optional().describe('The library to use for the connection'),
|
||||||
|
});
|
||||||
|
|
||||||
|
class VSCodeProxyBackend implements ServerBackend {
|
||||||
|
name = 'Playwright MCP Client Switcher';
|
||||||
|
version = packageJSON.version;
|
||||||
|
|
||||||
|
private _currentClient: Client | undefined;
|
||||||
|
private _contextSwitchTool: Tool;
|
||||||
|
private _roots: Root[] = [];
|
||||||
|
private _clientVersion?: ClientVersion;
|
||||||
|
|
||||||
|
constructor(private readonly _config: FullConfig, private readonly _defaultTransportFactory: () => Promise<Transport>) {
|
||||||
|
this._contextSwitchTool = this._defineContextSwitchTool();
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(server: mcpServer.Server, clientVersion: ClientVersion, roots: Root[]): Promise<void> {
|
||||||
|
this._clientVersion = clientVersion;
|
||||||
|
this._roots = roots;
|
||||||
|
const transport = await this._defaultTransportFactory();
|
||||||
|
await this._setCurrentClient(transport);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTools(): Promise<Tool[]> {
|
||||||
|
const response = await this._currentClient!.listTools();
|
||||||
|
return [
|
||||||
|
...response.tools,
|
||||||
|
this._contextSwitchTool,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult> {
|
||||||
|
if (name === this._contextSwitchTool.name)
|
||||||
|
return this._callContextSwitchTool(args as any);
|
||||||
|
return await this._currentClient!.callTool({
|
||||||
|
name,
|
||||||
|
arguments: args,
|
||||||
|
}) as CallToolResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
serverClosed?(server: mcpServer.Server): void {
|
||||||
|
void this._currentClient?.close().catch(logUnhandledError);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _callContextSwitchTool(params: z.infer<typeof contextSwitchOptions>): Promise<CallToolResult> {
|
||||||
|
if (!params.connectionString || !params.lib) {
|
||||||
|
const transport = await this._defaultTransportFactory();
|
||||||
|
await this._setCurrentClient(transport);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: '### Result\nSuccessfully disconnected.\n' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._setCurrentClient(
|
||||||
|
new StdioClientTransport({
|
||||||
|
command: process.execPath,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
args: [
|
||||||
|
path.join(fileURLToPath(import.meta.url), '..', 'main.js'),
|
||||||
|
JSON.stringify(this._config),
|
||||||
|
params.connectionString,
|
||||||
|
params.lib,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: '### Result\nSuccessfully connected.\n' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private _defineContextSwitchTool(): Tool {
|
||||||
|
return {
|
||||||
|
name: 'browser_connect',
|
||||||
|
description: 'Do not call, this tool is used in the integration with the Playwright VS Code Extension and meant for programmatic usage only.',
|
||||||
|
inputSchema: zodToJsonSchema(contextSwitchOptions, { strictUnions: true }) as Tool['inputSchema'],
|
||||||
|
annotations: {
|
||||||
|
title: 'Connect to a browser running in VS Code.',
|
||||||
|
readOnlyHint: true,
|
||||||
|
openWorldHint: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _setCurrentClient(transport: Transport) {
|
||||||
|
await this._currentClient?.close();
|
||||||
|
this._currentClient = undefined;
|
||||||
|
|
||||||
|
const client = new Client(this._clientVersion!);
|
||||||
|
client.registerCapabilities({
|
||||||
|
roots: {
|
||||||
|
listRoots: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
client.setRequestHandler(ListRootsRequestSchema, () => ({ roots: this._roots }));
|
||||||
|
client.setRequestHandler(PingRequestSchema, () => ({}));
|
||||||
|
|
||||||
|
await client.connect(transport);
|
||||||
|
this._currentClient = client;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runVSCodeTools(config: FullConfig) {
|
||||||
|
const serverBackendFactory: mcpServer.ServerBackendFactory = {
|
||||||
|
name: 'Playwright w/ vscode',
|
||||||
|
nameInConfig: 'playwright-vscode',
|
||||||
|
version: packageJSON.version,
|
||||||
|
create: () => new VSCodeProxyBackend(config, () => mcpServer.wrapInProcess(new BrowserServerBackend(config, contextFactory(config))))
|
||||||
|
};
|
||||||
|
await mcpServer.start(serverBackendFactory, config.server);
|
||||||
|
return;
|
||||||
|
}
|
||||||
75
src/vscode/main.ts
Normal file
75
src/vscode/main.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* 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 { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
|
import * as mcpServer from '../mcp/server.js';
|
||||||
|
import { BrowserServerBackend } from '../browserServerBackend.js';
|
||||||
|
import { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js';
|
||||||
|
import type { FullConfig } from '../config.js';
|
||||||
|
import type { BrowserContext } from 'playwright-core';
|
||||||
|
|
||||||
|
class VSCodeBrowserContextFactory implements BrowserContextFactory {
|
||||||
|
name = 'vscode';
|
||||||
|
description = 'Connect to a browser running in the Playwright VS Code extension';
|
||||||
|
|
||||||
|
constructor(private _config: FullConfig, private _playwright: typeof import('playwright'), private _connectionString: string) {}
|
||||||
|
|
||||||
|
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: BrowserContext; close: () => Promise<void>; }> {
|
||||||
|
let launchOptions: any = this._config.browser.launchOptions;
|
||||||
|
if (this._config.browser.userDataDir) {
|
||||||
|
launchOptions = {
|
||||||
|
...launchOptions,
|
||||||
|
...this._config.browser.contextOptions,
|
||||||
|
userDataDir: this._config.browser.userDataDir,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const connectionString = new URL(this._connectionString);
|
||||||
|
connectionString.searchParams.set('launch-options', JSON.stringify(launchOptions));
|
||||||
|
|
||||||
|
const browserType = this._playwright.chromium; // it could also be firefox or webkit, we just need some browser type to call `connect` on
|
||||||
|
const browser = await browserType.connect(connectionString.toString());
|
||||||
|
|
||||||
|
const context = browser.contexts()[0] ?? await browser.newContext(this._config.browser.contextOptions);
|
||||||
|
|
||||||
|
return {
|
||||||
|
browserContext: context,
|
||||||
|
close: async () => {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(config: FullConfig, connectionString: string, lib: string) {
|
||||||
|
const playwright = await import(lib).then(mod => mod.default ?? mod);
|
||||||
|
const factory = new VSCodeBrowserContextFactory(config, playwright, connectionString);
|
||||||
|
await mcpServer.connect(
|
||||||
|
{
|
||||||
|
name: 'Playwright MCP',
|
||||||
|
nameInConfig: 'playwright-vscode',
|
||||||
|
create: () => new BrowserServerBackend(config, factory),
|
||||||
|
version: 'unused'
|
||||||
|
},
|
||||||
|
new StdioServerTransport(),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await main(
|
||||||
|
JSON.parse(process.argv[2]),
|
||||||
|
process.argv[3],
|
||||||
|
process.argv[4]
|
||||||
|
);
|
||||||
@@ -31,16 +31,42 @@ test('test snapshot tool list', async ({ client }) => {
|
|||||||
'browser_close',
|
'browser_close',
|
||||||
'browser_install',
|
'browser_install',
|
||||||
'browser_navigate_back',
|
'browser_navigate_back',
|
||||||
'browser_navigate_forward',
|
|
||||||
'browser_navigate',
|
'browser_navigate',
|
||||||
'browser_network_requests',
|
'browser_network_requests',
|
||||||
'browser_press_key',
|
'browser_press_key',
|
||||||
'browser_resize',
|
'browser_resize',
|
||||||
'browser_snapshot',
|
'browser_snapshot',
|
||||||
'browser_tab_close',
|
'browser_tabs',
|
||||||
'browser_tab_list',
|
'browser_take_screenshot',
|
||||||
'browser_tab_new',
|
'browser_wait_for',
|
||||||
'browser_tab_select',
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test tool list proxy mode', async ({ startClient }) => {
|
||||||
|
const { client } = await startClient({
|
||||||
|
args: ['--connect-tool'],
|
||||||
|
});
|
||||||
|
const { tools } = await client.listTools();
|
||||||
|
expect(new Set(tools.map(t => t.name))).toEqual(new Set([
|
||||||
|
'browser_click',
|
||||||
|
'browser_connect', // the extra tool
|
||||||
|
'browser_console_messages',
|
||||||
|
'browser_drag',
|
||||||
|
'browser_evaluate',
|
||||||
|
'browser_file_upload',
|
||||||
|
'browser_handle_dialog',
|
||||||
|
'browser_hover',
|
||||||
|
'browser_select_option',
|
||||||
|
'browser_type',
|
||||||
|
'browser_close',
|
||||||
|
'browser_install',
|
||||||
|
'browser_navigate_back',
|
||||||
|
'browser_navigate',
|
||||||
|
'browser_network_requests',
|
||||||
|
'browser_press_key',
|
||||||
|
'browser_resize',
|
||||||
|
'browser_snapshot',
|
||||||
|
'browser_tabs',
|
||||||
'browser_take_screenshot',
|
'browser_take_screenshot',
|
||||||
'browser_wait_for',
|
'browser_wait_for',
|
||||||
]));
|
]));
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ test('cdp server', async ({ cdpServer, startClient, server }) => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
|
test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
|
||||||
@@ -41,18 +43,21 @@ test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
|
|||||||
element: 'Hello, world!',
|
element: 'Hello, world!',
|
||||||
ref: 'f0',
|
ref: 'f0',
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`Error: No open pages available. Use the \"browser_navigate\" tool to navigate to a page first.`);
|
})).toHaveResponse({
|
||||||
|
result: `Error: No open pages available. Use the "browser_navigate" tool to navigate to a page first.`,
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_snapshot',
|
name: 'browser_snapshot',
|
||||||
})).toHaveTextContent(`### Page state
|
})).toHaveResponse({
|
||||||
- Page URL: ${server.HELLO_WORLD}
|
pageState: expect.stringContaining(`- Page URL: ${server.HELLO_WORLD}
|
||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
- Page Snapshot:
|
- Page Snapshot:
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [active] [ref=e1]: Hello, world!
|
- generic [active] [ref=e1]: Hello, world!
|
||||||
\`\`\`
|
\`\`\``),
|
||||||
`);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should throw connection error and allow re-connecting', async ({ cdpServer, startClient, server }) => {
|
test('should throw connection error and allow re-connecting', async ({ cdpServer, startClient, server }) => {
|
||||||
@@ -66,12 +71,17 @@ test('should throw connection error and allow re-connecting', async ({ cdpServer
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
})).toContainTextContent(`Error: browserType.connectOverCDP: connect ECONNREFUSED`);
|
})).toHaveResponse({
|
||||||
|
result: expect.stringContaining(`Error: browserType.connectOverCDP: connect ECONNREFUSED`),
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
await cdpServer.start();
|
await cdpServer.start();
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||||
|
|||||||
@@ -33,21 +33,10 @@ test('browser_click', async ({ client, server, mcpBrowser }) => {
|
|||||||
element: 'Submit button',
|
element: 'Submit button',
|
||||||
ref: 'e2',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`
|
})).toHaveResponse({
|
||||||
### Ran Playwright code
|
code: `await page.getByRole('button', { name: 'Submit' }).click();`,
|
||||||
\`\`\`js
|
pageState: expect.stringContaining(`- button "Submit" ${mcpBrowser !== 'webkit' || process.platform === 'linux' ? '[active] ' : ''}[ref=e2]`),
|
||||||
// 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 }) => {
|
test('browser_click (double)', async ({ client, server }) => {
|
||||||
@@ -73,21 +62,10 @@ test('browser_click (double)', async ({ client, server }) => {
|
|||||||
ref: 'e2',
|
ref: 'e2',
|
||||||
doubleClick: true,
|
doubleClick: true,
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`
|
})).toHaveResponse({
|
||||||
### Ran Playwright code
|
code: `await page.getByRole('heading', { name: 'Click me' }).dblclick();`,
|
||||||
\`\`\`js
|
pageState: expect.stringContaining(`- heading "Double clicked" [level=1] [ref=e3]`),
|
||||||
// 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 }) => {
|
test('browser_click (right)', async ({ client, server }) => {
|
||||||
@@ -114,6 +92,8 @@ test('browser_click (right)', async ({ client, server }) => {
|
|||||||
button: 'right',
|
button: 'right',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result).toContainTextContent(`await page.getByRole('button', { name: 'Menu' }).click({ button: 'right' });`);
|
expect(result).toHaveResponse({
|
||||||
expect(result).toContainTextContent(`- button "Right clicked"`);
|
code: `await page.getByRole('button', { name: 'Menu' }).click({ button: 'right' });`,
|
||||||
|
pageState: expect.stringContaining(`- button "Right clicked"`),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ test('config user data dir', async ({ startClient, server, mcpMode }, testInfo)
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
})).toContainTextContent(`Hello, world!`);
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`Hello, world!`),
|
||||||
|
});
|
||||||
|
|
||||||
const files = await fs.promises.readdir(config.browser!.userDataDir!);
|
const files = await fs.promises.readdir(config.browser!.userDataDir!);
|
||||||
expect(files.length).toBeGreaterThan(0);
|
expect(files.length).toBeGreaterThan(0);
|
||||||
@@ -58,7 +60,9 @@ test.describe(() => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: 'data:text/html,<script>document.title = navigator.userAgent</script>' },
|
arguments: { url: 'data:text/html,<script>document.title = navigator.userAgent</script>' },
|
||||||
})).toContainTextContent(`Firefox`);
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`Firefox`),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -37,11 +37,10 @@ test('browser_console_messages', async ({ client, server }) => {
|
|||||||
const resource = await client.callTool({
|
const resource = await client.callTool({
|
||||||
name: 'browser_console_messages',
|
name: 'browser_console_messages',
|
||||||
});
|
});
|
||||||
expect(resource).toHaveTextContent([
|
expect(resource).toHaveResponse({
|
||||||
'### Result',
|
result: `[LOG] Hello, world! @ ${server.PREFIX}:4
|
||||||
`[LOG] Hello, world! @ ${server.PREFIX}:4`,
|
[ERROR] Error @ ${server.PREFIX}:5`,
|
||||||
`[ERROR] Error @ ${server.PREFIX}:5`,
|
});
|
||||||
].join('\n'));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_console_messages (page error)', async ({ client, server }) => {
|
test('browser_console_messages (page error)', async ({ client, server }) => {
|
||||||
@@ -64,8 +63,12 @@ test('browser_console_messages (page error)', async ({ client, server }) => {
|
|||||||
const resource = await client.callTool({
|
const resource = await client.callTool({
|
||||||
name: 'browser_console_messages',
|
name: 'browser_console_messages',
|
||||||
});
|
});
|
||||||
expect(resource).toHaveTextContent(/Error: Error in script/);
|
expect(resource).toHaveResponse({
|
||||||
expect(resource).toHaveTextContent(new RegExp(server.PREFIX));
|
result: expect.stringContaining(`Error: Error in script`),
|
||||||
|
});
|
||||||
|
expect(resource).toHaveResponse({
|
||||||
|
result: expect.stringContaining(server.PREFIX),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('recent console messages', async ({ client, server }) => {
|
test('recent console messages', async ({ client, server }) => {
|
||||||
@@ -91,7 +94,7 @@ test('recent console messages', async ({ client, server }) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response).toContainTextContent(`
|
expect(response).toHaveResponse({
|
||||||
### New console messages
|
consoleMessages: expect.stringContaining(`- [LOG] Hello, world! @`),
|
||||||
- [LOG] Hello, world! @`);
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,22 +20,15 @@ test('browser_navigate', async ({ client, server }) => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
})).toHaveTextContent(`
|
})).toHaveResponse({
|
||||||
### Ran Playwright code
|
code: `await page.goto('${server.HELLO_WORLD}');`,
|
||||||
\`\`\`js
|
pageState: `- Page URL: ${server.HELLO_WORLD}
|
||||||
// Navigate to ${server.HELLO_WORLD}
|
|
||||||
await page.goto('${server.HELLO_WORLD}');
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Page state
|
|
||||||
- Page URL: ${server.HELLO_WORLD}
|
|
||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
- Page Snapshot:
|
- Page Snapshot:
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [active] [ref=e1]: Hello, world!
|
- generic [active] [ref=e1]: Hello, world!
|
||||||
\`\`\`
|
\`\`\``,
|
||||||
`
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_select_option', async ({ client, server }) => {
|
test('browser_select_option', async ({ client, server }) => {
|
||||||
@@ -59,23 +52,17 @@ test('browser_select_option', async ({ client, server }) => {
|
|||||||
ref: 'e2',
|
ref: 'e2',
|
||||||
values: ['bar'],
|
values: ['bar'],
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`
|
})).toHaveResponse({
|
||||||
### Ran Playwright code
|
code: `await page.getByRole('combobox').selectOption(['bar']);`,
|
||||||
\`\`\`js
|
pageState: `- Page URL: ${server.PREFIX}
|
||||||
// Select options [bar] in Select
|
|
||||||
await page.getByRole('combobox').selectOption(['bar']);
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Page state
|
|
||||||
- Page URL: ${server.PREFIX}
|
|
||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
- Page Snapshot:
|
- Page Snapshot:
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- combobox [ref=e2]:
|
- combobox [ref=e2]:
|
||||||
- option "Foo"
|
- option "Foo"
|
||||||
- option "Bar" [selected]
|
- option "Bar" [selected]
|
||||||
\`\`\`
|
\`\`\``,
|
||||||
`);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_select_option (multiple)', async ({ client, server }) => {
|
test('browser_select_option (multiple)', async ({ client, server }) => {
|
||||||
@@ -100,24 +87,14 @@ test('browser_select_option (multiple)', async ({ client, server }) => {
|
|||||||
ref: 'e2',
|
ref: 'e2',
|
||||||
values: ['bar', 'baz'],
|
values: ['bar', 'baz'],
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`
|
})).toHaveResponse({
|
||||||
### Ran Playwright code
|
code: `await page.getByRole('listbox').selectOption(['bar', 'baz']);`,
|
||||||
\`\`\`js
|
pageState: expect.stringContaining(`
|
||||||
// Select options [bar, baz] in Select
|
|
||||||
await page.getByRole('listbox').selectOption(['bar', 'baz']);
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Page state
|
|
||||||
- Page URL: ${server.PREFIX}
|
|
||||||
- Page Title: Title
|
|
||||||
- Page Snapshot:
|
|
||||||
\`\`\`yaml
|
|
||||||
- listbox [ref=e2]:
|
- listbox [ref=e2]:
|
||||||
- option "Foo" [ref=e3]
|
- option "Foo" [ref=e3]
|
||||||
- option "Bar" [selected] [ref=e4]
|
- option "Bar" [selected] [ref=e4]
|
||||||
- option "Baz" [selected] [ref=e5]
|
- option "Baz" [selected] [ref=e5]`),
|
||||||
\`\`\`
|
});
|
||||||
`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_resize', async ({ client, server }) => {
|
test('browser_resize', async ({ client, server }) => {
|
||||||
@@ -141,12 +118,12 @@ test('browser_resize', async ({ client, server }) => {
|
|||||||
height: 780,
|
height: 780,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(response).toContainTextContent(`### Ran Playwright code
|
expect(response).toHaveResponse({
|
||||||
\`\`\`js
|
code: `await page.setViewportSize({ width: 390, height: 780 });`,
|
||||||
// Resize browser window to 390x780
|
});
|
||||||
await page.setViewportSize({ width: 390, height: 780 });
|
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toHaveResponse({
|
||||||
\`\`\``);
|
pageState: expect.stringContaining(`Window size: 390x780`),
|
||||||
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent('Window size: 390x780');
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('old locator error message', async ({ client, server }) => {
|
test('old locator error message', async ({ client, server }) => {
|
||||||
@@ -165,10 +142,11 @@ test('old locator error message', async ({ client, server }) => {
|
|||||||
arguments: {
|
arguments: {
|
||||||
url: server.PREFIX,
|
url: server.PREFIX,
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`
|
||||||
- button "Button 1" [ref=e2]
|
- button "Button 1" [ref=e2]
|
||||||
- button "Button 2" [ref=e3]
|
- button "Button 2" [ref=e3]`),
|
||||||
`.trim());
|
});
|
||||||
|
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
@@ -184,7 +162,10 @@ test('old locator error message', async ({ client, server }) => {
|
|||||||
element: 'Button 2',
|
element: 'Button 2',
|
||||||
ref: 'e3',
|
ref: 'e3',
|
||||||
},
|
},
|
||||||
})).toContainTextContent('Ref e3 not found in the current page snapshot. Try capturing new snapshot.');
|
})).toHaveResponse({
|
||||||
|
result: expect.stringContaining(`Ref e3 not found in the current page snapshot. Try capturing new snapshot.`),
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('visibility: hidden > visible should be shown', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/535' } }, async ({ client, server }) => {
|
test('visibility: hidden > visible should be shown', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/535' } }, async ({ client, server }) => {
|
||||||
@@ -203,5 +184,7 @@ test('visibility: hidden > visible should be shown', { annotation: { type: 'issu
|
|||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_snapshot'
|
name: 'browser_snapshot'
|
||||||
})).toContainTextContent('- button "Button"');
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- button "Button"`),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,5 +39,7 @@ test('--device should work', async ({ startClient, server, mcpMode }) => {
|
|||||||
arguments: {
|
arguments: {
|
||||||
url: server.PREFIX,
|
url: server.PREFIX,
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`393x659`);
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`393x659`),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ test('alert dialog', async ({ client, server }) => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
})).toContainTextContent('- button "Button" [ref=e2]');
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- button "Button" [ref=e2]`),
|
||||||
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
@@ -29,25 +31,31 @@ test('alert dialog', async ({ client, server }) => {
|
|||||||
element: 'Button',
|
element: 'Button',
|
||||||
ref: 'e2',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`### Ran Playwright code
|
})).toHaveResponse({
|
||||||
\`\`\`js
|
code: `await page.getByRole('button', { name: 'Button' }).click();`,
|
||||||
// Click Button
|
modalState: `- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`,
|
||||||
await page.getByRole('button', { name: 'Button' }).click();
|
});
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Modal state
|
expect(await client.callTool({
|
||||||
- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool
|
name: 'browser_click',
|
||||||
`);
|
arguments: {
|
||||||
|
element: 'Button',
|
||||||
|
ref: 'e2',
|
||||||
|
},
|
||||||
|
})).toHaveResponse({
|
||||||
|
code: undefined,
|
||||||
|
modalState: `- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`,
|
||||||
|
});
|
||||||
|
|
||||||
const result = await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_handle_dialog',
|
name: 'browser_handle_dialog',
|
||||||
arguments: {
|
arguments: {
|
||||||
accept: true,
|
accept: true,
|
||||||
},
|
},
|
||||||
|
})).toHaveResponse({
|
||||||
|
modalState: undefined,
|
||||||
|
pageState: expect.stringContaining(`- button "Button"`),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).not.toContainTextContent('### Modal state');
|
|
||||||
expect(result).toContainTextContent(`Page Snapshot:`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('two alert dialogs', async ({ client, server }) => {
|
test('two alert dialogs', async ({ client, server }) => {
|
||||||
@@ -61,7 +69,9 @@ test('two alert dialogs', async ({ client, server }) => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
})).toContainTextContent('- button "Button" [ref=e2]');
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- button "Button" [ref=e2]`),
|
||||||
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
@@ -69,15 +79,10 @@ test('two alert dialogs', async ({ client, server }) => {
|
|||||||
element: 'Button',
|
element: 'Button',
|
||||||
ref: 'e2',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`### Ran Playwright code
|
})).toHaveResponse({
|
||||||
\`\`\`js
|
code: `await page.getByRole('button', { name: 'Button' }).click();`,
|
||||||
// Click Button
|
modalState: expect.stringContaining(`- ["alert" dialog with message "Alert 1"]: can be handled by the "browser_handle_dialog" tool`),
|
||||||
await page.getByRole('button', { name: 'Button' }).click();
|
});
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Modal state
|
|
||||||
- ["alert" dialog with message "Alert 1"]: can be handled by the "browser_handle_dialog" tool
|
|
||||||
`);
|
|
||||||
|
|
||||||
const result = await client.callTool({
|
const result = await client.callTool({
|
||||||
name: 'browser_handle_dialog',
|
name: 'browser_handle_dialog',
|
||||||
@@ -86,9 +91,9 @@ await page.getByRole('button', { name: 'Button' }).click();
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toContainTextContent(`### Modal state
|
expect(result).toHaveResponse({
|
||||||
- ["alert" dialog with message "Alert 2"]: can be handled by the "browser_handle_dialog" tool
|
modalState: expect.stringContaining(`- ["alert" dialog with message "Alert 2"]: can be handled by the "browser_handle_dialog" tool`),
|
||||||
`);
|
});
|
||||||
|
|
||||||
const result2 = await client.callTool({
|
const result2 = await client.callTool({
|
||||||
name: 'browser_handle_dialog',
|
name: 'browser_handle_dialog',
|
||||||
@@ -97,7 +102,9 @@ await page.getByRole('button', { name: 'Button' }).click();
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result2).not.toContainTextContent('### Modal state');
|
expect(result2).not.toHaveResponse({
|
||||||
|
modalState: expect.stringContaining(`- ["alert" dialog with message "Alert 2"]: can be handled by the "browser_handle_dialog" tool`),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('confirm dialog (true)', async ({ client, server }) => {
|
test('confirm dialog (true)', async ({ client, server }) => {
|
||||||
@@ -111,7 +118,9 @@ test('confirm dialog (true)', async ({ client, server }) => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
})).toContainTextContent('- button "Button" [ref=e2]');
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- button "Button" [ref=e2]`),
|
||||||
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
@@ -119,21 +128,19 @@ test('confirm dialog (true)', async ({ client, server }) => {
|
|||||||
element: 'Button',
|
element: 'Button',
|
||||||
ref: 'e2',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`### Modal state
|
})).toHaveResponse({
|
||||||
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
|
modalState: expect.stringContaining(`- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`),
|
||||||
|
});
|
||||||
|
|
||||||
const result = await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_handle_dialog',
|
name: 'browser_handle_dialog',
|
||||||
arguments: {
|
arguments: {
|
||||||
accept: true,
|
accept: true,
|
||||||
},
|
},
|
||||||
|
})).toHaveResponse({
|
||||||
|
modalState: undefined,
|
||||||
|
pageState: expect.stringContaining(`- generic [active] [ref=e1]: "true"`),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).not.toContainTextContent('### Modal state');
|
|
||||||
expect(result).toContainTextContent(`- Page Snapshot:
|
|
||||||
\`\`\`yaml
|
|
||||||
- generic [active] [ref=e1]: "true"
|
|
||||||
\`\`\``);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('confirm dialog (false)', async ({ client, server }) => {
|
test('confirm dialog (false)', async ({ client, server }) => {
|
||||||
@@ -147,7 +154,9 @@ test('confirm dialog (false)', async ({ client, server }) => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
})).toContainTextContent('- button "Button" [ref=e2]');
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- button "Button" [ref=e2]`),
|
||||||
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
@@ -155,21 +164,19 @@ test('confirm dialog (false)', async ({ client, server }) => {
|
|||||||
element: 'Button',
|
element: 'Button',
|
||||||
ref: 'e2',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`### Modal state
|
})).toHaveResponse({
|
||||||
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool
|
modalState: expect.stringContaining(`- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`),
|
||||||
`);
|
});
|
||||||
|
|
||||||
const result = await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_handle_dialog',
|
name: 'browser_handle_dialog',
|
||||||
arguments: {
|
arguments: {
|
||||||
accept: false,
|
accept: false,
|
||||||
},
|
},
|
||||||
|
})).toHaveResponse({
|
||||||
|
modalState: undefined,
|
||||||
|
pageState: expect.stringContaining(`- generic [active] [ref=e1]: "false"`),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toContainTextContent(`- Page Snapshot:
|
|
||||||
\`\`\`yaml
|
|
||||||
- generic [active] [ref=e1]: "false"
|
|
||||||
\`\`\``);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('prompt dialog', async ({ client, server }) => {
|
test('prompt dialog', async ({ client, server }) => {
|
||||||
@@ -183,7 +190,9 @@ test('prompt dialog', async ({ client, server }) => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
})).toContainTextContent('- button "Button" [ref=e2]');
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- button "Button" [ref=e2]`),
|
||||||
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
@@ -191,9 +200,9 @@ test('prompt dialog', async ({ client, server }) => {
|
|||||||
element: 'Button',
|
element: 'Button',
|
||||||
ref: 'e2',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`### Modal state
|
})).toHaveResponse({
|
||||||
- ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool
|
modalState: expect.stringContaining(`- ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool`),
|
||||||
`);
|
});
|
||||||
|
|
||||||
const result = await client.callTool({
|
const result = await client.callTool({
|
||||||
name: 'browser_handle_dialog',
|
name: 'browser_handle_dialog',
|
||||||
@@ -203,10 +212,9 @@ test('prompt dialog', async ({ client, server }) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toContainTextContent(`- Page Snapshot:
|
expect(result).toHaveResponse({
|
||||||
\`\`\`yaml
|
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Answer`),
|
||||||
- generic [active] [ref=e1]: Answer
|
});
|
||||||
\`\`\``);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('alert dialog w/ race', async ({ client, server }) => {
|
test('alert dialog w/ race', async ({ client, server }) => {
|
||||||
@@ -214,7 +222,9 @@ test('alert dialog w/ race', async ({ client, server }) => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
})).toContainTextContent('- button "Button" [ref=e2]');
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- button "Button" [ref=e2]`),
|
||||||
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
@@ -222,15 +232,10 @@ test('alert dialog w/ race', async ({ client, server }) => {
|
|||||||
element: 'Button',
|
element: 'Button',
|
||||||
ref: 'e2',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`### Ran Playwright code
|
})).toHaveResponse({
|
||||||
\`\`\`js
|
code: `await page.getByRole('button', { name: 'Button' }).click();`,
|
||||||
// Click Button
|
modalState: expect.stringContaining(`- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`),
|
||||||
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({
|
const result = await client.callTool({
|
||||||
name: 'browser_handle_dialog',
|
name: 'browser_handle_dialog',
|
||||||
@@ -239,11 +244,12 @@ await page.getByRole('button', { name: 'Button' }).click();
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).not.toContainTextContent('### Modal state');
|
expect(result).toHaveResponse({
|
||||||
expect(result).toContainTextContent(`### Page state
|
modalState: undefined,
|
||||||
- Page URL: ${server.PREFIX}
|
pageState: expect.stringContaining(`- Page URL: ${server.PREFIX}
|
||||||
- Page Title:
|
- Page Title:
|
||||||
- Page Snapshot:
|
- Page Snapshot:
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- button "Button"`);
|
- button "Button"`),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,15 +20,19 @@ test('browser_evaluate', async ({ client, server }) => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
})).toContainTextContent(`- Page Title: Title`);
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- Page Title: Title`),
|
||||||
|
});
|
||||||
|
|
||||||
const result = await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_evaluate',
|
name: 'browser_evaluate',
|
||||||
arguments: {
|
arguments: {
|
||||||
function: '() => document.title',
|
function: '() => document.title',
|
||||||
},
|
},
|
||||||
|
})).toHaveResponse({
|
||||||
|
result: `"Title"`,
|
||||||
|
code: `await page.evaluate('() => document.title');`,
|
||||||
});
|
});
|
||||||
expect(result).toContainTextContent(`"Title"`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_evaluate (element)', async ({ client, server }) => {
|
test('browser_evaluate (element)', async ({ client, server }) => {
|
||||||
@@ -47,15 +51,19 @@ test('browser_evaluate (element)', async ({ client, server }) => {
|
|||||||
element: 'body',
|
element: 'body',
|
||||||
ref: 'e1',
|
ref: 'e1',
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`### Result
|
})).toHaveResponse({
|
||||||
"red"`);
|
result: `"red"`,
|
||||||
|
code: `await page.getByText('Hello, world!').evaluate('element => element.style.backgroundColor');`,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_evaluate (error)', async ({ client, server }) => {
|
test('browser_evaluate (error)', async ({ client, server }) => {
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
})).toContainTextContent(`- Page Title: Title`);
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- Page Title: Title`),
|
||||||
|
});
|
||||||
|
|
||||||
const result = await client.callTool({
|
const result = await client.callTool({
|
||||||
name: 'browser_evaluate',
|
name: 'browser_evaluate',
|
||||||
|
|||||||
@@ -26,22 +26,21 @@ test('browser_file_upload', async ({ client, server }, testInfo) => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
})).toContainTextContent(`
|
})).toHaveResponse({
|
||||||
\`\`\`yaml
|
pageState: expect.stringContaining(`- generic [active] [ref=e1]:
|
||||||
- generic [active] [ref=e1]:
|
|
||||||
- button "Choose File" [ref=e2]
|
- button "Choose File" [ref=e2]
|
||||||
- button "Button" [ref=e3]
|
- button "Button" [ref=e3]`),
|
||||||
\`\`\``);
|
});
|
||||||
|
|
||||||
{
|
{
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_file_upload',
|
name: 'browser_file_upload',
|
||||||
arguments: { paths: [] },
|
arguments: { paths: [] },
|
||||||
})).toHaveTextContent(`
|
})).toHaveResponse({
|
||||||
Error: The tool "browser_file_upload" can only be used when there is related modal state present.
|
isError: true,
|
||||||
### Modal state
|
result: expect.stringContaining(`The tool "browser_file_upload" can only be used when there is related modal state present.`),
|
||||||
- There is no modal state present
|
modalState: expect.stringContaining(`- There is no modal state present`),
|
||||||
`.trim());
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
@@ -50,8 +49,9 @@ Error: The tool "browser_file_upload" can only be used when there is related mod
|
|||||||
element: 'Textbox',
|
element: 'Textbox',
|
||||||
ref: 'e2',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`### Modal state
|
})).toHaveResponse({
|
||||||
- [File chooser]: can be handled by the "browser_file_upload" tool`);
|
modalState: expect.stringContaining(`- [File chooser]: can be handled by the "browser_file_upload" tool`),
|
||||||
|
});
|
||||||
|
|
||||||
const filePath = testInfo.outputPath('test.txt');
|
const filePath = testInfo.outputPath('test.txt');
|
||||||
await fs.writeFile(filePath, 'Hello, world!');
|
await fs.writeFile(filePath, 'Hello, world!');
|
||||||
@@ -64,7 +64,10 @@ Error: The tool "browser_file_upload" can only be used when there is related mod
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response).not.toContainTextContent('### Modal state');
|
expect(response).toHaveResponse({
|
||||||
|
code: expect.stringContaining(`await fileChooser.setFiles(`),
|
||||||
|
modalState: undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -76,7 +79,9 @@ Error: The tool "browser_file_upload" can only be used when there is related mod
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response).toContainTextContent('- [File chooser]: can be handled by the \"browser_file_upload\" tool');
|
expect(response).toHaveResponse({
|
||||||
|
modalState: `- [File chooser]: can be handled by the "browser_file_upload" tool`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -88,9 +93,10 @@ Error: The tool "browser_file_upload" can only be used when there is related mod
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response).toContainTextContent(`Error: Tool "browser_click" does not handle the modal state.
|
expect(response).toHaveResponse({
|
||||||
### Modal state
|
result: `Error: Tool "browser_click" does not handle the modal state.`,
|
||||||
- [File chooser]: can be handled by the "browser_file_upload" tool`);
|
modalState: expect.stringContaining(`- [File chooser]: can be handled by the "browser_file_upload" tool`),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,7 +111,9 @@ test('clicking on download link emits download', async ({ startClient, server, m
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
})).toContainTextContent('- link "Download" [ref=e2]');
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- link "Download" [ref=e2]`),
|
||||||
|
});
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
arguments: {
|
arguments: {
|
||||||
@@ -113,8 +121,9 @@ test('clicking on download link emits download', async ({ startClient, server, m
|
|||||||
ref: 'e2',
|
ref: 'e2',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(`### Downloads
|
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toHaveResponse({
|
||||||
- Downloaded file test.txt to ${testInfo.outputPath('output', 'test.txt')}`);
|
downloads: `- Downloaded file test.txt to ${testInfo.outputPath('output', 'test.txt')}`,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('navigating to download link emits download', async ({ startClient, server, mcpBrowser, mcpMode }, testInfo) => {
|
test('navigating to download link emits download', async ({ startClient, server, mcpBrowser, mcpMode }, testInfo) => {
|
||||||
@@ -136,5 +145,7 @@ test('navigating to download link emits download', async ({ startClient, server,
|
|||||||
arguments: {
|
arguments: {
|
||||||
url: server.PREFIX + 'download',
|
url: server.PREFIX + 'download',
|
||||||
},
|
},
|
||||||
})).toContainTextContent('### Downloads');
|
})).toHaveResponse({
|
||||||
|
downloads: expect.stringContaining(`- Downloaded file test.txt to`),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { chromium } from 'playwright';
|
|||||||
import { test as baseTest, expect as baseExpect } from '@playwright/test';
|
import { test as baseTest, expect as baseExpect } from '@playwright/test';
|
||||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { TestServer } from './testserver/index.ts';
|
import { TestServer } from './testserver/index.ts';
|
||||||
|
|
||||||
import type { Config } from '../config';
|
import type { Config } from '../config';
|
||||||
@@ -39,9 +40,18 @@ type CDPServer = {
|
|||||||
start: () => Promise<BrowserContext>;
|
start: () => Promise<BrowserContext>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type StartClient = (options?: {
|
||||||
|
clientName?: string,
|
||||||
|
args?: string[],
|
||||||
|
config?: Config,
|
||||||
|
roots?: { name: string, uri: string }[],
|
||||||
|
rootsResponseDelay?: number,
|
||||||
|
}) => Promise<{ client: Client, stderr: () => string }>;
|
||||||
|
|
||||||
|
|
||||||
type TestFixtures = {
|
type TestFixtures = {
|
||||||
client: Client;
|
client: Client;
|
||||||
startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<{ client: Client, stderr: () => string }>;
|
startClient: StartClient;
|
||||||
wsEndpoint: string;
|
wsEndpoint: string;
|
||||||
cdpServer: CDPServer;
|
cdpServer: CDPServer;
|
||||||
server: TestServer;
|
server: TestServer;
|
||||||
@@ -61,14 +71,11 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
},
|
},
|
||||||
|
|
||||||
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
|
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
|
||||||
const userDataDir = mcpMode !== 'docker' ? testInfo.outputPath('user-data-dir') : undefined;
|
|
||||||
const configDir = path.dirname(test.info().config.configFile!);
|
const configDir = path.dirname(test.info().config.configFile!);
|
||||||
let client: Client | undefined;
|
const clients: Client[] = [];
|
||||||
|
|
||||||
await use(async options => {
|
await use(async options => {
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
if (userDataDir)
|
|
||||||
args.push('--user-data-dir', userDataDir);
|
|
||||||
if (process.env.CI && process.platform === 'linux')
|
if (process.env.CI && process.platform === 'linux')
|
||||||
args.push('--no-sandbox');
|
args.push('--no-sandbox');
|
||||||
if (mcpHeadless)
|
if (mcpHeadless)
|
||||||
@@ -83,20 +90,30 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
args.push(`--config=${path.relative(configDir, configFile)}`);
|
args.push(`--config=${path.relative(configDir, configFile)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
|
const client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }, options?.roots ? { capabilities: { roots: {} } } : undefined);
|
||||||
const { transport, stderr } = await createTransport(args, mcpMode);
|
if (options?.roots) {
|
||||||
|
client.setRequestHandler(ListRootsRequestSchema, async request => {
|
||||||
|
if (options.rootsResponseDelay)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, options.rootsResponseDelay));
|
||||||
|
return {
|
||||||
|
roots: options.roots,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const { transport, stderr } = await createTransport(args, mcpMode, testInfo.outputPath('ms-playwright'));
|
||||||
let stderrBuffer = '';
|
let stderrBuffer = '';
|
||||||
stderr?.on('data', data => {
|
stderr?.on('data', data => {
|
||||||
if (process.env.PWMCP_DEBUG)
|
if (process.env.PWMCP_DEBUG)
|
||||||
process.stderr.write(data);
|
process.stderr.write(data);
|
||||||
stderrBuffer += data.toString();
|
stderrBuffer += data.toString();
|
||||||
});
|
});
|
||||||
|
clients.push(client);
|
||||||
await client.connect(transport);
|
await client.connect(transport);
|
||||||
await client.ping();
|
await client.ping();
|
||||||
return { client, stderr: () => stderrBuffer };
|
return { client, stderr: () => stderrBuffer };
|
||||||
});
|
});
|
||||||
|
|
||||||
await client?.close();
|
await Promise.all(clients.map(client => client.close()));
|
||||||
},
|
},
|
||||||
|
|
||||||
wsEndpoint: async ({ }, use) => {
|
wsEndpoint: async ({ }, use) => {
|
||||||
@@ -113,6 +130,8 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
await use({
|
await use({
|
||||||
endpoint: `http://localhost:${port}`,
|
endpoint: `http://localhost:${port}`,
|
||||||
start: async () => {
|
start: async () => {
|
||||||
|
if (browserContext)
|
||||||
|
throw new Error('CDP server already exists');
|
||||||
browserContext = await chromium.launchPersistentContext(testInfo.outputPath('cdp-user-data-dir'), {
|
browserContext = await chromium.launchPersistentContext(testInfo.outputPath('cdp-user-data-dir'), {
|
||||||
channel: mcpBrowser,
|
channel: mcpBrowser,
|
||||||
headless: true,
|
headless: true,
|
||||||
@@ -160,7 +179,7 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): Promise<{
|
async function createTransport(args: string[], mcpMode: TestOptions['mcpMode'], profilesDir: string): Promise<{
|
||||||
transport: Transport,
|
transport: Transport,
|
||||||
stderr: Stream | null,
|
stderr: Stream | null,
|
||||||
}> {
|
}> {
|
||||||
@@ -181,13 +200,14 @@ async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']):
|
|||||||
const transport = new StdioClientTransport({
|
const transport = new StdioClientTransport({
|
||||||
command: 'node',
|
command: 'node',
|
||||||
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
|
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
|
||||||
cwd: path.join(path.dirname(__filename), '..'),
|
cwd: path.dirname(test.info().config.configFile!),
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
DEBUG: 'pw:mcp:test',
|
DEBUG: 'pw:mcp:test',
|
||||||
DEBUG_COLORS: '0',
|
DEBUG_COLORS: '0',
|
||||||
DEBUG_HIDE_DATE: '1',
|
DEBUG_HIDE_DATE: '1',
|
||||||
|
PWMCP_PROFILES_DIR_FOR_TEST: profilesDir,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
@@ -199,41 +219,14 @@ async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']):
|
|||||||
type Response = Awaited<ReturnType<Client['callTool']>>;
|
type Response = Awaited<ReturnType<Client['callTool']>>;
|
||||||
|
|
||||||
export const expect = baseExpect.extend({
|
export const expect = baseExpect.extend({
|
||||||
toHaveTextContent(response: Response, content: string | RegExp) {
|
toHaveResponse(response: Response, object: any) {
|
||||||
|
const parsed = parseResponse(response);
|
||||||
const isNot = this.isNot;
|
const isNot = this.isNot;
|
||||||
try {
|
try {
|
||||||
const text = (response.content as any)[0].text;
|
|
||||||
if (typeof content === 'string') {
|
|
||||||
if (isNot)
|
|
||||||
baseExpect(text.trim()).not.toBe(content.trim());
|
|
||||||
else
|
|
||||||
baseExpect(text.trim()).toBe(content.trim());
|
|
||||||
} else {
|
|
||||||
if (isNot)
|
|
||||||
baseExpect(text).not.toMatch(content);
|
|
||||||
else
|
|
||||||
baseExpect(text).toMatch(content);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
pass: isNot,
|
|
||||||
message: () => e.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
pass: !isNot,
|
|
||||||
message: () => ``,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
toContainTextContent(response: Response, content: string) {
|
|
||||||
const isNot = this.isNot;
|
|
||||||
try {
|
|
||||||
const texts = (response.content as any).map(c => c.text).join('\n');
|
|
||||||
if (isNot)
|
if (isNot)
|
||||||
expect(texts).not.toContain(content);
|
expect(parsed).not.toEqual(expect.objectContaining(object));
|
||||||
else
|
else
|
||||||
expect(texts).toContain(content);
|
expect(parsed).toEqual(expect.objectContaining(object));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {
|
return {
|
||||||
pass: isNot,
|
pass: isNot,
|
||||||
@@ -250,3 +243,48 @@ export const expect = baseExpect.extend({
|
|||||||
export function formatOutput(output: string): string[] {
|
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);
|
return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/user data dir.*/, 'user data dir').trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseResponse(response: any) {
|
||||||
|
const text = response.content[0].text;
|
||||||
|
const sections = parseSections(text);
|
||||||
|
|
||||||
|
const result = sections.get('Result');
|
||||||
|
const code = sections.get('Ran Playwright code');
|
||||||
|
const tabs = sections.get('Open tabs');
|
||||||
|
const pageState = sections.get('Page state');
|
||||||
|
const consoleMessages = sections.get('New console messages');
|
||||||
|
const modalState = sections.get('Modal state');
|
||||||
|
const downloads = sections.get('Downloads');
|
||||||
|
const codeNoFrame = code?.replace(/^```js\n/, '').replace(/\n```$/, '');
|
||||||
|
const isError = response.isError;
|
||||||
|
const attachments = response.content.slice(1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
result,
|
||||||
|
code: codeNoFrame,
|
||||||
|
tabs,
|
||||||
|
pageState,
|
||||||
|
consoleMessages,
|
||||||
|
modalState,
|
||||||
|
downloads,
|
||||||
|
isError,
|
||||||
|
attachments,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSections(text: string): Map<string, string> {
|
||||||
|
const sections = new Map<string, string>();
|
||||||
|
const sectionHeaders = text.split(/^### /m).slice(1); // Remove empty first element
|
||||||
|
|
||||||
|
for (const section of sectionHeaders) {
|
||||||
|
const firstNewlineIndex = section.indexOf('\n');
|
||||||
|
if (firstNewlineIndex === -1)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const sectionName = section.substring(0, firstNewlineIndex);
|
||||||
|
const sectionContent = section.substring(firstNewlineIndex + 1).trim();
|
||||||
|
sections.set(sectionName, sectionContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ for (const mcpHeadless of [false, true]) {
|
|||||||
test.use({ mcpHeadless });
|
test.use({ mcpHeadless });
|
||||||
test.skip(process.platform === 'linux', 'Auto-detection wont let this test run on linux');
|
test.skip(process.platform === 'linux', 'Auto-detection wont let this test run on linux');
|
||||||
test.skip(({ mcpMode, mcpHeadless }) => mcpMode === 'docker' && !mcpHeadless, 'Headed mode is not supported in docker');
|
test.skip(({ mcpMode, mcpHeadless }) => mcpMode === 'docker' && !mcpHeadless, 'Headed mode is not supported in docker');
|
||||||
|
|
||||||
test('browser', async ({ client, server, mcpBrowser }) => {
|
test('browser', async ({ client, server, mcpBrowser }) => {
|
||||||
test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser ?? ''), 'Only chrome is supported for this test');
|
test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser ?? ''), 'Only chrome is supported for this test');
|
||||||
server.route('/', (req, res) => {
|
server.route('/', (req, res) => {
|
||||||
@@ -40,11 +41,9 @@ for (const mcpHeadless of [false, true]) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response).toContainTextContent(`Mozilla/5.0`);
|
expect(response).toHaveResponse({
|
||||||
if (mcpHeadless)
|
pageState: (mcpHeadless ? expect : expect.not).stringContaining(`HeadlessChrome`),
|
||||||
expect(response).toContainTextContent(`HeadlessChrome`);
|
});
|
||||||
else
|
|
||||||
expect(response).not.toContainTextContent(`HeadlessChrome`);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,8 @@ test('stitched aria frames', async ({ client }) => {
|
|||||||
arguments: {
|
arguments: {
|
||||||
url: `data:text/html,<h1>Hello</h1><iframe src="data:text/html,<button>World</button><main><iframe src='data:text/html,<p>Nested</p>'></iframe></main>"></iframe><iframe src="data:text/html,<h1>Should be invisible</h1>" style="display: none;"></iframe>`,
|
url: `data:text/html,<h1>Hello</h1><iframe src="data:text/html,<button>World</button><main><iframe src='data:text/html,<p>Nested</p>'></iframe></main>"></iframe><iframe src="data:text/html,<h1>Should be invisible</h1>" style="display: none;"></iframe>`,
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`
|
})).toHaveResponse({
|
||||||
\`\`\`yaml
|
pageState: expect.stringContaining(`- generic [active] [ref=e1]:
|
||||||
- generic [active] [ref=e1]:
|
|
||||||
- heading "Hello" [level=1] [ref=e2]
|
- heading "Hello" [level=1] [ref=e2]
|
||||||
- iframe [ref=e3]:
|
- iframe [ref=e3]:
|
||||||
- generic [active] [ref=f1e1]:
|
- generic [active] [ref=f1e1]:
|
||||||
@@ -32,7 +31,8 @@ test('stitched aria frames', async ({ client }) => {
|
|||||||
- main [ref=f1e3]:
|
- main [ref=f1e3]:
|
||||||
- iframe [ref=f1e4]:
|
- iframe [ref=f1e4]:
|
||||||
- paragraph [ref=f2e2]: Nested
|
- paragraph [ref=f2e2]: Nested
|
||||||
\`\`\``);
|
\`\`\``),
|
||||||
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
@@ -40,5 +40,7 @@ test('stitched aria frames', async ({ client }) => {
|
|||||||
element: 'World',
|
element: 'World',
|
||||||
ref: 'f1e2',
|
ref: 'f1e2',
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`// Click World`);
|
})).toHaveResponse({
|
||||||
|
code: `await page.locator('iframe').first().contentFrame().getByRole('button', { name: 'World' }).click();`,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,5 +20,7 @@ test('browser_install', async ({ client, mcpBrowser }) => {
|
|||||||
test.skip(mcpBrowser !== 'chromium', 'Test only chromium');
|
test.skip(mcpBrowser !== 'chromium', 'Test only chromium');
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_install',
|
name: 'browser_install',
|
||||||
})).toContainTextContent(`### No open tabs`);
|
})).toHaveResponse({
|
||||||
|
tabs: expect.stringContaining(`No open tabs`),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,18 +27,17 @@ test('test reopen browser', async ({ startClient, server, mcpMode }) => {
|
|||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_close',
|
name: 'browser_close',
|
||||||
})).toContainTextContent(`### Ran Playwright code
|
})).toHaveResponse({
|
||||||
\`\`\`js
|
code: `await page.close()`,
|
||||||
await page.close()
|
tabs: `No open tabs. Use the "browser_navigate" tool to navigate to a page first.`,
|
||||||
\`\`\`
|
});
|
||||||
|
|
||||||
### No open tabs
|
|
||||||
Use the "browser_navigate" tool to navigate to a page first.`);
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||||
|
});
|
||||||
|
|
||||||
await client.close();
|
await client.close();
|
||||||
|
|
||||||
@@ -68,7 +67,10 @@ test('executable path', async ({ startClient, server }) => {
|
|||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
});
|
});
|
||||||
expect(response).toContainTextContent(`executable doesn't exist`);
|
expect(response).toHaveResponse({
|
||||||
|
result: expect.stringContaining(`executable doesn't exist`),
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('persistent context', async ({ startClient, server }) => {
|
test('persistent context', async ({ startClient, server }) => {
|
||||||
@@ -82,11 +84,12 @@ test('persistent context', async ({ startClient, server }) => {
|
|||||||
`, 'text/html');
|
`, 'text/html');
|
||||||
|
|
||||||
const { client } = await startClient();
|
const { client } = await startClient();
|
||||||
const response = await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`Storage: NO`),
|
||||||
});
|
});
|
||||||
expect(response).toContainTextContent(`Storage: NO`);
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||||
|
|
||||||
@@ -95,12 +98,12 @@ test('persistent context', async ({ startClient, server }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { client: client2 } = await startClient();
|
const { client: client2 } = await startClient();
|
||||||
const response2 = await client2.callTool({
|
expect(await client2.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`Storage: YES`),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response2).toContainTextContent(`Storage: YES`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('isolated context', async ({ startClient, server }) => {
|
test('isolated context', async ({ startClient, server }) => {
|
||||||
@@ -114,22 +117,24 @@ test('isolated context', async ({ startClient, server }) => {
|
|||||||
`, 'text/html');
|
`, 'text/html');
|
||||||
|
|
||||||
const { client: client1 } = await startClient({ args: [`--isolated`] });
|
const { client: client1 } = await startClient({ args: [`--isolated`] });
|
||||||
const response = await client1.callTool({
|
expect(await client1.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`Storage: NO`),
|
||||||
});
|
});
|
||||||
expect(response).toContainTextContent(`Storage: NO`);
|
|
||||||
|
|
||||||
await client1.callTool({
|
await client1.callTool({
|
||||||
name: 'browser_close',
|
name: 'browser_close',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { client: client2 } = await startClient({ args: [`--isolated`] });
|
const { client: client2 } = await startClient({ args: [`--isolated`] });
|
||||||
const response2 = await client2.callTool({
|
expect(await client2.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`Storage: NO`),
|
||||||
});
|
});
|
||||||
expect(response2).toContainTextContent(`Storage: NO`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('isolated context with storage state', async ({ startClient, server }, testInfo) => {
|
test('isolated context with storage state', async ({ startClient, server }, testInfo) => {
|
||||||
@@ -155,9 +160,10 @@ test('isolated context with storage state', async ({ startClient, server }, test
|
|||||||
`--isolated`,
|
`--isolated`,
|
||||||
`--storage-state=${storageStatePath}`,
|
`--storage-state=${storageStatePath}`,
|
||||||
] });
|
] });
|
||||||
const response = await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`Storage: session-value`),
|
||||||
});
|
});
|
||||||
expect(response).toContainTextContent(`Storage: session-value`);
|
|
||||||
});
|
});
|
||||||
|
|||||||
217
tests/mdb.spec.ts
Normal file
217
tests/mdb.spec.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import zodToJsonSchema from 'zod-to-json-schema';
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
|
|
||||||
|
import { runMainBackend, runOnPauseBackendLoop } from '../src/mcp/mdb.js';
|
||||||
|
|
||||||
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
|
import type * as mcpServer from '../src/mcp/server.js';
|
||||||
|
import type { ServerBackendOnPause } from '../src/mcp/mdb.js';
|
||||||
|
|
||||||
|
test('call top level tool', async () => {
|
||||||
|
const { mdbUrl } = await startMDBAndCLI();
|
||||||
|
const mdbClient = await createMDBClient(mdbUrl);
|
||||||
|
|
||||||
|
const { tools } = await mdbClient.client.listTools();
|
||||||
|
expect(tools).toEqual([{
|
||||||
|
name: 'cli_echo',
|
||||||
|
description: 'Echo a message',
|
||||||
|
inputSchema: expect.any(Object),
|
||||||
|
}, {
|
||||||
|
name: 'cli_pause_in_gdb',
|
||||||
|
description: 'Pause in gdb',
|
||||||
|
inputSchema: expect.any(Object),
|
||||||
|
}, {
|
||||||
|
name: 'cli_pause_in_gdb_twice',
|
||||||
|
description: 'Pause in gdb twice',
|
||||||
|
inputSchema: expect.any(Object),
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const echoResult = await mdbClient.client.callTool({
|
||||||
|
name: 'cli_echo',
|
||||||
|
arguments: {
|
||||||
|
message: 'Hello, world!',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(echoResult.content).toEqual([{ type: 'text', text: 'Echo: Hello, world!' }]);
|
||||||
|
|
||||||
|
await mdbClient.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pause on error', async () => {
|
||||||
|
const { mdbUrl } = await startMDBAndCLI();
|
||||||
|
const mdbClient = await createMDBClient(mdbUrl);
|
||||||
|
|
||||||
|
// Make a call that results in a recoverable error.
|
||||||
|
const interruptResult = await mdbClient.client.callTool({
|
||||||
|
name: 'cli_pause_in_gdb',
|
||||||
|
arguments: {},
|
||||||
|
});
|
||||||
|
expect(interruptResult.content).toEqual([{ type: 'text', text: 'Paused on exception' }]);
|
||||||
|
|
||||||
|
// List new inner tools.
|
||||||
|
const { tools } = await mdbClient.client.listTools();
|
||||||
|
expect(tools).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'gdb_bt',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'gdb_continue',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Call the new inner tool.
|
||||||
|
const btResult = await mdbClient.client.callTool({
|
||||||
|
name: 'gdb_bt',
|
||||||
|
arguments: {},
|
||||||
|
});
|
||||||
|
expect(btResult.content).toEqual([{ type: 'text', text: 'Backtrace' }]);
|
||||||
|
|
||||||
|
// Continue execution.
|
||||||
|
const continueResult = await mdbClient.client.callTool({
|
||||||
|
name: 'gdb_continue',
|
||||||
|
arguments: {},
|
||||||
|
});
|
||||||
|
expect(continueResult.content).toEqual([{ type: 'text', text: 'Done' }]);
|
||||||
|
|
||||||
|
await mdbClient.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pause on error twice', async () => {
|
||||||
|
const { mdbUrl } = await startMDBAndCLI();
|
||||||
|
const mdbClient = await createMDBClient(mdbUrl);
|
||||||
|
|
||||||
|
// Make a call that results in a recoverable error.
|
||||||
|
const result = await mdbClient.client.callTool({
|
||||||
|
name: 'cli_pause_in_gdb_twice',
|
||||||
|
arguments: {},
|
||||||
|
});
|
||||||
|
expect(result.content).toEqual([{ type: 'text', text: 'Paused on exception 1' }]);
|
||||||
|
|
||||||
|
// Continue execution.
|
||||||
|
const continueResult1 = await mdbClient.client.callTool({
|
||||||
|
name: 'gdb_continue',
|
||||||
|
arguments: {},
|
||||||
|
});
|
||||||
|
expect(continueResult1.content).toEqual([{ type: 'text', text: 'Paused on exception 2' }]);
|
||||||
|
|
||||||
|
const continueResult2 = await mdbClient.client.callTool({
|
||||||
|
name: 'gdb_continue',
|
||||||
|
arguments: {},
|
||||||
|
});
|
||||||
|
expect(continueResult2.content).toEqual([{ type: 'text', text: 'Done' }]);
|
||||||
|
|
||||||
|
await mdbClient.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function startMDBAndCLI(): Promise<{ mdbUrl: string }> {
|
||||||
|
const mdbUrlBox = { mdbUrl: undefined as string | undefined };
|
||||||
|
const cliBackendFactory = {
|
||||||
|
name: 'CLI',
|
||||||
|
nameInConfig: 'cli',
|
||||||
|
version: '0.0.0',
|
||||||
|
create: () => new CLIBackend(mdbUrlBox)
|
||||||
|
};
|
||||||
|
|
||||||
|
const mdbUrl = (await runMainBackend(cliBackendFactory, { port: 0 }))!;
|
||||||
|
mdbUrlBox.mdbUrl = mdbUrl;
|
||||||
|
return { mdbUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMDBClient(mdbUrl: string): Promise<{ client: Client, close: () => Promise<void> }> {
|
||||||
|
const client = new Client({ name: 'Internal client', version: '0.0.0' });
|
||||||
|
const transport = new StreamableHTTPClientTransport(new URL(mdbUrl));
|
||||||
|
await client.connect(transport);
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
close: async () => {
|
||||||
|
await transport.terminateSession();
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class CLIBackend implements mcpServer.ServerBackend {
|
||||||
|
constructor(private readonly mdbUrlBox: { mdbUrl: string | undefined }) {}
|
||||||
|
|
||||||
|
async listTools(): Promise<mcpServer.Tool[]> {
|
||||||
|
return [{
|
||||||
|
name: 'cli_echo',
|
||||||
|
description: 'Echo a message',
|
||||||
|
inputSchema: zodToJsonSchema(z.object({ message: z.string() })) as any,
|
||||||
|
}, {
|
||||||
|
name: 'cli_pause_in_gdb',
|
||||||
|
description: 'Pause in gdb',
|
||||||
|
inputSchema: zodToJsonSchema(z.object({})) as any,
|
||||||
|
}, {
|
||||||
|
name: 'cli_pause_in_gdb_twice',
|
||||||
|
description: 'Pause in gdb twice',
|
||||||
|
inputSchema: zodToJsonSchema(z.object({})) as any,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
async callTool(name: string, args: mcpServer.CallToolRequest['params']['arguments']): Promise<mcpServer.CallToolResult> {
|
||||||
|
if (name === 'cli_echo')
|
||||||
|
return { content: [{ type: 'text', text: 'Echo: ' + (args?.message as string) }] };
|
||||||
|
if (name === 'cli_pause_in_gdb') {
|
||||||
|
await runOnPauseBackendLoop(this.mdbUrlBox.mdbUrl!, new GDBBackend(), 'Paused on exception');
|
||||||
|
return { content: [{ type: 'text', text: 'Done' }] };
|
||||||
|
}
|
||||||
|
if (name === 'cli_pause_in_gdb_twice') {
|
||||||
|
await runOnPauseBackendLoop(this.mdbUrlBox.mdbUrl!, new GDBBackend(), 'Paused on exception 1');
|
||||||
|
await runOnPauseBackendLoop(this.mdbUrlBox.mdbUrl!, new GDBBackend(), 'Paused on exception 2');
|
||||||
|
return { content: [{ type: 'text', text: 'Done' }] };
|
||||||
|
}
|
||||||
|
throw new Error(`Unknown tool: ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GDBBackend implements ServerBackendOnPause {
|
||||||
|
private _server!: mcpServer.Server;
|
||||||
|
|
||||||
|
async initialize(server: mcpServer.Server): Promise<void> {
|
||||||
|
this._server = server;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTools(): Promise<mcpServer.Tool[]> {
|
||||||
|
return [{
|
||||||
|
name: 'gdb_bt',
|
||||||
|
description: 'Print backtrace',
|
||||||
|
inputSchema: zodToJsonSchema(z.object({})) as any,
|
||||||
|
}, {
|
||||||
|
name: 'gdb_continue',
|
||||||
|
description: 'Continue execution',
|
||||||
|
inputSchema: zodToJsonSchema(z.object({})) as any,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
async callTool(name: string, args: mcpServer.CallToolRequest['params']['arguments']): Promise<mcpServer.CallToolResult> {
|
||||||
|
if (name === 'gdb_bt')
|
||||||
|
return { content: [{ type: 'text', text: 'Backtrace' }] };
|
||||||
|
if (name === 'gdb_continue') {
|
||||||
|
(this as ServerBackendOnPause).requestSelfDestruct?.();
|
||||||
|
// Stall
|
||||||
|
await new Promise(f => setTimeout(f, 1000));
|
||||||
|
}
|
||||||
|
throw new Error(`Unknown tool: ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,7 +40,8 @@ test('browser_network_requests', async ({ client, server }) => {
|
|||||||
|
|
||||||
await expect.poll(() => client.callTool({
|
await expect.poll(() => client.callTool({
|
||||||
name: 'browser_network_requests',
|
name: 'browser_network_requests',
|
||||||
})).toHaveTextContent(`### Result
|
})).toHaveResponse({
|
||||||
[GET] ${`${server.PREFIX}`} => [200] OK
|
result: expect.stringContaining(`[GET] ${`${server.PREFIX}`} => [200] OK
|
||||||
[GET] ${`${server.PREFIX}json`} => [200] OK`);
|
[GET] ${`${server.PREFIX}json`} => [200] OK`),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ test('save as pdf unavailable', async ({ startClient, server }) => {
|
|||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_pdf_save',
|
name: 'browser_pdf_save',
|
||||||
})).toHaveTextContent(/Tool \"browser_pdf_save\" not found/);
|
})).toHaveResponse({
|
||||||
|
result: 'Error: Tool "browser_pdf_save" not found',
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => {
|
test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => {
|
||||||
@@ -40,12 +43,16 @@ test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||||
const response = await client.callTool({
|
});
|
||||||
name: 'browser_pdf_save',
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_pdf_save',
|
||||||
|
})).toHaveResponse({
|
||||||
|
code: expect.stringContaining(`await page.pdf(`),
|
||||||
|
result: expect.stringMatching(/Saved page as.*page-[^:]+.pdf/),
|
||||||
});
|
});
|
||||||
expect(response).toHaveTextContent(/Save page as.*page-[^:]+.pdf/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, server }, testInfo) => {
|
test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, server }, testInfo) => {
|
||||||
@@ -58,14 +65,19 @@ test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, ser
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||||
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_pdf_save',
|
name: 'browser_pdf_save',
|
||||||
arguments: {
|
arguments: {
|
||||||
filename: 'output.pdf',
|
filename: 'output.pdf',
|
||||||
},
|
},
|
||||||
})).toContainTextContent(`output.pdf`);
|
})).toHaveResponse({
|
||||||
|
result: expect.stringContaining(`output.pdf`),
|
||||||
|
code: expect.stringContaining(`await page.pdf(`),
|
||||||
|
});
|
||||||
|
|
||||||
const files = [...fs.readdirSync(outputDir)];
|
const files = [...fs.readdirSync(outputDir)];
|
||||||
|
|
||||||
|
|||||||
79
tests/roots.spec.ts
Normal file
79
tests/roots.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* 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 { pathToFileURL } from 'url';
|
||||||
|
|
||||||
|
import { test, expect } from './fixtures.js';
|
||||||
|
import { createHash } from '../src/utils/guid.js';
|
||||||
|
|
||||||
|
const p = process.platform === 'win32' ? 'c:\\non\\existent\\folder' : '/non/existent/folder';
|
||||||
|
|
||||||
|
test('should use separate user data by root path', async ({ startClient, server }, testInfo) => {
|
||||||
|
const { client } = await startClient({
|
||||||
|
clientName: 'Visual Studio Code',
|
||||||
|
roots: [
|
||||||
|
{
|
||||||
|
name: 'test',
|
||||||
|
uri: 'file://' + p.replace(/\\/g, '/'),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
|
||||||
|
const hash = createHash(p);
|
||||||
|
const [file] = await fs.promises.readdir(testInfo.outputPath('ms-playwright'));
|
||||||
|
expect(file).toContain(hash);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('check that trace is saved in workspace', async ({ startClient, server }, testInfo) => {
|
||||||
|
const rootPath = testInfo.outputPath('workspace');
|
||||||
|
const { client } = await startClient({
|
||||||
|
args: ['--save-trace'],
|
||||||
|
clientName: 'My client',
|
||||||
|
roots: [
|
||||||
|
{
|
||||||
|
name: 'workspace',
|
||||||
|
uri: pathToFileURL(rootPath).toString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
})).toHaveResponse({
|
||||||
|
code: expect.stringContaining(`page.goto('http://localhost`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [file] = await fs.promises.readdir(path.join(rootPath, '.playwright-mcp'));
|
||||||
|
expect(file).toContain('traces');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should list all tools when listRoots is slow', async ({ startClient }) => {
|
||||||
|
const { client } = await startClient({
|
||||||
|
clientName: 'Another custom client',
|
||||||
|
roots: [],
|
||||||
|
rootsResponseDelay: 1000,
|
||||||
|
});
|
||||||
|
const tools = await client.listTools();
|
||||||
|
expect(tools.tools.length).toBeGreaterThan(10);
|
||||||
|
});
|
||||||
@@ -25,22 +25,19 @@ test('browser_take_screenshot (viewport)', async ({ startClient, server }, testI
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
})).toContainTextContent(`Navigate to http://localhost`);
|
})).toHaveResponse({
|
||||||
|
code: expect.stringContaining(`page.goto('http://localhost`),
|
||||||
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_take_screenshot',
|
name: 'browser_take_screenshot',
|
||||||
})).toEqual({
|
})).toHaveResponse({
|
||||||
content: [
|
code: expect.stringContaining(`await page.screenshot`),
|
||||||
{
|
attachments: [{
|
||||||
text: expect.stringContaining(`Screenshot viewport and save it as`),
|
data: expect.any(String),
|
||||||
type: 'text',
|
mimeType: 'image/png',
|
||||||
},
|
type: 'image',
|
||||||
{
|
}],
|
||||||
data: expect.any(String),
|
|
||||||
mimeType: 'image/jpeg',
|
|
||||||
type: 'image',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -51,7 +48,9 @@ test('browser_take_screenshot (element)', async ({ startClient, server }, testIn
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
})).toContainTextContent(`[ref=e1]`);
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`[ref=e1]`),
|
||||||
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_take_screenshot',
|
name: 'browser_take_screenshot',
|
||||||
@@ -67,7 +66,7 @@ test('browser_take_screenshot (element)', async ({ startClient, server }, testIn
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
data: expect.any(String),
|
data: expect.any(String),
|
||||||
mimeType: 'image/jpeg',
|
mimeType: 'image/png',
|
||||||
type: 'image',
|
type: 'image',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -82,61 +81,101 @@ test('--output-dir should work', async ({ startClient, server }, testInfo) => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
})).toContainTextContent(`Navigate to http://localhost`);
|
})).toHaveResponse({
|
||||||
|
code: expect.stringContaining(`page.goto('http://localhost`),
|
||||||
|
});
|
||||||
|
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_take_screenshot',
|
name: 'browser_take_screenshot',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fs.existsSync(outputDir)).toBeTruthy();
|
expect(fs.existsSync(outputDir)).toBeTruthy();
|
||||||
const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.jpeg'));
|
const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.png'));
|
||||||
expect(files).toHaveLength(1);
|
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$/);
|
expect(files[0]).toMatch(/^page-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.png$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const raw of [undefined, true]) {
|
for (const type of ['png', 'jpeg']) {
|
||||||
test(`browser_take_screenshot (raw: ${raw})`, async ({ startClient, server }, testInfo) => {
|
test(`browser_take_screenshot (type: ${type})`, async ({ startClient, server }, testInfo) => {
|
||||||
const outputDir = testInfo.outputPath('output');
|
const outputDir = testInfo.outputPath('output');
|
||||||
const ext = raw ? 'png' : 'jpeg';
|
|
||||||
const { client } = await startClient({
|
const { client } = await startClient({
|
||||||
config: { outputDir },
|
config: { outputDir },
|
||||||
});
|
});
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
})).toContainTextContent(`Navigate to http://localhost`);
|
})).toHaveResponse({
|
||||||
|
code: expect.stringContaining(`page.goto('http://localhost`),
|
||||||
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_take_screenshot',
|
name: 'browser_take_screenshot',
|
||||||
arguments: { raw },
|
arguments: { type },
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
text: expect.stringMatching(
|
text: expect.stringMatching(
|
||||||
new RegExp(`page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}\\-\\d{3}Z\\.${ext}`)
|
new RegExp(`page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}\\-\\d{3}Z\\.${type}`)
|
||||||
),
|
),
|
||||||
type: 'text',
|
type: 'text',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
data: expect.any(String),
|
data: expect.any(String),
|
||||||
mimeType: `image/${ext}`,
|
mimeType: `image/${type}`,
|
||||||
type: 'image',
|
type: 'image',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith(`.${ext}`));
|
const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith(`.${type}`));
|
||||||
|
|
||||||
expect(fs.existsSync(outputDir)).toBeTruthy();
|
expect(fs.existsSync(outputDir)).toBeTruthy();
|
||||||
expect(files).toHaveLength(1);
|
expect(files).toHaveLength(1);
|
||||||
expect(files[0]).toMatch(
|
expect(files[0]).toMatch(
|
||||||
new RegExp(`^page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z\\.${ext}$`)
|
new RegExp(`^page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z\\.${type}$`)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient, server }, testInfo) => {
|
test('browser_take_screenshot (default type should be png)', async ({ startClient, server }, testInfo) => {
|
||||||
|
const outputDir = testInfo.outputPath('output');
|
||||||
|
const { client } = await startClient({
|
||||||
|
config: { outputDir },
|
||||||
|
});
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
})).toHaveResponse({
|
||||||
|
code: `await page.goto('${server.PREFIX}');`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_take_screenshot',
|
||||||
|
})).toEqual({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
text: expect.stringMatching(
|
||||||
|
new RegExp(`page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}\\-\\d{3}Z\\.png`)
|
||||||
|
),
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: expect.any(String),
|
||||||
|
mimeType: 'image/png',
|
||||||
|
type: 'image',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.png'));
|
||||||
|
|
||||||
|
expect(fs.existsSync(outputDir)).toBeTruthy();
|
||||||
|
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\.png$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('browser_take_screenshot (filename: "output.png")', async ({ startClient, server }, testInfo) => {
|
||||||
const outputDir = testInfo.outputPath('output');
|
const outputDir = testInfo.outputPath('output');
|
||||||
const { client } = await startClient({
|
const { client } = await startClient({
|
||||||
config: { outputDir },
|
config: { outputDir },
|
||||||
@@ -144,32 +183,34 @@ test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient,
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
})).toContainTextContent(`Navigate to http://localhost`);
|
})).toHaveResponse({
|
||||||
|
code: expect.stringContaining(`page.goto('http://localhost`),
|
||||||
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_take_screenshot',
|
name: 'browser_take_screenshot',
|
||||||
arguments: {
|
arguments: {
|
||||||
filename: 'output.jpeg',
|
filename: 'output.png',
|
||||||
},
|
},
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
text: expect.stringContaining(`output.jpeg`),
|
text: expect.stringContaining(`output.png`),
|
||||||
type: 'text',
|
type: 'text',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
data: expect.any(String),
|
data: expect.any(String),
|
||||||
mimeType: 'image/jpeg',
|
mimeType: 'image/png',
|
||||||
type: 'image',
|
type: 'image',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.jpeg'));
|
const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.png'));
|
||||||
|
|
||||||
expect(fs.existsSync(outputDir)).toBeTruthy();
|
expect(fs.existsSync(outputDir)).toBeTruthy();
|
||||||
expect(files).toHaveLength(1);
|
expect(files).toHaveLength(1);
|
||||||
expect(files[0]).toMatch(/^output\.jpeg$/);
|
expect(files[0]).toMatch(/^output\.png$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_take_screenshot (imageResponses=omit)', async ({ startClient, server }, testInfo) => {
|
test('browser_take_screenshot (imageResponses=omit)', async ({ startClient, server }, testInfo) => {
|
||||||
@@ -184,7 +225,9 @@ test('browser_take_screenshot (imageResponses=omit)', async ({ startClient, serv
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
})).toContainTextContent(`Navigate to http://localhost`);
|
})).toHaveResponse({
|
||||||
|
code: expect.stringContaining(`page.goto('http://localhost`),
|
||||||
|
});
|
||||||
|
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_take_screenshot',
|
name: 'browser_take_screenshot',
|
||||||
@@ -195,7 +238,7 @@ test('browser_take_screenshot (imageResponses=omit)', async ({ startClient, serv
|
|||||||
})).toEqual({
|
})).toEqual({
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
text: expect.stringContaining(`Screenshot viewport and save it as`),
|
text: expect.stringContaining(`await page.screenshot`),
|
||||||
type: 'text',
|
type: 'text',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -209,7 +252,9 @@ test('browser_take_screenshot (fullPage: true)', async ({ startClient, server },
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
})).toContainTextContent(`Navigate to http://localhost`);
|
})).toHaveResponse({
|
||||||
|
code: expect.stringContaining(`page.goto('http://localhost`),
|
||||||
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_take_screenshot',
|
name: 'browser_take_screenshot',
|
||||||
@@ -217,14 +262,9 @@ test('browser_take_screenshot (fullPage: true)', async ({ startClient, server },
|
|||||||
})).toEqual({
|
})).toEqual({
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
text: expect.stringContaining(`Screenshot full page and save it as`),
|
text: expect.stringContaining('fullPage: true'),
|
||||||
type: 'text',
|
type: 'text',
|
||||||
},
|
}
|
||||||
{
|
|
||||||
data: expect.any(String),
|
|
||||||
mimeType: 'image/jpeg',
|
|
||||||
type: 'image',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -236,7 +276,9 @@ test('browser_take_screenshot (fullPage with element should error)', async ({ st
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
})).toContainTextContent(`[ref=e1]`);
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`[ref=e1]`),
|
||||||
|
});
|
||||||
|
|
||||||
const result = await client.callTool({
|
const result = await client.callTool({
|
||||||
name: 'browser_take_screenshot',
|
name: 'browser_take_screenshot',
|
||||||
@@ -258,8 +300,13 @@ test('browser_take_screenshot (viewport without snapshot)', async ({ startClient
|
|||||||
|
|
||||||
// Ensure we have a tab but don't navigate anywhere (no snapshot captured)
|
// Ensure we have a tab but don't navigate anywhere (no snapshot captured)
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_tab_list',
|
name: 'browser_tabs',
|
||||||
})).toContainTextContent('about:blank');
|
arguments: {
|
||||||
|
action: 'list',
|
||||||
|
},
|
||||||
|
})).toHaveResponse({
|
||||||
|
tabs: `- 0: (current) [] (about:blank)`,
|
||||||
|
});
|
||||||
|
|
||||||
// This should work without requiring a snapshot since it's a viewport screenshot
|
// This should work without requiring a snapshot since it's a viewport screenshot
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
@@ -267,12 +314,12 @@ test('browser_take_screenshot (viewport without snapshot)', async ({ startClient
|
|||||||
})).toEqual({
|
})).toEqual({
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
text: expect.stringContaining(`Screenshot viewport and save it as`),
|
text: expect.stringContaining(`page.screenshot`),
|
||||||
type: 'text',
|
type: 'text',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
data: expect.any(String),
|
data: expect.any(String),
|
||||||
mimeType: 'image/jpeg',
|
mimeType: 'image/png',
|
||||||
type: 'image',
|
type: 'image',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
275
tests/session-log.spec.ts
Normal file
275
tests/session-log.spec.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
/**
|
||||||
|
* 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('session log should record tool calls', async ({ startClient, server }, testInfo) => {
|
||||||
|
const { client, stderr } = await startClient({
|
||||||
|
args: [
|
||||||
|
'--save-session',
|
||||||
|
'--output-dir', testInfo.outputPath('output'),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
})).toHaveResponse({
|
||||||
|
code: `await page.getByRole('button', { name: 'Submit' }).click();`,
|
||||||
|
pageState: expect.stringContaining(`- button "Submit"`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = stderr().split('\n').filter(line => line.startsWith('Session: '))[0];
|
||||||
|
const sessionFolder = output.substring('Session: '.length);
|
||||||
|
await expect.poll(() => readSessionLog(sessionFolder)).toBe(`
|
||||||
|
### Tool call: browser_navigate
|
||||||
|
- Args
|
||||||
|
\`\`\`json
|
||||||
|
{
|
||||||
|
"url": "http://localhost:${server.PORT}/"
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
- Code
|
||||||
|
\`\`\`js
|
||||||
|
await page.goto('http://localhost:${server.PORT}/');
|
||||||
|
\`\`\`
|
||||||
|
- Snapshot: 001.snapshot.yml
|
||||||
|
|
||||||
|
|
||||||
|
### Tool call: browser_click
|
||||||
|
- Args
|
||||||
|
\`\`\`json
|
||||||
|
{
|
||||||
|
"element": "Submit button",
|
||||||
|
"ref": "e2"
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
- Code
|
||||||
|
\`\`\`js
|
||||||
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
\`\`\`
|
||||||
|
- Snapshot: 002.snapshot.yml
|
||||||
|
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('session log should record user action', async ({ cdpServer, startClient }, testInfo) => {
|
||||||
|
const browserContext = await cdpServer.start();
|
||||||
|
const { client, stderr } = await startClient({
|
||||||
|
args: [
|
||||||
|
'--save-session',
|
||||||
|
'--output-dir', testInfo.outputPath('output'),
|
||||||
|
`--cdp-endpoint=${cdpServer.endpoint}`,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force browser context creation.
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_snapshot',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [page] = browserContext.pages();
|
||||||
|
await page.setContent(`
|
||||||
|
<button>Button 1</button>
|
||||||
|
<button>Button 2</button>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Button 1' }).click();
|
||||||
|
|
||||||
|
const output = stderr().split('\n').filter(line => line.startsWith('Session: '))[0];
|
||||||
|
const sessionFolder = output.substring('Session: '.length);
|
||||||
|
|
||||||
|
await expect.poll(() => readSessionLog(sessionFolder)).toBe(`
|
||||||
|
### Tool call: browser_snapshot
|
||||||
|
- Args
|
||||||
|
\`\`\`json
|
||||||
|
{}
|
||||||
|
\`\`\`
|
||||||
|
- Snapshot: 001.snapshot.yml
|
||||||
|
|
||||||
|
|
||||||
|
### User action: click
|
||||||
|
- Args
|
||||||
|
\`\`\`json
|
||||||
|
{
|
||||||
|
"name": "click",
|
||||||
|
"ref": "e2",
|
||||||
|
"button": "left",
|
||||||
|
"modifiers": 0,
|
||||||
|
"clickCount": 1
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
- Code
|
||||||
|
\`\`\`js
|
||||||
|
await page.getByRole('button', { name: 'Button 1' }).click();
|
||||||
|
\`\`\`
|
||||||
|
- Snapshot: 002.snapshot.yml
|
||||||
|
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('session log should update user action', async ({ cdpServer, startClient }, testInfo) => {
|
||||||
|
const browserContext = await cdpServer.start();
|
||||||
|
const { client, stderr } = await startClient({
|
||||||
|
args: [
|
||||||
|
'--save-session',
|
||||||
|
'--output-dir', testInfo.outputPath('output'),
|
||||||
|
`--cdp-endpoint=${cdpServer.endpoint}`,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force browser context creation.
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_snapshot',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [page] = browserContext.pages();
|
||||||
|
await page.setContent(`
|
||||||
|
<button>Button 1</button>
|
||||||
|
<button>Button 2</button>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Button 1' }).dblclick();
|
||||||
|
|
||||||
|
const output = stderr().split('\n').filter(line => line.startsWith('Session: '))[0];
|
||||||
|
const sessionFolder = output.substring('Session: '.length);
|
||||||
|
|
||||||
|
await expect.poll(() => readSessionLog(sessionFolder)).toBe(`
|
||||||
|
### Tool call: browser_snapshot
|
||||||
|
- Args
|
||||||
|
\`\`\`json
|
||||||
|
{}
|
||||||
|
\`\`\`
|
||||||
|
- Snapshot: 001.snapshot.yml
|
||||||
|
|
||||||
|
|
||||||
|
### User action: click
|
||||||
|
- Args
|
||||||
|
\`\`\`json
|
||||||
|
{
|
||||||
|
"name": "click",
|
||||||
|
"ref": "e2",
|
||||||
|
"button": "left",
|
||||||
|
"modifiers": 0,
|
||||||
|
"clickCount": 2
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
- Code
|
||||||
|
\`\`\`js
|
||||||
|
await page.getByRole('button', { name: 'Button 1' }).dblclick();
|
||||||
|
\`\`\`
|
||||||
|
- Snapshot: 002.snapshot.yml
|
||||||
|
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('session log should record tool calls and user actions', async ({ cdpServer, startClient }, testInfo) => {
|
||||||
|
const browserContext = await cdpServer.start();
|
||||||
|
const { client, stderr } = await startClient({
|
||||||
|
args: [
|
||||||
|
'--save-session',
|
||||||
|
'--output-dir', testInfo.outputPath('output'),
|
||||||
|
`--cdp-endpoint=${cdpServer.endpoint}`,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [page] = browserContext.pages();
|
||||||
|
await page.setContent(`
|
||||||
|
<button>Button 1</button>
|
||||||
|
<button>Button 2</button>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_snapshot',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manual action.
|
||||||
|
await page.getByRole('button', { name: 'Button 1' }).click();
|
||||||
|
|
||||||
|
// This is to simulate a delay after the user action before the tool action.
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
// Tool action.
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Button 2',
|
||||||
|
ref: 'e3',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = stderr().split('\n').filter(line => line.startsWith('Session: '))[0];
|
||||||
|
const sessionFolder = output.substring('Session: '.length);
|
||||||
|
await expect.poll(() => readSessionLog(sessionFolder)).toBe(`
|
||||||
|
### Tool call: browser_snapshot
|
||||||
|
- Args
|
||||||
|
\`\`\`json
|
||||||
|
{}
|
||||||
|
\`\`\`
|
||||||
|
- Snapshot: 001.snapshot.yml
|
||||||
|
|
||||||
|
|
||||||
|
### User action: click
|
||||||
|
- Args
|
||||||
|
\`\`\`json
|
||||||
|
{
|
||||||
|
"name": "click",
|
||||||
|
"ref": "e2",
|
||||||
|
"button": "left",
|
||||||
|
"modifiers": 0,
|
||||||
|
"clickCount": 1
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
- Code
|
||||||
|
\`\`\`js
|
||||||
|
await page.getByRole('button', { name: 'Button 1' }).click();
|
||||||
|
\`\`\`
|
||||||
|
- Snapshot: 002.snapshot.yml
|
||||||
|
|
||||||
|
|
||||||
|
### Tool call: browser_click
|
||||||
|
- Args
|
||||||
|
\`\`\`json
|
||||||
|
{
|
||||||
|
"element": "Button 2",
|
||||||
|
"ref": "e3"
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
- Code
|
||||||
|
\`\`\`js
|
||||||
|
await page.getByRole('button', { name: 'Button 2' }).click();
|
||||||
|
\`\`\`
|
||||||
|
- Snapshot: 003.snapshot.yml
|
||||||
|
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function readSessionLog(sessionFolder: string): Promise<string> {
|
||||||
|
return await fs.promises.readFile(path.join(sessionFolder, 'session.md'), 'utf8').catch(() => '');
|
||||||
|
}
|
||||||
@@ -19,8 +19,14 @@ import { test, expect } from './fixtures.js';
|
|||||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
|
||||||
async function createTab(client: Client, title: string, body: string) {
|
async function createTab(client: Client, title: string, body: string) {
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_tabs',
|
||||||
|
arguments: {
|
||||||
|
action: 'new',
|
||||||
|
},
|
||||||
|
});
|
||||||
return await client.callTool({
|
return await client.callTool({
|
||||||
name: 'browser_tab_new',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: {
|
||||||
url: `data:text/html,<title>${title}</title><body>${body}</body>`,
|
url: `data:text/html,<title>${title}</title><body>${body}</body>`,
|
||||||
},
|
},
|
||||||
@@ -29,100 +35,96 @@ async function createTab(client: Client, title: string, body: string) {
|
|||||||
|
|
||||||
test('list initial tabs', async ({ client }) => {
|
test('list initial tabs', async ({ client }) => {
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_tab_list',
|
name: 'browser_tabs',
|
||||||
})).toHaveTextContent(`### Open tabs
|
arguments: {
|
||||||
- 0: (current) [] (about:blank)`);
|
action: 'list',
|
||||||
|
},
|
||||||
|
})).toHaveResponse({
|
||||||
|
tabs: `- 0: (current) [] (about:blank)`,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('list first tab', async ({ client }) => {
|
test('list first tab', async ({ client }) => {
|
||||||
await createTab(client, 'Tab one', 'Body one');
|
await createTab(client, 'Tab one', 'Body one');
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_tab_list',
|
name: 'browser_tabs',
|
||||||
})).toHaveTextContent(`### Open tabs
|
arguments: {
|
||||||
- 0: [] (about:blank)
|
action: 'list',
|
||||||
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`);
|
},
|
||||||
|
})).toHaveResponse({
|
||||||
|
tabs: `- 0: [] (about:blank)
|
||||||
|
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('create new tab', async ({ client }) => {
|
test('create new tab', async ({ client }) => {
|
||||||
const result = await createTab(client, 'Tab one', 'Body one');
|
expect(await createTab(client, 'Tab one', 'Body one')).toHaveResponse({
|
||||||
expect(result).toContainTextContent(`### Open tabs
|
tabs: `- 0: [] (about:blank)
|
||||||
- 0: [] (about:blank)
|
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`,
|
||||||
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
pageState: expect.stringContaining(`- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
||||||
`);
|
|
||||||
|
|
||||||
expect(result).toContainTextContent(`
|
|
||||||
### Page state
|
|
||||||
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
|
||||||
- Page Title: Tab one
|
- Page Title: Tab one
|
||||||
- Page Snapshot:
|
- Page Snapshot:
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [active] [ref=e1]: Body one
|
- generic [active] [ref=e1]: Body one
|
||||||
\`\`\``);
|
\`\`\``),
|
||||||
|
});
|
||||||
|
|
||||||
const result2 = await createTab(client, 'Tab two', 'Body two');
|
expect(await createTab(client, 'Tab two', 'Body two')).toHaveResponse({
|
||||||
expect(result2).toContainTextContent(`### Open tabs
|
tabs: `- 0: [] (about:blank)
|
||||||
- 0: [] (about:blank)
|
|
||||||
- 1: [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
- 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>)
|
- 2: (current) [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)`,
|
||||||
`);
|
pageState: expect.stringContaining(`- Page URL: data:text/html,<title>Tab two</title><body>Body two</body>
|
||||||
|
|
||||||
expect(result2).toContainTextContent(`
|
|
||||||
### Page state
|
|
||||||
- Page URL: data:text/html,<title>Tab two</title><body>Body two</body>
|
|
||||||
- Page Title: Tab two
|
- Page Title: Tab two
|
||||||
- Page Snapshot:
|
- Page Snapshot:
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [active] [ref=e1]: Body two
|
- generic [active] [ref=e1]: Body two
|
||||||
\`\`\``);
|
\`\`\``),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('select tab', async ({ client }) => {
|
test('select tab', async ({ client }) => {
|
||||||
await createTab(client, 'Tab one', 'Body one');
|
await createTab(client, 'Tab one', 'Body one');
|
||||||
await createTab(client, 'Tab two', 'Body two');
|
await createTab(client, 'Tab two', 'Body two');
|
||||||
|
|
||||||
const result = await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_tab_select',
|
name: 'browser_tabs',
|
||||||
arguments: {
|
arguments: {
|
||||||
|
action: 'select',
|
||||||
index: 1,
|
index: 1,
|
||||||
},
|
},
|
||||||
});
|
})).toHaveResponse({
|
||||||
expect(result).toContainTextContent(`### Open tabs
|
tabs: `- 0: [] (about:blank)
|
||||||
- 0: [] (about:blank)
|
|
||||||
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
- 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>)`);
|
- 2: [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)`,
|
||||||
|
pageState: expect.stringContaining(`- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
||||||
expect(result).toContainTextContent(`
|
|
||||||
### Page state
|
|
||||||
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
|
||||||
- Page Title: Tab one
|
- Page Title: Tab one
|
||||||
- Page Snapshot:
|
- Page Snapshot:
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [active] [ref=e1]: Body one
|
- generic [active] [ref=e1]: Body one
|
||||||
\`\`\``);
|
\`\`\``),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('close tab', async ({ client }) => {
|
test('close tab', async ({ client }) => {
|
||||||
await createTab(client, 'Tab one', 'Body one');
|
await createTab(client, 'Tab one', 'Body one');
|
||||||
await createTab(client, 'Tab two', 'Body two');
|
await createTab(client, 'Tab two', 'Body two');
|
||||||
|
|
||||||
const result = await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_tab_close',
|
name: 'browser_tabs',
|
||||||
arguments: {
|
arguments: {
|
||||||
|
action: 'close',
|
||||||
index: 2,
|
index: 2,
|
||||||
},
|
},
|
||||||
});
|
})).toHaveResponse({
|
||||||
expect(result).toContainTextContent(`### Open tabs
|
tabs: `- 0: [] (about:blank)
|
||||||
- 0: [] (about:blank)
|
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`,
|
||||||
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`);
|
pageState: expect.stringContaining(`- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
||||||
|
|
||||||
expect(result).toContainTextContent(`
|
|
||||||
### Page state
|
|
||||||
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
|
||||||
- Page Title: Tab one
|
- Page Title: Tab one
|
||||||
- Page Snapshot:
|
- Page Snapshot:
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [active] [ref=e1]: Body one
|
- generic [active] [ref=e1]: Body one
|
||||||
\`\`\``);
|
\`\`\``),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reuse first tab when navigating', async ({ startClient, cdpServer, server }) => {
|
test('reuse first tab when navigating', async ({ startClient, cdpServer, server }) => {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
@@ -29,7 +28,10 @@ test('check that trace is saved', async ({ startClient, server, mcpMode }, testI
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
})).toContainTextContent(`Navigate to http://localhost`);
|
})).toHaveResponse({
|
||||||
|
code: expect.stringContaining(`page.goto('http://localhost`),
|
||||||
|
});
|
||||||
|
|
||||||
expect(fs.existsSync(path.join(outputDir, 'traces', 'trace.trace'))).toBeTruthy();
|
const [file] = await fs.promises.readdir(outputDir);
|
||||||
|
expect(file).toContain('traces');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,13 +41,18 @@ test('browser_type', async ({ client, server }) => {
|
|||||||
submit: true,
|
submit: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(response).toContainTextContent(`fill('Hi!');`);
|
expect(response).toHaveResponse({
|
||||||
expect(response).toContainTextContent(`- textbox`);
|
code: `await page.getByRole('textbox').fill('Hi!');
|
||||||
|
await page.getByRole('textbox').press('Enter');`,
|
||||||
|
pageState: expect.stringContaining(`- textbox`),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_console_messages',
|
name: 'browser_console_messages',
|
||||||
})).toHaveTextContent(/\[LOG\] Key pressed: Enter , Text: Hi!/);
|
})).toHaveResponse({
|
||||||
|
result: expect.stringContaining(`[LOG] Key pressed: Enter , Text: Hi!`),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_type (slowly)', async ({ client, server }) => {
|
test('browser_type (slowly)', async ({ client, server }) => {
|
||||||
@@ -72,15 +77,23 @@ test('browser_type (slowly)', async ({ client, server }) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response).toContainTextContent(`pressSequentially('Hi!');`);
|
expect(response).toHaveResponse({
|
||||||
expect(response).toContainTextContent(`- textbox`);
|
code: `await page.getByRole('textbox').pressSequentially('Hi!');`,
|
||||||
|
pageState: expect.stringContaining(`- textbox`),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const response = await client.callTool({
|
const response = await client.callTool({
|
||||||
name: 'browser_console_messages',
|
name: 'browser_console_messages',
|
||||||
});
|
});
|
||||||
expect(response).toHaveTextContent(/\[LOG\] Key pressed: H Text: /);
|
expect(response).toHaveResponse({
|
||||||
expect(response).toHaveTextContent(/\[LOG\] Key pressed: i Text: H/);
|
result: expect.stringContaining(`[LOG] Key pressed: H Text: `),
|
||||||
expect(response).toHaveTextContent(/\[LOG\] Key pressed: ! Text: Hi/);
|
});
|
||||||
|
expect(response).toHaveResponse({
|
||||||
|
result: expect.stringContaining(`[LOG] Key pressed: i Text: H`),
|
||||||
|
});
|
||||||
|
expect(response).toHaveResponse({
|
||||||
|
result: expect.stringContaining(`[LOG] Key pressed: ! Text: Hi`),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_type (no submit)', async ({ client, server }) => {
|
test('browser_type (no submit)', async ({ client, server }) => {
|
||||||
@@ -95,7 +108,9 @@ test('browser_type (no submit)', async ({ client, server }) => {
|
|||||||
url: server.PREFIX,
|
url: server.PREFIX,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(response).toContainTextContent(`- textbox`);
|
expect(response).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- textbox`),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
const response = await client.callTool({
|
const response = await client.callTool({
|
||||||
@@ -106,14 +121,18 @@ test('browser_type (no submit)', async ({ client, server }) => {
|
|||||||
text: 'Hi!',
|
text: 'Hi!',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(response).toContainTextContent(`fill('Hi!');`);
|
expect(response).toHaveResponse({
|
||||||
// Should yield no snapshot.
|
code: expect.stringContaining(`fill('Hi!')`),
|
||||||
expect(response).not.toContainTextContent(`- textbox`);
|
// Should yield no snapshot.
|
||||||
|
pageState: expect.not.stringContaining(`- textbox`),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
const response = await client.callTool({
|
const response = await client.callTool({
|
||||||
name: 'browser_console_messages',
|
name: 'browser_console_messages',
|
||||||
});
|
});
|
||||||
expect(response).toHaveTextContent(/\[LOG\] New value: Hi!/);
|
expect(response).toHaveResponse({
|
||||||
|
result: expect.stringContaining(`[LOG] New value: Hi!`),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user