Compare commits
70 Commits
copilot/fi
...
vscode-cli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76ba7f7bb6 | ||
|
|
dc149c19c0 | ||
|
|
74e3ab5267 | ||
|
|
d12b5aab18 | ||
|
|
922002e435 | ||
|
|
21e03968c5 | ||
|
|
ee59735f42 | ||
|
|
da5b0c6fdd | ||
|
|
35c464ef5b | ||
|
|
f6862a39c3 | ||
|
|
e664e0460c | ||
|
|
865eac2fee | ||
|
|
d5d810f896 | ||
|
|
1efd3b55e5 | ||
|
|
1d1db1e287 | ||
|
|
25f15e7f5e | ||
|
|
c559243ef6 | ||
|
|
91d5d24cab | ||
|
|
92554abfd1 | ||
|
|
4370f2cdf2 | ||
|
|
ba726fb44a | ||
|
|
2fc4e88048 | ||
|
|
3f148a4005 | ||
|
|
c92aefdc12 | ||
|
|
fcd953c097 | ||
|
|
badfd82202 | ||
|
|
12942b81d6 | ||
|
|
73adb0fdf0 | ||
|
|
14b931d25d | ||
|
|
bcbc2fecb8 | ||
|
|
5a0cfb9e65 | ||
|
|
1ff80f8761 | ||
|
|
98fef06b3b | ||
|
|
affe1d7ed9 | ||
|
|
cc61b67c14 | ||
|
|
8572ab300c | ||
|
|
c091a11d76 | ||
|
|
dbd44110f1 | ||
|
|
2f41a3f6b1 | ||
|
|
7c4d67b3ae | ||
|
|
53c6b6dcb1 | ||
|
|
7a814d5cd4 | ||
|
|
39c384850f | ||
|
|
f8a61de332 | ||
|
|
9d17572403 | ||
|
|
0741b8bee8 | ||
|
|
1fb2878271 | ||
|
|
0d0783be07 | ||
|
|
001fa6f2fb | ||
|
|
e884b3aacb | ||
|
|
ab0ecc4075 | ||
|
|
f010164bf1 | ||
|
|
db9cfe1720 | ||
|
|
24f81a7a27 | ||
|
|
21ced701b5 | ||
|
|
d3bf2eefc6 | ||
|
|
2ca899316d | ||
|
|
16f3523317 | ||
|
|
6c2dda31ad | ||
|
|
3b6ecf0a43 | ||
|
|
636f1956cc | ||
|
|
5aef2aafcb | ||
|
|
8ecc46c905 | ||
|
|
5dbb1504ba | ||
|
|
20e1144c3b | ||
|
|
eab20aa69e | ||
|
|
46ce86f97e | ||
|
|
4890b9d509 | ||
|
|
3f6837baa9 | ||
|
|
6d62c173c8 |
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/
|
||||||
|
|||||||
34
README.md
34
README.md
@@ -61,7 +61,7 @@ Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user),
|
|||||||
|
|
||||||
#### 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 +100,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 +181,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 +217,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 +257,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
|
||||||
|
|||||||
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",
|
||||||
|
|
||||||
|
|||||||
316
extension/package-lock.json
generated
316
extension/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp-extension",
|
"name": "@playwright/mcp-extension",
|
||||||
"version": "0.0.32",
|
"version": "0.0.34",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@playwright/mcp-extension",
|
"name": "@playwright/mcp-extension",
|
||||||
"version": "0.0.32",
|
"version": "0.0.34",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/chrome": "^0.0.315",
|
"@types/chrome": "^0.0.315",
|
||||||
@@ -16,7 +16,8 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.2",
|
||||||
"vite": "^5.0.0"
|
"vite": "^5.0.0",
|
||||||
|
"vite-plugin-static-copy": "^3.1.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -1090,6 +1091,46 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/anymatch": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"normalize-path": "^3.0.0",
|
||||||
|
"picomatch": "^2.0.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/binary-extensions": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/braces": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fill-range": "^7.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.25.1",
|
"version": "4.25.1",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
|
||||||
@@ -1142,6 +1183,31 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/chokidar": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"anymatch": "~3.1.2",
|
||||||
|
"braces": "~3.0.2",
|
||||||
|
"glob-parent": "~5.1.2",
|
||||||
|
"is-binary-path": "~2.1.0",
|
||||||
|
"is-glob": "~4.0.1",
|
||||||
|
"normalize-path": "~3.0.0",
|
||||||
|
"readdirp": "~3.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/convert-source-map": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
@@ -1224,6 +1290,34 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fill-range": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
|
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"to-regex-range": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fs-extra": {
|
||||||
|
"version": "11.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz",
|
||||||
|
"integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.2.0",
|
||||||
|
"jsonfile": "^6.0.1",
|
||||||
|
"universalify": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1247,6 +1341,72 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/glob-parent": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"is-glob": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/graceful-fs": {
|
||||||
|
"version": "4.2.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/is-binary-path": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"binary-extensions": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-extglob": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-glob": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-extglob": "^2.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-number": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -1277,6 +1437,19 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsonfile": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"universalify": "^2.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"graceful-fs": "^4.1.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/loose-envify": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
@@ -1328,12 +1501,48 @@
|
|||||||
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
|
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/normalize-path": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-map": {
|
||||||
|
"version": "7.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz",
|
||||||
|
"integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/picomatch": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
@@ -1396,6 +1605,19 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/readdirp": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"picomatch": "^2.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.46.1",
|
"version": "4.46.1",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.1.tgz",
|
||||||
@@ -1462,6 +1684,64 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinyglobby": {
|
||||||
|
"version": "0.2.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
||||||
|
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fdir": "^6.4.4",
|
||||||
|
"picomatch": "^4.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinyglobby/node_modules/fdir": {
|
||||||
|
"version": "6.4.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
|
||||||
|
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"picomatch": "^3 || ^4"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"picomatch": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/to-regex-range": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-number": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.8.3",
|
"version": "5.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||||
@@ -1475,6 +1755,16 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/universalify": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
|
||||||
@@ -1564,6 +1854,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite-plugin-static-copy": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-oR53SkL5cX4KT1t18E/xU50vJDo0N8oaHza4EMk0Fm+2/u6nQivxavOfrDk3udWj+dizRizB/QnBvJOOQrTTAQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chokidar": "^3.6.0",
|
||||||
|
"fs-extra": "^11.3.0",
|
||||||
|
"p-map": "^7.0.3",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"tinyglobby": "^0.2.14"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.0.0 || >=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp-extension",
|
"name": "@playwright/mcp-extension",
|
||||||
"version": "0.0.32",
|
"version": "0.0.34",
|
||||||
"description": "Playwright MCP Browser Extension",
|
"description": "Playwright MCP Browser Extension",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -19,7 +19,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --project . && tsc --project tsconfig.ui.json && vite build",
|
"build": "tsc --project . && tsc --project tsconfig.ui.json && vite build",
|
||||||
"watch": "tsc --watch --project . & tsc --watch --project tsconfig.ui.json & vite build --watch",
|
"watch": "tsc --watch --project . & tsc --watch --project tsconfig.ui.json & vite build --watch",
|
||||||
"clean": "rm -rf lib"
|
"test": "playwright test",
|
||||||
|
"clean": "rm -rf dist"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/chrome": "^0.0.315",
|
"@types/chrome": "^0.0.315",
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.2",
|
||||||
"vite": "^5.0.0"
|
"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,67 @@ 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':
|
||||||
|
this._connectTab(sender.tab!.id!, message.tabId, message.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 +87,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 +141,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,15 +171,50 @@ 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.filter(tab => tab.url && !['chrome:', 'edge:', 'devtools:'].some(scheme => tab.url!.startsWith(scheme)));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
new TabShareExtension();
|
new TabShareExtension();
|
||||||
|
|||||||
@@ -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,10 +60,27 @@ export class RelayConnection {
|
|||||||
chrome.debugger.onDetach.addListener(this._detachListener);
|
chrome.debugger.onDetach.addListener(this._detachListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Either setTabId or close is called after creating the connection.
|
||||||
|
setTabId(tabId: number): void {
|
||||||
|
this._debuggee = { tabId };
|
||||||
|
this._tabPromiseResolve();
|
||||||
|
}
|
||||||
|
|
||||||
close(message: string): void {
|
close(message: string): void {
|
||||||
|
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.onEvent.removeListener(this._eventListener);
|
||||||
chrome.debugger.onDetach.removeListener(this._detachListener);
|
chrome.debugger.onDetach.removeListener(this._detachListener);
|
||||||
this._ws.close(1000, message);
|
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 {
|
||||||
@@ -111,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');
|
||||||
@@ -121,6 +144,8 @@ export class RelayConnection {
|
|||||||
targetInfo: result?.targetInfo,
|
targetInfo: result?.targetInfo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
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 === '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);
|
||||||
@@ -147,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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,10 +25,9 @@ body {
|
|||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
color: #1f2328;
|
color: #1f2328;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 24px;
|
padding: 16px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-wrapper {
|
.content-wrapper {
|
||||||
@@ -36,50 +35,55 @@ body {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-title {
|
/* Status Banner */
|
||||||
font-size: 32px;
|
.status-container {
|
||||||
font-weight: 600;
|
display: flex;
|
||||||
margin-bottom: 8px;
|
align-items: center;
|
||||||
color: #1f2328;
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-right: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status Banner */
|
|
||||||
.status-banner {
|
.status-banner {
|
||||||
padding: 16px;
|
padding: 12px;
|
||||||
margin-bottom: 24px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-banner.connected {
|
.status-banner.connected {
|
||||||
background-color: #dafbe1;
|
color: #1f2328;
|
||||||
border-color: #1a7f37;
|
}
|
||||||
color: #0d5a23;
|
|
||||||
|
.status-banner.connected::before {
|
||||||
|
content: "\2705";
|
||||||
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-banner.error {
|
.status-banner.error {
|
||||||
background-color: #ffebe9;
|
color: #1f2328;
|
||||||
border-color: #da3633;
|
|
||||||
color: #a40e26;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-banner.connecting {
|
.status-banner.error::before {
|
||||||
background-color: #fff8c5;
|
content: "\274C";
|
||||||
border-color: #d1b500;
|
margin-right: 8px;
|
||||||
color: #7a5c00;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
.button-container {
|
.button-container {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-right: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid;
|
border: none;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -88,46 +92,63 @@ body {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
|
min-width: 90px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button.primary {
|
.button.primary {
|
||||||
background-color: #2da44e;
|
background-color: #f8f9fa;
|
||||||
border-color: #2da44e;
|
color: #3c4043;
|
||||||
color: #ffffff;
|
border: 1px solid #dadce0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button.primary:hover {
|
.button.primary:hover {
|
||||||
background-color: #2c974b;
|
background-color: #f1f3f4;
|
||||||
|
border-color: #dadce0;
|
||||||
|
box-shadow: 0 1px 2px 0 rgba(60,64,67,.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button.default {
|
.button.default {
|
||||||
background-color: #f6f8fa;
|
background-color: #f6f8fa;
|
||||||
border-color: #d1d9e0;
|
|
||||||
color: #24292f;
|
color: #24292f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button.default:hover {
|
.button.default:hover {
|
||||||
background-color: #f3f4f6;
|
background-color: #f3f4f6;
|
||||||
border-color: #c7d2da;
|
}
|
||||||
|
|
||||||
|
.button.reject {
|
||||||
|
background-color: #da3633;
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid #da3633;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.reject:hover {
|
||||||
|
background-color: #c73836;
|
||||||
|
border-color: #c73836;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tab selection */
|
/* Tab selection */
|
||||||
.tab-section-title {
|
.tab-section-title {
|
||||||
font-size: 20px;
|
padding-left: 12px;
|
||||||
font-weight: 600;
|
font-size: 12px;
|
||||||
margin-bottom: 16px;
|
font-weight: 400;
|
||||||
color: #1f2328;
|
margin-bottom: 12px;
|
||||||
|
color: #656d76;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-item {
|
.tab-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: 1px solid #d1d9e0;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-item.selected {
|
.tab-item.selected {
|
||||||
|
|||||||
@@ -18,10 +18,12 @@
|
|||||||
<head>
|
<head>
|
||||||
<title>Playwright MCP extension</title>
|
<title>Playwright MCP extension</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="src/ui/connect.css">
|
<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>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="src/ui/connect.tsx"></script>
|
<script type="module" src="connect.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -16,21 +16,18 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { Button, TabItem } from './tabItem.js';
|
||||||
|
import type { TabInfo } from './tabItem.js';
|
||||||
|
|
||||||
interface TabInfo {
|
type Status =
|
||||||
id: number;
|
| { type: 'connecting'; message: string }
|
||||||
windowId: number;
|
| { type: 'connected'; message: string }
|
||||||
title: string;
|
| { type: 'error'; message: string }
|
||||||
url: string;
|
| { type: 'error'; versionMismatch: { pwMcpVersion: string; extensionVersion: string } };
|
||||||
favIconUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type StatusType = 'connected' | 'error' | 'connecting';
|
|
||||||
|
|
||||||
const ConnectApp: React.FC = () => {
|
const ConnectApp: React.FC = () => {
|
||||||
const [tabs, setTabs] = useState<TabInfo[]>([]);
|
const [tabs, setTabs] = useState<TabInfo[]>([]);
|
||||||
const [selectedTab, setSelectedTab] = useState<TabInfo | undefined>();
|
const [status, setStatus] = useState<Status | null>(null);
|
||||||
const [status, setStatus] = useState<{ type: StatusType; message: string } | null>(null);
|
|
||||||
const [showButtons, setShowButtons] = useState(true);
|
const [showButtons, setShowButtons] = useState(true);
|
||||||
const [showTabList, setShowTabList] = useState(true);
|
const [showTabList, setShowTabList] = useState(true);
|
||||||
const [clientInfo, setClientInfo] = useState('unknown');
|
const [clientInfo, setClientInfo] = useState('unknown');
|
||||||
@@ -54,42 +51,63 @@ const ConnectApp: React.FC = () => {
|
|||||||
setClientInfo(info);
|
setClientInfo(info);
|
||||||
setStatus({
|
setStatus({
|
||||||
type: 'connecting',
|
type: 'connecting',
|
||||||
message: `MCP client "${info}" is trying to connect. Do you want to continue?`
|
message: `🎭 Playwright MCP started from "${info}" is trying to connect. Do you want to continue?`
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus({ type: 'error', message: 'Failed to parse client version.' });
|
setStatus({ type: 'error', message: 'Failed to parse client version.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadTabs();
|
const pwMcpVersion = params.get('pwMcpVersion');
|
||||||
}, []);
|
const extensionVersion = chrome.runtime.getManifest().version;
|
||||||
|
if (pwMcpVersion !== extensionVersion) {
|
||||||
const loadTabs = useCallback(async () => {
|
setShowButtons(false);
|
||||||
const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
|
setShowTabList(false);
|
||||||
if (response.success) {
|
setStatus({
|
||||||
setTabs(response.tabs);
|
type: 'error',
|
||||||
const currentTab = response.tabs.find((tab: TabInfo) => tab.id === response.currentTabId);
|
versionMismatch: {
|
||||||
setSelectedTab(currentTab);
|
pwMcpVersion: pwMcpVersion || 'unknown',
|
||||||
} else {
|
extensionVersion
|
||||||
setStatus({ type: 'error', message: 'Failed to load tabs: ' + response.error });
|
}
|
||||||
}
|
});
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleContinue = useCallback(async () => {
|
|
||||||
setShowButtons(false);
|
|
||||||
setShowTabList(false);
|
|
||||||
|
|
||||||
if (!selectedTab) {
|
|
||||||
setStatus({ type: 'error', message: 'Tab not selected.' });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void connectToMCPRelay(relayUrl);
|
||||||
|
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 {
|
try {
|
||||||
const response = await chrome.runtime.sendMessage({
|
const response = await chrome.runtime.sendMessage({
|
||||||
type: 'connectToMCPRelay',
|
type: 'connectToTab',
|
||||||
mcpRelayUrl,
|
mcpRelayUrl,
|
||||||
tabId: selectedTab.id,
|
tabId: tab.id,
|
||||||
windowId: selectedTab.windowId,
|
windowId: tab.windowId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response?.success) {
|
if (response?.success) {
|
||||||
@@ -106,47 +124,48 @@ const ConnectApp: React.FC = () => {
|
|||||||
message: `MCP client "${clientInfo}" failed to connect: ${e}`
|
message: `MCP client "${clientInfo}" failed to connect: ${e}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [selectedTab, clientInfo, mcpRelayUrl]);
|
}, [clientInfo, mcpRelayUrl]);
|
||||||
|
|
||||||
const handleReject = useCallback(() => {
|
useEffect(() => {
|
||||||
setShowButtons(false);
|
const listener = (message: any) => {
|
||||||
setShowTabList(false);
|
if (message.type === 'connectionTimeout')
|
||||||
setStatus({ type: 'error', message: 'Connection rejected. This tab can be closed.' });
|
handleReject('Connection timed out.');
|
||||||
}, []);
|
};
|
||||||
|
chrome.runtime.onMessage.addListener(listener);
|
||||||
|
return () => {
|
||||||
|
chrome.runtime.onMessage.removeListener(listener);
|
||||||
|
};
|
||||||
|
}, [handleReject]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='app-container'>
|
<div className='app-container'>
|
||||||
<div className='content-wrapper'>
|
<div className='content-wrapper'>
|
||||||
<h1 className='main-title'>
|
{status && (
|
||||||
Playwright MCP Extension
|
<div className='status-container'>
|
||||||
</h1>
|
<StatusBanner status={status} />
|
||||||
|
{showButtons && (
|
||||||
{status && <StatusBanner type={status.type} message={status.message} />}
|
<Button variant='reject' onClick={() => handleReject('Connection rejected. This tab can be closed.')}>
|
||||||
|
Reject
|
||||||
{showButtons && (
|
</Button>
|
||||||
<div className='button-container'>
|
)}
|
||||||
<Button variant='primary' onClick={handleContinue}>
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
<Button variant='default' onClick={handleReject}>
|
|
||||||
Reject
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{showTabList && (
|
{showTabList && (
|
||||||
<div>
|
<div>
|
||||||
<h2 className='tab-section-title'>
|
<div className='tab-section-title'>
|
||||||
Select page to expose to MCP server:
|
Select page to expose to MCP server:
|
||||||
</h2>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{tabs.map(tab => (
|
{tabs.map(tab => (
|
||||||
<TabItem
|
<TabItem
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
tab={tab}
|
tab={tab}
|
||||||
isSelected={selectedTab?.id === tab.id}
|
button={
|
||||||
onSelect={() => setSelectedTab(tab)}
|
<Button variant='primary' onClick={() => handleConnectToTab(tab)}>
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -157,49 +176,28 @@ const ConnectApp: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const StatusBanner: React.FC<{ type: StatusType; message: string }> = ({ type, message }) => {
|
const VersionMismatchError: React.FC<{ pwMcpVersion: string; extensionVersion: string }> = ({ pwMcpVersion, extensionVersion }) => {
|
||||||
return <div className={`status-banner ${type}`}>{message}</div>;
|
const readmeUrl = 'https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md';
|
||||||
};
|
|
||||||
|
|
||||||
const Button: React.FC<{ variant: 'primary' | 'default'; onClick: () => void; children: React.ReactNode }> = ({
|
|
||||||
variant,
|
|
||||||
onClick,
|
|
||||||
children
|
|
||||||
}) => {
|
|
||||||
return (
|
return (
|
||||||
<button className={`button ${variant}`} onClick={onClick}>
|
<div>
|
||||||
{children}
|
Incompatible Playwright MCP version: {pwMcpVersion} (extension version: {extensionVersion}).
|
||||||
</button>
|
Please install the latest version of the extension.{' '}
|
||||||
);
|
See <a href={readmeUrl} target='_blank' rel='noopener noreferrer'>installation instructions</a>.
|
||||||
};
|
|
||||||
|
|
||||||
const TabItem: React.FC<{ tab: TabInfo; isSelected: boolean; onSelect: () => void }> = ({
|
|
||||||
tab,
|
|
||||||
isSelected,
|
|
||||||
onSelect
|
|
||||||
}) => {
|
|
||||||
const className = `tab-item ${isSelected ? 'selected' : ''}`.trim();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className} onClick={onSelect}>
|
|
||||||
<input
|
|
||||||
type='radio'
|
|
||||||
className='tab-radio'
|
|
||||||
checked={isSelected}
|
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
</div>
|
</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} />
|
||||||
|
) : (
|
||||||
|
status.message
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Initialize the React app
|
// Initialize the React app
|
||||||
const container = document.getElementById('root');
|
const container = document.getElementById('root');
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
244
extension/tests/extension.spec.ts
Normal file
244
extension/tests/extension.spec.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
await selectorPage.locator('.tab-item', { hasText: 'Playwright MCP Extension' }).getByRole('button', { name: 'Connect' }).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). Please install the latest version of the extension. See installation instructions.`);
|
||||||
|
|
||||||
|
expect(await navigateResponse).toHaveResponse({
|
||||||
|
result: expect.stringContaining('Extension connection timeout.'),
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
@@ -6,8 +6,9 @@
|
|||||||
"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",
|
"jsx": "react-jsx",
|
||||||
"jsxImportSource": "react"
|
"jsxImportSource": "react"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,23 +17,37 @@
|
|||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [
|
||||||
base: '/lib/ui/',
|
react(),
|
||||||
|
viteStaticCopy({
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
src: '../../icons/*',
|
||||||
|
dest: 'icons'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '../../manifest.json',
|
||||||
|
dest: '.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
],
|
||||||
|
root: resolve(__dirname, 'src/ui'),
|
||||||
build: {
|
build: {
|
||||||
outDir: resolve(__dirname, 'lib/ui'),
|
outDir: resolve(__dirname, 'dist/'),
|
||||||
emptyOutDir: true,
|
emptyOutDir: false,
|
||||||
minify: false,
|
minify: false,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: resolve(__dirname, 'connect.html'),
|
input: ['src/ui/connect.html', 'src/ui/status.html'],
|
||||||
output: {
|
output: {
|
||||||
manualChunks: undefined,
|
manualChunks: undefined,
|
||||||
inlineDynamicImports: true,
|
entryFileNames: 'lib/ui/[name].js',
|
||||||
entryFileNames: '[name].js',
|
chunkFileNames: 'lib/ui/[name].js',
|
||||||
chunkFileNames: '[name].js',
|
assetFileNames: 'lib/ui/[name].[ext]'
|
||||||
assetFileNames: '[name].[ext]'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32
package-lock.json
generated
32
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,8 +14,8 @@
|
|||||||
"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-1753913825000",
|
"playwright": "1.55.0-alpha-2025-08-12",
|
||||||
"playwright-core": "1.55.0-alpha-1753913825000",
|
"playwright-core": "1.55.0-alpha-2025-08-12",
|
||||||
"ws": "^8.18.1",
|
"ws": "^8.18.1",
|
||||||
"zod": "^3.24.1",
|
"zod": "^3.24.1",
|
||||||
"zod-to-json-schema": "^3.24.4"
|
"zod-to-json-schema": "^3.24.4"
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"@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-1753913825000",
|
"@playwright/test": "1.55.0-alpha-2025-08-12",
|
||||||
"@stylistic/eslint-plugin": "^3.0.1",
|
"@stylistic/eslint-plugin": "^3.0.1",
|
||||||
"@types/debug": "^4.1.12",
|
"@types/debug": "^4.1.12",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
@@ -703,13 +703,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.55.0-alpha-1753913825000",
|
"version": "1.55.0-alpha-2025-08-12",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0-alpha-1753913825000.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0-alpha-2025-08-12.tgz",
|
||||||
"integrity": "sha512-YM5YHU6nTYNVzXlKvQvtEdXzpubLvdfEiTxwWvbqGHL/iDK2kBJd3L0psIG6yClU1wy01O756TkOOQSEpzOu7g==",
|
"integrity": "sha512-lyq9MDSd4UcOWx5292AYLBfbYYCstg8iLb+lk6LdM69ps6bwmPloZO3Ol3JO3FQQ63qAuW9VD0w+ZYKL0lRmQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.55.0-alpha-1753913825000"
|
"playwright": "1.55.0-alpha-2025-08-12"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -3745,12 +3745,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.55.0-alpha-1753913825000",
|
"version": "1.55.0-alpha-2025-08-12",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0-alpha-1753913825000.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0-alpha-2025-08-12.tgz",
|
||||||
"integrity": "sha512-IDyZzTu3tRNIjcx7/6ZmU7VmZPFGaW4jNsizwqbjSoeLFZPTLx2y693qeVVF/8KwEjuiSU3hVTQEzWvnx7cf2Q==",
|
"integrity": "sha512-daZPM5gX0VTG6ae3/qOpEKc9NxoavkM2lfL0UIzTG0k+yK8ZeSPYo63iewZhVANsWRm0BT+XQ1NniAUOwWQ+xA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.55.0-alpha-1753913825000"
|
"playwright-core": "1.55.0-alpha-2025-08-12"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -3763,9 +3763,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.55.0-alpha-1753913825000",
|
"version": "1.55.0-alpha-2025-08-12",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0-alpha-1753913825000.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0-alpha-2025-08-12.tgz",
|
||||||
"integrity": "sha512-FH5pHzLseQxD8+d2wGlRa/I32AzJ+ZzcdDNM1aiSw5+gmq+aOo3PBqXHvhsh7tj0h4l2Qf6z9qf4mMiwijVthw==",
|
"integrity": "sha512-4uxOd9xmeF6gqdsORzzlXd7p795vcACOiAGVHHEiTuFXsD83LYH+0C/SYLWB0Z+fAq4LdKGsy0qEfTm0JkY8Ig==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
|
|||||||
11
package.json
11
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,8 +17,9 @@
|
|||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"lint": "npm run update-readme && eslint . && tsc --noEmit",
|
"lint": "npm run update-readme && npm run check-deps && 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",
|
||||||
"test": "playwright test",
|
"test": "playwright test",
|
||||||
@@ -42,8 +43,8 @@
|
|||||||
"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-1753913825000",
|
"playwright": "1.55.0-alpha-2025-08-12",
|
||||||
"playwright-core": "1.55.0-alpha-1753913825000",
|
"playwright-core": "1.55.0-alpha-2025-08-12",
|
||||||
"ws": "^8.18.1",
|
"ws": "^8.18.1",
|
||||||
"zod": "^3.24.1",
|
"zod": "^3.24.1",
|
||||||
"zod-to-json-schema": "^3.24.4"
|
"zod-to-json-schema": "^3.24.4"
|
||||||
@@ -52,7 +53,7 @@
|
|||||||
"@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-1753913825000",
|
"@playwright/test": "1.55.0-alpha-2025-08-12",
|
||||||
"@stylistic/eslint-plugin": "^3.0.1",
|
"@stylistic/eslint-plugin": "^3.0.1",
|
||||||
"@types/debug": "^4.1.12",
|
"@types/debug": "^4.1.12",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
|
|||||||
@@ -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]
|
||||||
|
***
|
||||||
@@ -21,8 +21,10 @@ import path from 'path';
|
|||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { registryDirectory } from 'playwright-core/lib/server/registry/index';
|
import { registryDirectory } from 'playwright-core/lib/server/registry/index';
|
||||||
import { logUnhandledError, testDebug } from './log.js';
|
// @ts-ignore
|
||||||
import { createHash } from './utils.js';
|
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 { outputFile } from './config.js';
|
||||||
|
|
||||||
import type { FullConfig } from './config.js';
|
import type { FullConfig } from './config.js';
|
||||||
@@ -40,29 +42,24 @@ export function contextFactory(config: FullConfig): BrowserContextFactory {
|
|||||||
export type ClientInfo = { name?: string, version?: string, rootPath?: string };
|
export type ClientInfo = { name?: string, version?: string, rootPath?: string };
|
||||||
|
|
||||||
export interface BrowserContextFactory {
|
export interface BrowserContextFactory {
|
||||||
readonly name: string;
|
|
||||||
readonly description: string;
|
|
||||||
createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
|
createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
class BaseContextFactory implements BrowserContextFactory {
|
class BaseContextFactory implements BrowserContextFactory {
|
||||||
readonly name: string;
|
|
||||||
readonly description: string;
|
|
||||||
readonly config: FullConfig;
|
readonly config: FullConfig;
|
||||||
|
private _logName: string;
|
||||||
protected _browserPromise: Promise<playwright.Browser> | undefined;
|
protected _browserPromise: Promise<playwright.Browser> | undefined;
|
||||||
protected _tracesDir: string | undefined;
|
|
||||||
|
|
||||||
constructor(name: string, description: string, config: FullConfig) {
|
constructor(name: string, config: FullConfig) {
|
||||||
this.name = name;
|
this._logName = name;
|
||||||
this.description = description;
|
|
||||||
this.config = config;
|
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;
|
||||||
@@ -73,16 +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(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||||
if (this.config.saveTrace)
|
testDebug(`create browser context (${this._logName})`);
|
||||||
this._tracesDir = await outputFile(this.config, clientInfo.rootPath, `traces-${Date.now()}`);
|
const browser = await this._obtainBrowser(clientInfo);
|
||||||
|
|
||||||
testDebug(`create browser context (${this.name})`);
|
|
||||||
const browser = await this._obtainBrowser();
|
|
||||||
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) };
|
||||||
}
|
}
|
||||||
@@ -92,12 +86,12 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,14 +99,14 @@ class BaseContextFactory implements BrowserContextFactory {
|
|||||||
|
|
||||||
class IsolatedContextFactory extends BaseContextFactory {
|
class IsolatedContextFactory extends BaseContextFactory {
|
||||||
constructor(config: FullConfig) {
|
constructor(config: FullConfig) {
|
||||||
super('isolated', 'Create a new isolated browser context', config);
|
super('isolated', config);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
protected override async _doObtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
|
||||||
await injectCdpPort(this.config.browser);
|
await injectCdpPort(this.config.browser);
|
||||||
const browserType = playwright[this.config.browser.browserName];
|
const browserType = playwright[this.config.browser.browserName];
|
||||||
return browserType.launch({
|
return browserType.launch({
|
||||||
tracesDir: this._tracesDir,
|
tracesDir: await startTraceServer(this.config, clientInfo.rootPath),
|
||||||
...this.config.browser.launchOptions,
|
...this.config.browser.launchOptions,
|
||||||
handleSIGINT: false,
|
handleSIGINT: false,
|
||||||
handleSIGTERM: false,
|
handleSIGTERM: false,
|
||||||
@@ -130,7 +124,7 @@ class IsolatedContextFactory extends BaseContextFactory {
|
|||||||
|
|
||||||
class CdpContextFactory extends BaseContextFactory {
|
class CdpContextFactory extends BaseContextFactory {
|
||||||
constructor(config: FullConfig) {
|
constructor(config: FullConfig) {
|
||||||
super('cdp', 'Connect to a browser over CDP', config);
|
super('cdp', config);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||||
@@ -144,7 +138,7 @@ class CdpContextFactory extends BaseContextFactory {
|
|||||||
|
|
||||||
class RemoteContextFactory extends BaseContextFactory {
|
class RemoteContextFactory extends BaseContextFactory {
|
||||||
constructor(config: FullConfig) {
|
constructor(config: FullConfig) {
|
||||||
super('remote', 'Connect to a browser using a remote endpoint', config);
|
super('remote', config);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||||
@@ -175,9 +169,7 @@ class PersistentContextFactory implements BrowserContextFactory {
|
|||||||
await injectCdpPort(this.config.browser);
|
await injectCdpPort(this.config.browser);
|
||||||
testDebug('create browser context (persistent)');
|
testDebug('create browser context (persistent)');
|
||||||
const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo.rootPath);
|
const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo.rootPath);
|
||||||
let tracesDir: string | undefined;
|
const tracesDir = await startTraceServer(this.config, clientInfo.rootPath);
|
||||||
if (this.config.saveTrace)
|
|
||||||
tracesDir = await outputFile(this.config, clientInfo.rootPath, `traces-${Date.now()}`);
|
|
||||||
|
|
||||||
this._userDataDirs.add(userDataDir);
|
this._userDataDirs.add(userDataDir);
|
||||||
testDebug('lock user data dir', userDataDir);
|
testDebug('lock user data dir', userDataDir);
|
||||||
@@ -242,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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,48 +15,35 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { z } from 'zod';
|
|
||||||
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 { defineTool } from './tools/tool.js';
|
|
||||||
|
|
||||||
import type { Tool } from './tools/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';
|
||||||
|
|
||||||
type NonEmptyArray<T> = [T, ...T[]];
|
|
||||||
|
|
||||||
export type FactoryList = NonEmptyArray<BrowserContextFactory>;
|
|
||||||
|
|
||||||
export class BrowserServerBackend implements ServerBackend {
|
export class BrowserServerBackend implements ServerBackend {
|
||||||
name = 'Playwright';
|
|
||||||
version = packageJSON.version;
|
|
||||||
|
|
||||||
private _tools: Tool[];
|
private _tools: Tool[];
|
||||||
private _context: Context | undefined;
|
private _context: Context | undefined;
|
||||||
private _sessionLog: SessionLog | undefined;
|
private _sessionLog: SessionLog | undefined;
|
||||||
private _config: FullConfig;
|
private _config: FullConfig;
|
||||||
private _browserContextFactory: BrowserContextFactory;
|
private _browserContextFactory: BrowserContextFactory;
|
||||||
|
|
||||||
constructor(config: FullConfig, factories: FactoryList) {
|
constructor(config: FullConfig, factory: BrowserContextFactory) {
|
||||||
this._config = config;
|
this._config = config;
|
||||||
this._browserContextFactory = factories[0];
|
this._browserContextFactory = factory;
|
||||||
this._tools = filteredTools(config);
|
this._tools = filteredTools(config);
|
||||||
if (factories.length > 1)
|
|
||||||
this._tools.push(this._defineContextSwitchTool(factories));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize(server: mcpServer.Server): Promise<void> {
|
async initialize(clientVersion: mcpServer.ClientVersion, roots: mcpServer.Root[]): Promise<void> {
|
||||||
const capabilities = server.getClientCapabilities() as mcpServer.ClientCapabilities;
|
|
||||||
let rootPath: string | undefined;
|
let rootPath: string | undefined;
|
||||||
if (capabilities.roots) {
|
if (roots.length > 0) {
|
||||||
const { roots } = await server.listRoots();
|
|
||||||
const firstRootUri = roots[0]?.uri;
|
const firstRootUri = roots[0]?.uri;
|
||||||
const url = firstRootUri ? new URL(firstRootUri) : undefined;
|
const url = firstRootUri ? new URL(firstRootUri) : undefined;
|
||||||
rootPath = url ? fileURLToPath(url) : undefined;
|
rootPath = url ? fileURLToPath(url) : undefined;
|
||||||
@@ -67,18 +54,21 @@ export class BrowserServerBackend implements ServerBackend {
|
|||||||
config: this._config,
|
config: this._config,
|
||||||
browserContextFactory: this._browserContextFactory,
|
browserContextFactory: this._browserContextFactory,
|
||||||
sessionLog: this._sessionLog,
|
sessionLog: this._sessionLog,
|
||||||
clientInfo: { ...server.getClientVersion(), rootPath },
|
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 tool = this._tools.find(tool => tool.schema.name === name)!;
|
||||||
|
if (!tool)
|
||||||
|
throw new Error(`Tool "${name}" not found`);
|
||||||
|
const parsedArguments = tool.schema.inputSchema.parse(rawArguments || {});
|
||||||
const context = this._context!;
|
const context = this._context!;
|
||||||
const response = new Response(context, schema.name, parsedArguments);
|
const response = new Response(context, name, parsedArguments);
|
||||||
const tool = this._tools.find(tool => tool.schema.name === schema.name)!;
|
|
||||||
context.setRunningTool(true);
|
context.setRunningTool(true);
|
||||||
try {
|
try {
|
||||||
await tool.handle(context, parsedArguments, response);
|
await tool.handle(context, parsedArguments, response);
|
||||||
@@ -93,48 +83,6 @@ export class BrowserServerBackend implements ServerBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
serverClosed() {
|
serverClosed() {
|
||||||
void this._context!.dispose().catch(logUnhandledError);
|
void this._context?.dispose().catch(logUnhandledError);
|
||||||
}
|
|
||||||
|
|
||||||
private _defineContextSwitchTool(factories: FactoryList): Tool<any> {
|
|
||||||
const self = this;
|
|
||||||
return defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
|
|
||||||
schema: {
|
|
||||||
name: 'browser_connect',
|
|
||||||
title: 'Connect to a browser context',
|
|
||||||
description: [
|
|
||||||
'Connect to a browser using one of the available methods:',
|
|
||||||
...factories.map(factory => `- "${factory.name}": ${factory.description}`),
|
|
||||||
].join('\n'),
|
|
||||||
inputSchema: z.object({
|
|
||||||
method: z.enum(factories.map(factory => factory.name) as [string, ...string[]]).default(factories[0].name).describe('The method to use to connect to the browser'),
|
|
||||||
}),
|
|
||||||
type: 'readOnly',
|
|
||||||
},
|
|
||||||
|
|
||||||
async handle(context, params, response) {
|
|
||||||
const factory = factories.find(factory => factory.name === params.method);
|
|
||||||
if (!factory) {
|
|
||||||
response.addError('Unknown connection method: ' + params.method);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await self._setContextFactory(factory);
|
|
||||||
response.addResult('Successfully changed connection method.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _setContextFactory(newFactory: BrowserContextFactory) {
|
|
||||||
if (this._context) {
|
|
||||||
const options = {
|
|
||||||
...this._context.options,
|
|
||||||
browserContextFactory: newFactory,
|
|
||||||
};
|
|
||||||
await this._context.dispose();
|
|
||||||
this._context = new Context(options);
|
|
||||||
}
|
|
||||||
this._browserContextFactory = newFactory;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ 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 './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';
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
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 { outputFile } from './config.js';
|
||||||
|
|
||||||
|
|||||||
3
src/extension/DEPS.list
Normal file
3
src/extension/DEPS.list
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[*]
|
||||||
|
../mcp/
|
||||||
|
../utils/
|
||||||
@@ -26,9 +26,11 @@ import { spawn } from 'child_process';
|
|||||||
import http from 'http';
|
import http from 'http';
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import { WebSocket, WebSocketServer } from 'ws';
|
import { WebSocket, WebSocketServer } from 'ws';
|
||||||
import { httpAddressToString } from '../httpServer.js';
|
import { httpAddressToString } from '../mcp/http.js';
|
||||||
import { logUnhandledError } from '../log.js';
|
import { logUnhandledError } from '../utils/log.js';
|
||||||
import { ManualPromise } from '../manualPromise.js';
|
import { ManualPromise } from '../utils/manualPromise.js';
|
||||||
|
import { packageJSON } from '../utils/package.js';
|
||||||
|
|
||||||
import type websocket from 'ws';
|
import type websocket from 'ws';
|
||||||
import type { ClientInfo } from '../browserContextFactory.js';
|
import type { ClientInfo } from '../browserContextFactory.js';
|
||||||
|
|
||||||
@@ -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}`;
|
||||||
@@ -98,6 +102,9 @@ export class CDPRelayServer {
|
|||||||
debugLogger('Waiting for incoming extension connection');
|
debugLogger('Waiting for incoming extension connection');
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
this._extensionConnectionPromise,
|
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))
|
new Promise((_, reject) => abortSignal.addEventListener('abort', reject))
|
||||||
]);
|
]);
|
||||||
debugLogger('Extension connection established');
|
debugLogger('Extension connection established');
|
||||||
@@ -106,9 +113,14 @@ export class CDPRelayServer {
|
|||||||
private _connectBrowser(clientInfo: ClientInfo) {
|
private _connectBrowser(clientInfo: ClientInfo) {
|
||||||
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/lib/ui/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);
|
||||||
const href = url.toString();
|
const href = url.toString();
|
||||||
const executableInfo = registry.findExecutable(this._browserChannel);
|
const executableInfo = registry.findExecutable(this._browserChannel);
|
||||||
if (!executableInfo)
|
if (!executableInfo)
|
||||||
@@ -117,7 +129,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,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
import { startHttpServer } from '../httpServer.js';
|
import { startHttpServer } from '../mcp/http.js';
|
||||||
import { CDPRelayServer } from './cdpRelay.js';
|
import { CDPRelayServer } from './cdpRelay.js';
|
||||||
|
|
||||||
import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js';
|
import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js';
|
||||||
@@ -24,55 +24,40 @@ import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory
|
|||||||
const debugLogger = debug('pw:mcp:relay');
|
const debugLogger = debug('pw:mcp:relay');
|
||||||
|
|
||||||
export class ExtensionContextFactory implements BrowserContextFactory {
|
export class ExtensionContextFactory implements BrowserContextFactory {
|
||||||
name = 'extension';
|
|
||||||
description = 'Connect to a browser using the Playwright MCP extension';
|
|
||||||
|
|
||||||
private _browserChannel: string;
|
private _browserChannel: string;
|
||||||
private _relayPromise: Promise<CDPRelayServer> | undefined;
|
private _userDataDir?: string;
|
||||||
private _browserPromise: Promise<playwright.Browser> | undefined;
|
|
||||||
|
|
||||||
constructor(browserChannel: string) {
|
constructor(browserChannel: string, userDataDir: string | undefined) {
|
||||||
this._browserChannel = browserChannel;
|
this._browserChannel = browserChannel;
|
||||||
|
this._userDataDir = userDataDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||||
// First call will establish the connection to the extension.
|
const browser = await this._obtainBrowser(clientInfo, abortSignal);
|
||||||
if (!this._browserPromise)
|
|
||||||
this._browserPromise = this._obtainBrowser(clientInfo, abortSignal);
|
|
||||||
const browser = await this._browserPromise;
|
|
||||||
return {
|
return {
|
||||||
browserContext: browser.contexts()[0],
|
browserContext: browser.contexts()[0],
|
||||||
close: async () => {
|
close: async () => {
|
||||||
debugLogger('close() called for browser context');
|
debugLogger('close() called for browser context');
|
||||||
await browser.close();
|
await browser.close();
|
||||||
this._browserPromise = undefined;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _obtainBrowser(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<playwright.Browser> {
|
private async _obtainBrowser(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<playwright.Browser> {
|
||||||
if (!this._relayPromise)
|
const relay = await this._startRelay(abortSignal);
|
||||||
this._relayPromise = this._startRelay(abortSignal);
|
|
||||||
const relay = await this._relayPromise;
|
|
||||||
|
|
||||||
abortSignal.throwIfAborted();
|
|
||||||
await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal);
|
await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal);
|
||||||
const browser = await playwright.chromium.connectOverCDP(relay.cdpEndpoint());
|
return await playwright.chromium.connectOverCDP(relay.cdpEndpoint());
|
||||||
browser.on('disconnected', () => {
|
|
||||||
this._browserPromise = undefined;
|
|
||||||
debugLogger('Browser disconnected');
|
|
||||||
});
|
|
||||||
return browser;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _startRelay(abortSignal: AbortSignal) {
|
private async _startRelay(abortSignal: AbortSignal) {
|
||||||
const httpServer = await startHttpServer({});
|
const httpServer = await startHttpServer({});
|
||||||
const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel);
|
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()}.`);
|
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
|
||||||
if (abortSignal.aborted)
|
|
||||||
cdpRelayServer.stop();
|
|
||||||
else
|
|
||||||
abortSignal.addEventListener('abort', () => cdpRelayServer.stop());
|
|
||||||
return cdpRelayServer;
|
return cdpRelayServer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +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 { ExtensionContextFactory } from './extensionContextFactory.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) {
|
|
||||||
const contextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome');
|
|
||||||
const serverBackendFactory = () => new BrowserServerBackend(config, [contextFactory]);
|
|
||||||
await mcpTransport.start(serverBackendFactory, config.server);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createExtensionContextFactory(config: FullConfig) {
|
|
||||||
return new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome');
|
|
||||||
}
|
|
||||||
@@ -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';
|
||||||
@@ -27,7 +28,7 @@ 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);
|
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 {
|
||||||
|
|||||||
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);
|
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,61 @@
|
|||||||
* 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();
|
||||||
|
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');
|
||||||
@@ -108,30 +136,3 @@ async function handleStreamable(serverBackendFactory: ServerBackendFactory, req:
|
|||||||
res.statusCode = 400;
|
res.statusCode = 400;
|
||||||
res.end('Invalid request');
|
res.end('Invalid request');
|
||||||
}
|
}
|
||||||
|
|
||||||
function startHttpTransport(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);
|
|
||||||
});
|
|
||||||
const url = httpAddressToString(httpServer.address());
|
|
||||||
const message = [
|
|
||||||
`Listening on ${url}`,
|
|
||||||
'Put this in your client config:',
|
|
||||||
JSON.stringify({
|
|
||||||
'mcpServers': {
|
|
||||||
'playwright': {
|
|
||||||
'url': `${url}/mcp`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, undefined, 2),
|
|
||||||
'For legacy SSE transport support, you can use the /sse endpoint instead.',
|
|
||||||
].join('\n');
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error(message);
|
|
||||||
}
|
|
||||||
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 } 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(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,80 +14,66 @@
|
|||||||
* 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 { ManualPromise } from '../manualPromise.js';
|
import { httpAddressToString, installHttpTransport, startHttpServer } from './http.js';
|
||||||
import { logUnhandledError } from '../log.js';
|
import { InProcessTransport } from './inProcessTransport.js';
|
||||||
|
|
||||||
import type { ImageContent, 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 { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
|
export type { Tool, CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
export type ClientCapabilities = {
|
const serverDebug = debug('pw:mcp:server');
|
||||||
roots?: {
|
const errorsDebug = debug('pw:mcp:errors');
|
||||||
listRoots?: boolean
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ToolResponse = {
|
|
||||||
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 type ClientVersion = { name: string, version: string };
|
||||||
export interface ServerBackend {
|
export interface ServerBackend {
|
||||||
name: string;
|
initialize?(clientVersion: ClientVersion, roots: Root[]): Promise<void>;
|
||||||
version: string;
|
listTools(): Promise<Tool[]>;
|
||||||
initialize?(server: Server): Promise<void>;
|
callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult>;
|
||||||
tools(): ToolSchema<any>[];
|
|
||||||
callTool(schema: ToolSchema<any>, parsedArguments: any): Promise<ToolResponse>;
|
|
||||||
serverClosed?(): 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);
|
||||||
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 initializedPromise = new ManualPromise<void>();
|
const server = createServer('Internal', '0.0.0', backend, false);
|
||||||
const server = new Server({ name: backend.name, version: backend.version }, {
|
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;
|
await initializedPromise;
|
||||||
|
|
||||||
if (runHeartbeat && !heartbeatRunning) {
|
if (runHeartbeat && !heartbeatRunning) {
|
||||||
@@ -95,22 +81,29 @@ export function createServer(backend: ServerBackend, runHeartbeat: boolean): Ser
|
|||||||
startHeartbeat(server);
|
startHeartbeat(server);
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorResult = (...messages: string[]) => ({
|
|
||||||
content: [{ type: 'text', text: '### Result\n' + messages.join('\n') }],
|
|
||||||
isError: true,
|
|
||||||
});
|
|
||||||
const tool = tools.find(tool => tool.name === request.params.name) as ToolSchema<any>;
|
|
||||||
if (!tool)
|
|
||||||
return errorResult(`Error: 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', () => {
|
addServerListener(server, 'initialized', async () => {
|
||||||
backend.initialize?.(server).then(() => initializedPromise.resolve()).catch(logUnhandledError);
|
try {
|
||||||
|
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?.(clientVersion, clientRoots);
|
||||||
|
initializedPromiseResolve();
|
||||||
|
} catch (e) {
|
||||||
|
errorsDebug(e);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
addServerListener(server, 'close', () => backend.serverClosed?.());
|
addServerListener(server, 'close', () => backend.serverClosed?.());
|
||||||
return server;
|
return server;
|
||||||
@@ -138,3 +131,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);
|
||||||
|
}
|
||||||
|
|||||||
42
src/mcp/tool.ts
Normal file
42
src/mcp/tool.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 { createExtensionContextFactory, runWithExtension } from './extension/main.js';
|
|
||||||
import { BrowserServerBackend, FactoryList } 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,8 +57,8 @@ 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('--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 => {
|
||||||
@@ -67,31 +69,62 @@ program
|
|||||||
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);
|
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);
|
if (options.connectTool) {
|
||||||
const factories: FactoryList = [browserContextFactory];
|
const providers: MCPProvider[] = [
|
||||||
if (options.connectTool)
|
{
|
||||||
factories.push(createExtensionContextFactory(config));
|
name: 'default',
|
||||||
const serverBackendFactory = () => new BrowserServerBackend(config, factories);
|
description: 'Starts standalone browser',
|
||||||
await mcpTransport.start(serverBackendFactory, config.server);
|
connect: () => mcpServer.wrapInProcess(new BrowserServerBackend(config, browserContextFactory)),
|
||||||
|
},
|
||||||
if (config.saveTrace) {
|
{
|
||||||
const server = await startTraceViewerServer();
|
name: 'extension',
|
||||||
const urlPrefix = server.urlPrefix('human-readable');
|
description: 'Connect to a browser using the Playwright MCP extension',
|
||||||
const url = urlPrefix + '/trace/index.html?trace=' + config.browser.launchOptions.tracesDir + '/trace.json';
|
connect: () => mcpServer.wrapInProcess(new BrowserServerBackend(config, extensionContextFactory)),
|
||||||
// eslint-disable-next-line no-console
|
},
|
||||||
console.error('\nTrace viewer listening on ' + url);
|
];
|
||||||
|
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() {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { Response } from './response.js';
|
import { Response } from './response.js';
|
||||||
import { logUnhandledError } from './log.js';
|
import { logUnhandledError } from './utils/log.js';
|
||||||
import { outputFile } from './config.js';
|
import { outputFile } from './config.js';
|
||||||
|
|
||||||
import type { FullConfig } from './config.js';
|
import type { FullConfig } from './config.js';
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
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 './utils/manualPromise.js';
|
||||||
import { ModalState } from './tools/tool.js';
|
import { ModalState } from './tools/tool.js';
|
||||||
|
|
||||||
import type { Context } from './context.js';
|
import type { Context } from './context.js';
|
||||||
|
|||||||
2
src/tools/DEPS.list
Normal file
2
src/tools/DEPS.list
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[*]
|
||||||
|
../utils/
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
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.'),
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -75,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({
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
@@ -16,14 +16,10 @@
|
|||||||
|
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
export function createGuid(): string {
|
||||||
|
return crypto.randomBytes(16).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
export function createHash(data: string): string {
|
export function createHash(data: string): string {
|
||||||
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 7);
|
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 7);
|
||||||
}
|
}
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
@@ -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 { 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';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
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(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?(): 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]
|
||||||
|
);
|
||||||
@@ -46,6 +46,40 @@ test('test snapshot tool list', async ({ client }) => {
|
|||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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_forward',
|
||||||
|
'browser_navigate',
|
||||||
|
'browser_network_requests',
|
||||||
|
'browser_press_key',
|
||||||
|
'browser_resize',
|
||||||
|
'browser_snapshot',
|
||||||
|
'browser_tab_close',
|
||||||
|
'browser_tab_list',
|
||||||
|
'browser_tab_new',
|
||||||
|
'browser_tab_select',
|
||||||
|
'browser_take_screenshot',
|
||||||
|
'browser_wait_for',
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
test('test capabilities (pdf)', async ({ startClient }) => {
|
test('test capabilities (pdf)', async ({ startClient }) => {
|
||||||
const { client } = await startClient({
|
const { client } = await startClient({
|
||||||
args: ['--caps=pdf'],
|
args: ['--caps=pdf'],
|
||||||
|
|||||||
@@ -40,14 +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?: {
|
startClient: StartClient;
|
||||||
clientName?: string,
|
|
||||||
args?: string[],
|
|
||||||
config?: Config,
|
|
||||||
roots?: { name: string, uri: string }[],
|
|
||||||
}) => Promise<{ client: Client, stderr: () => string }>;
|
|
||||||
wsEndpoint: string;
|
wsEndpoint: string;
|
||||||
cdpServer: CDPServer;
|
cdpServer: CDPServer;
|
||||||
server: TestServer;
|
server: TestServer;
|
||||||
@@ -68,7 +72,7 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
|
|
||||||
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
|
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
|
||||||
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[] = [];
|
||||||
@@ -86,9 +90,11 @@ 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' }, options?.roots ? { capabilities: { roots: {} } } : undefined);
|
const client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }, options?.roots ? { capabilities: { roots: {} } } : undefined);
|
||||||
if (options?.roots) {
|
if (options?.roots) {
|
||||||
client.setRequestHandler(ListRootsRequestSchema, async request => {
|
client.setRequestHandler(ListRootsRequestSchema, async request => {
|
||||||
|
if (options.rootsResponseDelay)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, options.rootsResponseDelay));
|
||||||
return {
|
return {
|
||||||
roots: options.roots,
|
roots: options.roots,
|
||||||
};
|
};
|
||||||
@@ -101,12 +107,13 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
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) => {
|
||||||
@@ -123,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,
|
||||||
@@ -191,7 +200,7 @@ 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,
|
||||||
|
|||||||
@@ -19,12 +19,13 @@ import path from 'path';
|
|||||||
import { pathToFileURL } from 'url';
|
import { pathToFileURL } from 'url';
|
||||||
|
|
||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
import { createHash } from '../src/utils.js';
|
import { createHash } from '../src/utils/guid.js';
|
||||||
|
|
||||||
const p = process.platform === 'win32' ? 'c:\\non\\existent\\folder' : '/non/existent/folder';
|
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) => {
|
test('should use separate user data by root path', async ({ startClient, server }, testInfo) => {
|
||||||
const { client } = await startClient({
|
const { client } = await startClient({
|
||||||
|
clientName: 'Visual Studio Code',
|
||||||
roots: [
|
roots: [
|
||||||
{
|
{
|
||||||
name: 'test',
|
name: 'test',
|
||||||
@@ -43,11 +44,11 @@ test('should use separate user data by root path', async ({ startClient, server
|
|||||||
expect(file).toContain(hash);
|
expect(file).toContain(hash);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('check that trace is saved in workspace', async ({ startClient, server }, testInfo) => {
|
||||||
test('check that trace is saved in workspace', async ({ startClient, server, mcpMode }, testInfo) => {
|
|
||||||
const rootPath = testInfo.outputPath('workspace');
|
const rootPath = testInfo.outputPath('workspace');
|
||||||
const { client } = await startClient({
|
const { client } = await startClient({
|
||||||
args: ['--save-trace'],
|
args: ['--save-trace'],
|
||||||
|
clientName: 'My client',
|
||||||
roots: [
|
roots: [
|
||||||
{
|
{
|
||||||
name: 'workspace',
|
name: 'workspace',
|
||||||
@@ -66,3 +67,13 @@ test('check that trace is saved in workspace', async ({ startClient, server, mcp
|
|||||||
const [file] = await fs.promises.readdir(path.join(rootPath, '.playwright-mcp'));
|
const [file] = await fs.promises.readdir(path.join(rootPath, '.playwright-mcp'));
|
||||||
expect(file).toContain('traces');
|
expect(file).toContain('traces');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should list all tools when listRoots is slow', async ({ startClient, server }, testInfo) => {
|
||||||
|
const { client } = await startClient({
|
||||||
|
clientName: 'Another custom client',
|
||||||
|
roots: [],
|
||||||
|
rootsResponseDelay: 1000,
|
||||||
|
});
|
||||||
|
const tools = await client.listTools();
|
||||||
|
expect(tools.tools.length).toBeGreaterThan(20);
|
||||||
|
});
|
||||||
|
|||||||
@@ -264,12 +264,7 @@ test('browser_take_screenshot (fullPage: true)', async ({ startClient, server },
|
|||||||
{
|
{
|
||||||
text: expect.stringContaining('fullPage: true'),
|
text: expect.stringContaining('fullPage: true'),
|
||||||
type: 'text',
|
type: 'text',
|
||||||
},
|
}
|
||||||
{
|
|
||||||
data: expect.any(String),
|
|
||||||
mimeType: 'image/png',
|
|
||||||
type: 'image',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
54
tests/vscode.spec.ts
Normal file
54
tests/vscode.spec.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 { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
|
test('browser_connect(vscode) works', async ({ startClient, playwright, browserName }) => {
|
||||||
|
const { client } = await startClient({
|
||||||
|
args: ['--vscode'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = await playwright[browserName].launchServer();
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_connect',
|
||||||
|
arguments: {
|
||||||
|
connectionString: server.wsEndpoint(),
|
||||||
|
lib: import.meta.resolve('playwright'),
|
||||||
|
}
|
||||||
|
})).toHaveResponse({
|
||||||
|
result: 'Successfully connected.'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: 'data:text/html,foo'
|
||||||
|
}
|
||||||
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining('foo'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.close();
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_snapshot',
|
||||||
|
arguments: {}
|
||||||
|
}), 'it actually used the server').toHaveResponse({
|
||||||
|
isError: true,
|
||||||
|
result: expect.stringContaining('ECONNREFUSED')
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,6 +47,7 @@ test('browser_wait_for(text)', async ({ client, server }) => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_wait_for',
|
name: 'browser_wait_for',
|
||||||
arguments: { text: 'Text to appear' },
|
arguments: { text: 'Text to appear' },
|
||||||
|
code: `await page.getByText("Text to appear").first().waitFor({ state: 'visible' });`,
|
||||||
})).toHaveResponse({
|
})).toHaveResponse({
|
||||||
pageState: expect.stringContaining(`- generic [ref=e3]: Text to appear`),
|
pageState: expect.stringContaining(`- generic [ref=e3]: Text to appear`),
|
||||||
});
|
});
|
||||||
@@ -83,7 +84,24 @@ test('browser_wait_for(textGone)', async ({ client, server }) => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_wait_for',
|
name: 'browser_wait_for',
|
||||||
arguments: { textGone: 'Text to disappear' },
|
arguments: { textGone: 'Text to disappear' },
|
||||||
|
code: `await page.getByText("Text to disappear").first().waitFor({ state: 'hidden' });`,
|
||||||
})).toHaveResponse({
|
})).toHaveResponse({
|
||||||
pageState: expect.stringContaining(`- generic [ref=e3]: Text to appear`),
|
pageState: expect.stringContaining(`- generic [ref=e3]: Text to appear`),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('browser_wait_for(time)', async ({ client, server }) => {
|
||||||
|
server.setContent('/', `<body><div>Hello World</div></body>`, 'text/html');
|
||||||
|
|
||||||
|
await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.PREFIX },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_wait_for',
|
||||||
|
arguments: { time: 1 },
|
||||||
|
})).toHaveResponse({
|
||||||
|
code: `await new Promise(f => setTimeout(f, 1 * 1000));`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
179
utils/check-deps.js
Normal file
179
utils/check-deps.js
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Copyright 2019 Google Inc. All rights reserved.
|
||||||
|
* Modifications copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import ts from 'typescript';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
||||||
|
|
||||||
|
const depsCache = {};
|
||||||
|
const packageRoot = path.resolve(__dirname, '..');
|
||||||
|
|
||||||
|
async function checkDeps() {
|
||||||
|
const deps = new Set();
|
||||||
|
const src = path.join(packageRoot, 'src');
|
||||||
|
|
||||||
|
const program = ts.createProgram({
|
||||||
|
options: {
|
||||||
|
allowJs: true,
|
||||||
|
target: ts.ScriptTarget.ESNext,
|
||||||
|
strict: true,
|
||||||
|
},
|
||||||
|
rootNames: listAllFiles(src),
|
||||||
|
});
|
||||||
|
const sourceFiles = program.getSourceFiles();
|
||||||
|
const errors = [];
|
||||||
|
sourceFiles.filter(x => !x.fileName.includes(path.sep + 'node_modules' + path.sep) && !x.fileName.includes(path.sep + 'bundles' + path.sep)).map(x => visit(x, x.fileName, x.getFullText()));
|
||||||
|
|
||||||
|
if (errors.length) {
|
||||||
|
for (const error of errors)
|
||||||
|
console.log(error);
|
||||||
|
console.log(`--------------------------------------------------------`);
|
||||||
|
console.log(`Changing the project structure or adding new components?`);
|
||||||
|
console.log(`Update DEPS in ${packageRoot}`);
|
||||||
|
console.log(`--------------------------------------------------------`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function visit(node, fileName, text) {
|
||||||
|
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
||||||
|
if (node.importClause) {
|
||||||
|
if (node.importClause.isTypeOnly)
|
||||||
|
return;
|
||||||
|
if (node.importClause.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) {
|
||||||
|
if (node.importClause.namedBindings.elements.every(e => e.isTypeOnly))
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const importName = node.moduleSpecifier.text;
|
||||||
|
let importPath;
|
||||||
|
if (importName.startsWith('.'))
|
||||||
|
importPath = path.resolve(path.dirname(fileName), importName);
|
||||||
|
|
||||||
|
const mergedDeps = calculateDeps(fileName);
|
||||||
|
if (mergedDeps.includes('***'))
|
||||||
|
return;
|
||||||
|
if (importPath) {
|
||||||
|
if (!fs.existsSync(importPath)) {
|
||||||
|
if (fs.existsSync(importPath + '.ts'))
|
||||||
|
importPath = importPath + '.ts';
|
||||||
|
else if (fs.existsSync(importPath + '.tsx'))
|
||||||
|
importPath = importPath + '.tsx';
|
||||||
|
else if (fs.existsSync(importPath + '.d.ts'))
|
||||||
|
importPath = importPath + '.d.ts';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowImport(fileName, importPath, mergedDeps))
|
||||||
|
errors.push(`Disallowed import ${path.relative(packageRoot, importPath)} in ${path.relative(packageRoot, fileName)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullStart = node.getFullStart();
|
||||||
|
const commentRanges = ts.getLeadingCommentRanges(text, fullStart);
|
||||||
|
for (const range of commentRanges || []) {
|
||||||
|
const comment = text.substring(range.pos, range.end);
|
||||||
|
if (comment.includes('@no-check-deps'))
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importName.startsWith('@'))
|
||||||
|
deps.add(importName.split('/').slice(0, 2).join('/'));
|
||||||
|
else
|
||||||
|
deps.add(importName.split('/')[0]);
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, x => visit(x, fileName, text));
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateDeps(from) {
|
||||||
|
const fromDirectory = path.dirname(from);
|
||||||
|
let depsDirectory = fromDirectory;
|
||||||
|
while (depsDirectory.startsWith(packageRoot) && !depsCache[depsDirectory] && !fs.existsSync(path.join(depsDirectory, 'DEPS.list')))
|
||||||
|
depsDirectory = path.dirname(depsDirectory);
|
||||||
|
if (!depsDirectory.startsWith(packageRoot))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
let deps = depsCache[depsDirectory];
|
||||||
|
if (!deps) {
|
||||||
|
const depsListFile = path.join(depsDirectory, 'DEPS.list');
|
||||||
|
deps = {};
|
||||||
|
let group = [];
|
||||||
|
for (const line of fs.readFileSync(depsListFile, 'utf-8').split('\n').filter(Boolean).filter(l => !l.startsWith('#'))) {
|
||||||
|
const groupMatch = line.match(/\[(.*)\]/);
|
||||||
|
if (groupMatch) {
|
||||||
|
group = [];
|
||||||
|
deps[groupMatch[1]] = group;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line === '***')
|
||||||
|
group.push('***');
|
||||||
|
else
|
||||||
|
group.push(path.resolve(depsDirectory, line));
|
||||||
|
}
|
||||||
|
depsCache[depsDirectory] = deps;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...(deps['*'] || []), ...(deps[path.relative(depsDirectory, from)] || [])]
|
||||||
|
}
|
||||||
|
|
||||||
|
function allowImport(from, to, mergedDeps) {
|
||||||
|
const fromDirectory = path.dirname(from);
|
||||||
|
const toDirectory = isDirectory(to) ? to : path.dirname(to);
|
||||||
|
if (to === toDirectory)
|
||||||
|
to = path.join(to, 'index.ts');
|
||||||
|
if (fromDirectory === toDirectory)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
for (const dep of mergedDeps) {
|
||||||
|
if (dep === '***')
|
||||||
|
return true;
|
||||||
|
if (to === dep || toDirectory === dep)
|
||||||
|
return true;
|
||||||
|
if (dep.endsWith('**')) {
|
||||||
|
const parent = dep.substring(0, dep.length - 2);
|
||||||
|
if (to.startsWith(parent))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function listAllFiles(dir) {
|
||||||
|
const dirs = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
const result = [];
|
||||||
|
dirs.forEach(d => {
|
||||||
|
const res = path.resolve(dir, d.name);
|
||||||
|
if (d.isDirectory())
|
||||||
|
result.push(...listAllFiles(res));
|
||||||
|
else
|
||||||
|
result.push(res);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkDeps().catch(e => {
|
||||||
|
console.error(e && e.stack ? e.stack : e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
function isDirectory(dir) {
|
||||||
|
return fs.existsSync(dir) && fs.statSync(dir).isDirectory();
|
||||||
|
}
|
||||||
68
utils/set-version.js
Normal file
68
utils/set-version.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import child_process from 'child_process';
|
||||||
|
import { argv } from 'process';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
||||||
|
|
||||||
|
const readJSON = async (filePath) => JSON.parse(await fs.promises.readFile(filePath, 'utf8'));
|
||||||
|
const writeJSON = async (filePath, json) => {
|
||||||
|
await fs.promises.writeFile(filePath, JSON.stringify(json, null, 2) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePackageJSON(dir, version) {
|
||||||
|
const packageJSONPath = path.join(dir, 'package.json');
|
||||||
|
const packageJSON = await readJSON(packageJSONPath);
|
||||||
|
console.log(`Updating ${packageJSONPath} to version ${version}`);
|
||||||
|
packageJSON.version = version;
|
||||||
|
await writeJSON(packageJSONPath, packageJSON);
|
||||||
|
|
||||||
|
// Run npm i to update package-lock.json
|
||||||
|
child_process.execSync('npm i', {
|
||||||
|
cwd: dir
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateExtensionManifest(dir, version) {
|
||||||
|
const manifestPath = path.join(dir, 'manifest.json');
|
||||||
|
const manifest = await readJSON(manifestPath);
|
||||||
|
console.log(`Updating ${manifestPath} to version ${version}`);
|
||||||
|
manifest.version = version;
|
||||||
|
await writeJSON(manifestPath, manifest);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setVersion(version) {
|
||||||
|
if (version.startsWith('v'))
|
||||||
|
throw new Error('version must not start with "v"');
|
||||||
|
|
||||||
|
const packageRoot = path.join(__dirname, '..');
|
||||||
|
await updatePackageJSON(packageRoot, version)
|
||||||
|
await updatePackageJSON(path.join(packageRoot, 'extension'), version)
|
||||||
|
await updateExtensionManifest(path.join(packageRoot, 'extension'), version)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (argv.length !== 3) {
|
||||||
|
console.error('Usage: set-version <version>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
setVersion(argv[2]);
|
||||||
Reference in New Issue
Block a user