Compare commits
301 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78795fc7c7 | ||
|
|
e4b545c438 | ||
|
|
3d6a66fd08 | ||
|
|
971489536e | ||
|
|
6171abd277 | ||
|
|
60d2b97b43 | ||
|
|
3fe5b8a408 | ||
|
|
c58b2a93da | ||
|
|
d142f13d80 | ||
|
|
2461f32d05 | ||
|
|
8d86ce4958 | ||
|
|
87741662f4 | ||
|
|
b85dc6954a | ||
|
|
e8e2af40b7 | ||
|
|
b176111891 | ||
|
|
29d468dac7 | ||
|
|
51ab77e04e | ||
|
|
7fb8b0dc3a | ||
|
|
fc04de2be5 | ||
|
|
11480fa8ce | ||
|
|
78298c3448 | ||
|
|
7774ad93ca | ||
|
|
1a64a51812 | ||
|
|
22043cb3ef | ||
|
|
0812df2f5e | ||
|
|
3d1a60b7f3 | ||
|
|
86eba2245a | ||
|
|
2521a67b2f | ||
|
|
fb28e99fa4 | ||
|
|
64af5f8763 | ||
|
|
fb65bc7559 | ||
|
|
94ca0763d5 | ||
|
|
2ae7800ac1 | ||
|
|
f6862a39c3 | ||
|
|
e664e0460c | ||
|
|
865eac2fee | ||
|
|
d5d810f896 | ||
|
|
1efd3b55e5 | ||
|
|
1d1db1e287 | ||
|
|
25f15e7f5e | ||
|
|
c559243ef6 | ||
|
|
91d5d24cab | ||
|
|
92554abfd1 | ||
|
|
4370f2cdf2 | ||
|
|
ba726fb44a | ||
|
|
2fc4e88048 | ||
|
|
3f148a4005 | ||
|
|
c92aefdc12 | ||
|
|
badfd82202 | ||
|
|
12942b81d6 | ||
|
|
73adb0fdf0 | ||
|
|
8572ab300c | ||
|
|
c091a11d76 | ||
|
|
dbd44110f1 | ||
|
|
2f41a3f6b1 | ||
|
|
7c4d67b3ae | ||
|
|
53c6b6dcb1 | ||
|
|
1fb2878271 | ||
|
|
ab0ecc4075 | ||
|
|
f010164bf1 | ||
|
|
db9cfe1720 | ||
|
|
24f81a7a27 | ||
|
|
21ced701b5 | ||
|
|
d3bf2eefc6 | ||
|
|
2ca899316d | ||
|
|
16f3523317 | ||
|
|
6c2dda31ad | ||
|
|
3b6ecf0a43 | ||
|
|
636f1956cc | ||
|
|
5aef2aafcb | ||
|
|
8ecc46c905 | ||
|
|
5dbb1504ba | ||
|
|
20e1144c3b | ||
|
|
eab20aa69e | ||
|
|
46ce86f97e | ||
|
|
4890b9d509 | ||
|
|
3f6837baa9 | ||
|
|
6d62c173c8 | ||
|
|
3c6eac9b21 | ||
|
|
41a44f7abc | ||
|
|
372395666a | ||
|
|
a60d7b8cd1 | ||
|
|
ffe0117456 | ||
|
|
7c07cc86eb | ||
|
|
3787439fc1 | ||
|
|
2a86ac74e3 | ||
|
|
6dd44923da | ||
|
|
f600234897 | ||
|
|
4df162aff5 | ||
|
|
65d99fe595 | ||
|
|
903c857f19 | ||
|
|
9b5f97b076 | ||
|
|
04988d8fac | ||
|
|
2bf57e22c6 | ||
|
|
dbf113d5e4 | ||
|
|
6710a78641 | ||
|
|
a9b9fb85da | ||
|
|
26a2a6fc83 | ||
|
|
e934d5e23e | ||
|
|
ecfa10448b | ||
|
|
e153ac3b7c | ||
|
|
e0fb748ccc | ||
|
|
c63b7823e1 | ||
|
|
bd34e9d7e9 | ||
|
|
c72d0320f4 | ||
|
|
da8a244f33 | ||
|
|
31a4fb3d07 | ||
|
|
bc120baa78 | ||
|
|
2c5eac89a8 | ||
|
|
288f1b863b | ||
|
|
53e3e37991 | ||
|
|
b1a0f775cf | ||
|
|
6320b08173 | ||
|
|
601a74305c | ||
|
|
c2b98dc70b | ||
|
|
70862ce456 | ||
|
|
468c84eb8f | ||
|
|
cfcca40b90 | ||
|
|
f1826b96b6 | ||
|
|
eeeab4f042 | ||
|
|
efe3ff0c7c | ||
|
|
e3df209b96 | ||
|
|
29711d07d3 | ||
|
|
b0be1ee256 | ||
|
|
d3867affed | ||
|
|
1eee30fd45 | ||
|
|
29ac29e6bb | ||
|
|
9f8441daa5 | ||
|
|
64f950ae42 | ||
|
|
5bfff0a059 | ||
|
|
c97bc6e2ae | ||
|
|
fe0c0ffffe | ||
|
|
9526910864 | ||
|
|
95454735bf | ||
|
|
e9f6433241 | ||
|
|
d61aa16fee | ||
|
|
012c906500 | ||
|
|
825a97d66e | ||
|
|
3061d9aa56 | ||
|
|
da818d113a | ||
|
|
a5a57df105 | ||
|
|
be8adb1866 | ||
|
|
c5a2324aaf | ||
|
|
128474b4aa | ||
|
|
7fca8f50f8 | ||
|
|
841bb417d1 | ||
|
|
59f1d67a4e | ||
|
|
1600ba6645 | ||
|
|
127c996e86 | ||
|
|
4bd39c07e9 | ||
|
|
f5b68dc590 | ||
|
|
875bd3b6ec | ||
|
|
137b74750c | ||
|
|
ded00dc422 | ||
|
|
5df6c2431b | ||
|
|
9066988098 | ||
|
|
1dc4977ff9 | ||
|
|
96e234012d | ||
|
|
6c3f3b6576 | ||
|
|
0df6d7a441 | ||
|
|
4ea7041ba9 | ||
|
|
7dae68de78 | ||
|
|
60495ed9b0 | ||
|
|
0aaef661b1 | ||
|
|
abbe7858a2 | ||
|
|
767af21e02 | ||
|
|
27c498e0e7 | ||
|
|
0fb9646c4d | ||
|
|
9728527900 | ||
|
|
675b083db3 | ||
|
|
0b74cdaaf8 | ||
|
|
f31ef598bc | ||
|
|
656779531c | ||
|
|
eec177d3ac | ||
|
|
54ed7c3200 | ||
|
|
3cd74a824a | ||
|
|
177b008328 | ||
|
|
9429463951 | ||
|
|
45f493da6c | ||
|
|
9e5ffd2ccf | ||
|
|
1051ea810a | ||
|
|
f20ae22ec6 | ||
|
|
13cd1b4bd9 | ||
|
|
c318f13895 | ||
|
|
1318e39fac | ||
|
|
c2b7fb29de | ||
|
|
aa6ac51f92 | ||
|
|
fea50e6840 | ||
|
|
746c9fc124 | ||
|
|
ee33097abe | ||
|
|
ab20175826 | ||
|
|
c506027aec | ||
|
|
7be0c8872e | ||
|
|
ce72367208 | ||
|
|
949f956378 | ||
|
|
a1eee8351e | ||
|
|
fea3f26e85 | ||
|
|
dd5b41f1d8 | ||
|
|
05dc5d915b | ||
|
|
65a229c79f | ||
|
|
84664d4b09 | ||
|
|
445170a76b | ||
|
|
c28b480b51 | ||
|
|
65716b60dd | ||
|
|
75f74a54bc | ||
|
|
ef41c626ef | ||
|
|
95ca08fdb7 | ||
|
|
053c2f3d32 | ||
|
|
57b3c14276 | ||
|
|
85c85bd2fb | ||
|
|
09ba7989c3 | ||
|
|
a115c31953 | ||
|
|
b5be37e5e7 | ||
|
|
c2255246a3 | ||
|
|
950d0d1d34 | ||
|
|
cdeba454b5 | ||
|
|
91ae93c167 | ||
|
|
35e6c49d7c | ||
|
|
e95b5b1dd6 | ||
|
|
23a2e5fee7 | ||
|
|
d01aa19ffa | ||
|
|
8cd7d5a753 | ||
|
|
42faa3ccf8 | ||
|
|
4694d60fc5 | ||
|
|
7dc689eee7 | ||
|
|
5df011ad4b | ||
|
|
200cf737bb | ||
|
|
d8a59e0d0d | ||
|
|
21533d9000 | ||
|
|
49979641fa | ||
|
|
43aa4001b5 | ||
|
|
7e087af6a6 | ||
|
|
927a1280f1 | ||
|
|
292e75d464 | ||
|
|
2c9376e50f | ||
|
|
062cdd0704 | ||
|
|
a713300c5b | ||
|
|
a15f0f301b | ||
|
|
23ce973377 | ||
|
|
685dea9e19 | ||
|
|
878be97668 | ||
|
|
6d6b1a384b | ||
|
|
fd22def4c5 | ||
|
|
1b60870f50 | ||
|
|
1c760b3826 | ||
|
|
9efaea6a1c | ||
|
|
3f72fe53ec | ||
|
|
40d125f0bb | ||
|
|
21d2f80fef | ||
|
|
6efdc90078 | ||
|
|
ad4147da54 | ||
|
|
69703cc882 | ||
|
|
4147e21a3a | ||
|
|
80c9b93b72 | ||
|
|
12e72a96c4 | ||
|
|
697a69a8c2 | ||
|
|
6e76d5e550 | ||
|
|
26779ceb20 | ||
|
|
23704ace1f | ||
|
|
b02370df2f | ||
|
|
bf7dbabca4 | ||
|
|
7256ee3701 | ||
|
|
0ed0bcd914 | ||
|
|
4d95761f66 | ||
|
|
b9dc323734 | ||
|
|
586492a3f0 | ||
|
|
f7e9bae571 | ||
|
|
1bc3c761de | ||
|
|
c80f7cf222 | ||
|
|
9578a5b2af | ||
|
|
cd5aa344f1 | ||
|
|
dc955c73a3 | ||
|
|
d4f8f87b03 | ||
|
|
0c3792d231 | ||
|
|
7695717546 | ||
|
|
6a070a0dd8 | ||
|
|
6481100bdf | ||
|
|
4b261286bf | ||
|
|
7e4a964b0a | ||
|
|
cea347d067 | ||
|
|
6054290d9a | ||
|
|
6d4adfe5c6 | ||
|
|
e7c7709b33 | ||
|
|
5c2e11017d | ||
|
|
e4331313f9 | ||
|
|
bc48600a49 | ||
|
|
0d6bb2f547 | ||
|
|
795a9d578a | ||
|
|
4a19e18999 | ||
|
|
4d59e06184 | ||
|
|
6891a525b3 | ||
|
|
0f7fd1362f | ||
|
|
de08c24b96 | ||
|
|
71e51ea42a | ||
|
|
0c5a104e0f | ||
|
|
606b898a71 | ||
|
|
e729494bd9 | ||
|
|
77080e8ca4 | ||
|
|
31ac1ed191 | ||
|
|
b8ff009b0a | ||
|
|
42167878fb |
106
.github/workflows/ci.yml
vendored
106
.github/workflows/ci.yml
vendored
@@ -7,29 +7,107 @@ on:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js 18
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run linting
|
||||
- name: Run ESLint
|
||||
run: npm run lint
|
||||
- name: Ensure no changes
|
||||
run: git diff --exit-code
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Install Playwright browsers
|
||||
test:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-15, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Playwright install
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
run: npm run test
|
||||
|
||||
test_docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Playwright install
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
tags: playwright-mcp-dev:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
load: true
|
||||
- name: Run tests
|
||||
shell: bash
|
||||
run: |
|
||||
# Used for the Docker tests to share the test-results folder with the container.
|
||||
umask 0000
|
||||
npm run test -- --project=chromium-docker
|
||||
env:
|
||||
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 MCP server
|
||||
run: |
|
||||
cd ..
|
||||
npm ci
|
||||
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
|
||||
|
||||
48
.github/workflows/publish-canary.yml
vendored
Normal file
48
.github/workflows/publish-canary.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Publish Canary
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 8 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish-canary:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # Needed for npm provenance
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
registry-url: https://registry.npmjs.org/
|
||||
|
||||
- name: Get current date
|
||||
id: date
|
||||
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get current version
|
||||
id: version
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set canary version
|
||||
id: canary-version
|
||||
run: echo "version=${{ steps.version.outputs.version }}-alpha-${{ steps.date.outputs.date }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Update package.json version
|
||||
run: |
|
||||
npm version ${{ steps.canary-version.outputs.version }} --no-git-tag-version
|
||||
|
||||
- run: npm ci
|
||||
- run: npx playwright install --with-deps
|
||||
- run: npm run build
|
||||
- run: npm run lint
|
||||
- run: npm run ctest
|
||||
|
||||
- name: Publish to npm with next tag
|
||||
run: npm publish --tag next --provenance
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Reset package.json version
|
||||
run: git checkout -- package.json
|
||||
85
.github/workflows/publish.yml
vendored
85
.github/workflows/publish.yml
vendored
@@ -7,7 +7,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
id-token: write # Needed for npm provenance
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
@@ -15,9 +15,88 @@ jobs:
|
||||
node-version: 18
|
||||
registry-url: https://registry.npmjs.org/
|
||||
- run: npm ci
|
||||
- run: npx playwright install --with-deps
|
||||
- run: npm run build
|
||||
- run: npm run lint
|
||||
- run: npm run test
|
||||
- run: npm run ctest
|
||||
- run: npm publish --provenance
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
publish-docker:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # Needed for OIDC login to Azure
|
||||
environment: allow-publishing-docker-to-acr
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up QEMU # Needed for multi-platform builds (e.g., arm64 on amd64 runner)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx # Needed for multi-platform builds
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Azure Login via OIDC
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_DOCKER_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_DOCKER_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_DOCKER_SUBSCRIPTION_ID }}
|
||||
- name: Login to ACR
|
||||
run: az acr login --name playwright
|
||||
- name: Build and push Docker image
|
||||
id: build-push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile # Adjust path if your Dockerfile is elsewhere
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
playwright.azurecr.io/public/playwright/mcp:${{ github.event.release.tag_name }}
|
||||
playwright.azurecr.io/public/playwright/mcp:latest
|
||||
- uses: oras-project/setup-oras@v1
|
||||
- name: Set oras tags
|
||||
run: |
|
||||
attach_eol_manifest() {
|
||||
local image="$1"
|
||||
local today=$(date -u +'%Y-%m-%d')
|
||||
# oras is re-using Docker credentials, so we don't need to login.
|
||||
# Following the advice in https://portal.microsofticm.com/imp/v3/incidents/incident/476783820/summary
|
||||
oras attach --artifact-type application/vnd.microsoft.artifact.lifecycle --annotation "vnd.microsoft.artifact.lifecycle.end-of-life.date=$today" $image
|
||||
}
|
||||
# for each tag, attach the eol manifest
|
||||
for tag in $(echo ${{ steps.build-push.outputs.metadata['image.name'] }} | tr ',' '\n'); do
|
||||
attach_eol_manifest $tag
|
||||
done
|
||||
|
||||
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: Get extension version
|
||||
id: get-version
|
||||
working-directory: ./extension
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
- name: Package extension
|
||||
working-directory: ./extension
|
||||
run: |
|
||||
cd dist
|
||||
zip -r ../playwright-mcp-extension-${{ steps.get-version.outputs.version }}.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-${{ steps.get-version.outputs.version }}.zip
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,3 +1,10 @@
|
||||
lib/
|
||||
dist/
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
.vscode/mcp.json
|
||||
.idea
|
||||
.DS_Store
|
||||
.env
|
||||
sessions/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
**/*
|
||||
README.md
|
||||
LICENSE
|
||||
!lib/**/*.js
|
||||
!cli.js
|
||||
!index.*
|
||||
!config.d.ts
|
||||
|
||||
137
CONTRIBUTING.md
Normal file
137
CONTRIBUTING.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Contributing
|
||||
|
||||
## Choose an issue
|
||||
|
||||
Playwright MCP **requires an issue** for every contribution, except for minor documentation updates.
|
||||
|
||||
If you are passionate about a bug/feature, but cannot find an issue describing it, **file an issue first**. This will
|
||||
facilitate the discussion, and you might get some early feedback from project maintainers before spending your time on
|
||||
creating a pull request.
|
||||
|
||||
## Make a change
|
||||
|
||||
> [!WARNING]
|
||||
> The core of the Playwright MCP was moved to the [Playwright monorepo](https://github.com/microsoft/playwright).
|
||||
|
||||
Clone the Playwright repository. If you plan to send a pull request, it might be better to [fork the repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo) first.
|
||||
|
||||
|
||||
```bash
|
||||
git clone https://github.com/microsoft/playwright
|
||||
cd playwright
|
||||
```
|
||||
|
||||
Install dependencies and run the build in watch mode.
|
||||
```bash
|
||||
# install deps and run watch
|
||||
npm ci
|
||||
npm run watch
|
||||
npx playwright install
|
||||
```
|
||||
|
||||
Source code for Playwright MCP is located at [packages/playwright/src/mcp](https://github.com/microsoft/playwright/blob/main/packages/playwright/src/mcp).
|
||||
|
||||
```bash
|
||||
# list source files
|
||||
ls -la packages/playwright/src/mcp
|
||||
```
|
||||
|
||||
Coding style is fully defined in [eslint.config.mjs](https://github.com/microsoft/playwright/blob/main/eslint.config.mjs). Before creating a pull request, or at any moment during development, run linter to check all kinds of things:
|
||||
```bash
|
||||
# lint the source base before sending PR
|
||||
npm run flint
|
||||
```
|
||||
|
||||
Comments should have an explicit purpose and should improve readability rather than hinder it. If the code would not be understood without comments, consider re-writing the code to make it self-explanatory.
|
||||
|
||||
## Add a test
|
||||
|
||||
Playwright requires a test for the new or modified functionality. An exception would be a pure refactoring, but chances are you are doing more than that.
|
||||
|
||||
There are multiple [test suites](https://github.com/microsoft/playwright/blob/main/tests) in Playwright that will be executed on the CI. Tests for Playwright MCP are located at [tests/mcp](https://github.com/microsoft/playwright/blob/main/tests/mcp).
|
||||
|
||||
```bash
|
||||
# list test files
|
||||
ls -la tests/mcp
|
||||
```
|
||||
|
||||
To run the mcp tests, use
|
||||
|
||||
```bash
|
||||
# fast path runs all MCP tests in Chromium
|
||||
npm run mcp-ctest
|
||||
```
|
||||
|
||||
```bash
|
||||
# slow path runs all tests in three browsers
|
||||
npm run mcp-test
|
||||
```
|
||||
|
||||
Since Playwright tests are using Playwright under the hood, everything from our documentation applies, for example [this guide on running and debugging tests](https://playwright.dev/docs/running-tests#running-tests).
|
||||
|
||||
Note that tests should be *hermetic*, and not depend on external services. Tests should work on all three platforms: macOS, Linux and Windows.
|
||||
|
||||
## Write a commit message
|
||||
|
||||
Commit messages should follow the [Semantic Commit Messages](https://www.conventionalcommits.org/en/v1.0.0/) format:
|
||||
|
||||
```
|
||||
label(namespace): title
|
||||
|
||||
description
|
||||
|
||||
footer
|
||||
```
|
||||
|
||||
1. *label* is one of the following:
|
||||
- `fix` - bug fixes
|
||||
- `feat` - new features
|
||||
- `docs` - documentation-only changes
|
||||
- `test` - test-only changes
|
||||
- `devops` - changes to the CI or build
|
||||
- `chore` - everything that doesn't fall under previous categories
|
||||
2. *namespace* is put in parentheses after label and is optional. Must be lowercase.
|
||||
3. *title* is a brief summary of changes.
|
||||
4. *description* is **optional**, new-line separated from title and is in present tense.
|
||||
5. *footer* is **optional**, new-line separated from *description* and contains "fixes" / "references" attribution to GitHub issues.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
feat(trace viewer): network panel filtering
|
||||
|
||||
This patch adds a filtering toolbar to the network panel.
|
||||
<link to a screenshot>
|
||||
|
||||
Fixes #123, references #234.
|
||||
```
|
||||
|
||||
## Send a pull request
|
||||
|
||||
All submissions, including submissions by project members, require review. We use GitHub pull requests for this purpose.
|
||||
Make sure to keep your PR (diff) small and readable. If necessary, split your contribution into multiple PRs.
|
||||
Consult [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more information on using pull requests.
|
||||
|
||||
After a successful code review, one of the maintainers will merge your pull request. Congratulations!
|
||||
|
||||
## More details
|
||||
|
||||
**No new dependencies**
|
||||
|
||||
There is a very high bar for new dependencies, including updating to a new version of an existing dependency. We recommend to explicitly discuss this in an issue and get a green light from a maintainer, before creating a pull request that updates dependencies.
|
||||
|
||||
## Contributor License Agreement
|
||||
|
||||
This project welcomes contributions and suggestions. Most contributions require you to agree to a
|
||||
Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us
|
||||
the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com.
|
||||
|
||||
When you submit a pull request, a CLA bot will automatically determine whether you need to provide
|
||||
a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions
|
||||
provided by the bot. You will only need to do this once across all repos using our CLA.
|
||||
|
||||
### Code of Conduct
|
||||
|
||||
This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
|
||||
For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or
|
||||
contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.
|
||||
64
Dockerfile
Normal file
64
Dockerfile
Normal file
@@ -0,0 +1,64 @@
|
||||
ARG PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||
|
||||
# ------------------------------
|
||||
# Base
|
||||
# ------------------------------
|
||||
# Base stage: Contains only the minimal dependencies required for runtime
|
||||
# (node_modules and Playwright system dependencies)
|
||||
FROM node:22-bookworm-slim AS base
|
||||
|
||||
ARG PLAYWRIGHT_BROWSERS_PATH
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=${PLAYWRIGHT_BROWSERS_PATH}
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \
|
||||
--mount=type=bind,source=package.json,target=package.json \
|
||||
--mount=type=bind,source=package-lock.json,target=package-lock.json \
|
||||
npm ci --omit=dev && \
|
||||
# Install system dependencies for playwright
|
||||
npx -y playwright-core install-deps chromium
|
||||
|
||||
# ------------------------------
|
||||
# Builder
|
||||
# ------------------------------
|
||||
FROM base AS builder
|
||||
|
||||
RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \
|
||||
--mount=type=bind,source=package.json,target=package.json \
|
||||
--mount=type=bind,source=package-lock.json,target=package-lock.json \
|
||||
npm ci
|
||||
|
||||
# Copy the rest of the app
|
||||
COPY *.json *.js *.ts .
|
||||
|
||||
# ------------------------------
|
||||
# Browser
|
||||
# ------------------------------
|
||||
# Cache optimization:
|
||||
# - Browser is downloaded only when node_modules or Playwright system dependencies change
|
||||
# - Cache is reused when only source code changes
|
||||
FROM base AS browser
|
||||
|
||||
RUN npx -y playwright-core install --no-shell chromium
|
||||
|
||||
# ------------------------------
|
||||
# Runtime
|
||||
# ------------------------------
|
||||
FROM base
|
||||
|
||||
ARG PLAYWRIGHT_BROWSERS_PATH
|
||||
ARG USERNAME=node
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Set the correct ownership for the runtime user on production `node_modules`
|
||||
RUN chown -R ${USERNAME}:${USERNAME} node_modules
|
||||
|
||||
USER ${USERNAME}
|
||||
|
||||
COPY --from=browser --chown=${USERNAME}:${USERNAME} ${PLAYWRIGHT_BROWSERS_PATH} ${PLAYWRIGHT_BROWSERS_PATH}
|
||||
COPY --chown=${USERNAME}:${USERNAME} cli.js package.json ./
|
||||
|
||||
# Run in headless and only with chromium (other browsers need more dependencies not included in this image)
|
||||
ENTRYPOINT ["node", "cli.js", "--headless", "--browser", "chromium", "--no-sandbox"]
|
||||
811
README.md
811
README.md
@@ -4,18 +4,24 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Fast and lightweight**: Uses Playwright's accessibility tree, not pixel-based input.
|
||||
- **LLM-friendly**: No vision models needed, operates purely on structured data.
|
||||
- **Deterministic tool application**: Avoids ambiguity common with screenshot-based approaches.
|
||||
- **Fast and lightweight**. Uses Playwright's accessibility tree, not pixel-based input.
|
||||
- **LLM-friendly**. No vision models needed, operates purely on structured data.
|
||||
- **Deterministic tool application**. Avoids ambiguity common with screenshot-based approaches.
|
||||
|
||||
### Use Cases
|
||||
### Requirements
|
||||
- Node.js 18 or newer
|
||||
- VS Code, Cursor, Windsurf, Claude Desktop, Goose or any other MCP client
|
||||
|
||||
- Web navigation and form-filling
|
||||
- Data extraction from structured content
|
||||
- Automated testing driven by LLMs
|
||||
- General-purpose browser interaction for agents
|
||||
<!--
|
||||
// Generate using:
|
||||
node utils/generate-links.js
|
||||
-->
|
||||
|
||||
### Example config
|
||||
### Getting started
|
||||
|
||||
First, install the Playwright MCP server with your client.
|
||||
|
||||
**Standard config** works in most of the tools:
|
||||
|
||||
```js
|
||||
{
|
||||
@@ -30,68 +36,239 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
|
||||
}
|
||||
```
|
||||
|
||||
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
|
||||
|
||||
#### Installation in VS Code
|
||||
|
||||
Install the Playwright MCP server in VS Code using one of these buttons:
|
||||
<details>
|
||||
<summary>Claude Code</summary>
|
||||
|
||||
<!--
|
||||
// Generate using?:
|
||||
const config = JSON.stringify({ name: 'playwright', command: 'npx', args: ["-y", "@playwright/mcp@latest"] });
|
||||
const urlForWebsites = `vscode:mcp/install?${encodeURIComponent(config)}`;
|
||||
// Github markdown does not allow linking to `vscode:` directly, so you can use our redirect:
|
||||
const urlForGithub = `https://insiders.vscode.dev/redirect?url=${encodeURIComponent(urlForWebsites)}`;
|
||||
-->
|
||||
Use the Claude Code CLI to add the Playwright MCP server:
|
||||
|
||||
[<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
|
||||
```bash
|
||||
claude mcp add playwright npx @playwright/mcp@latest
|
||||
```
|
||||
</details>
|
||||
|
||||
Alternatively, you can install the Playwright MCP server using the VS Code CLI:
|
||||
<details>
|
||||
<summary>Claude Desktop</summary>
|
||||
|
||||
Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user), use the standard config above.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Codex</summary>
|
||||
|
||||
Create or edit the configuration file `~/.codex/config.toml` and add:
|
||||
|
||||
```toml
|
||||
[mcp_servers.playwright]
|
||||
command = "npx"
|
||||
args = ["@playwright/mcp@latest"]
|
||||
```
|
||||
|
||||
For more information, see the [Codex MCP documentation](https://github.com/openai/codex/blob/main/codex-rs/config.md#mcp_servers).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Cursor</summary>
|
||||
|
||||
#### Click the button to install:
|
||||
|
||||
[<img src="https://cursor.com/deeplink/mcp-install-dark.svg" alt="Install in Cursor">](cursor://anysphere.cursor-deeplink/mcp/install?name=Playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
|
||||
|
||||
#### Or install manually:
|
||||
|
||||
Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, use `command` type with the command `npx @playwright/mcp@latest`. You can also verify config or add command like arguments via clicking `Edit`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Gemini CLI</summary>
|
||||
|
||||
Follow the MCP install [guide](https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md#configure-the-mcp-server-in-settingsjson), use the standard config above.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Goose</summary>
|
||||
|
||||
#### Click the button to install:
|
||||
|
||||
[](https://block.github.io/goose/extension?cmd=npx&arg=%40playwright%2Fmcp%40latest&id=playwright&name=Playwright&description=Interact%20with%20web%20pages%20through%20structured%20accessibility%20snapshots%20using%20Playwright)
|
||||
|
||||
#### Or install manually:
|
||||
|
||||
Go to `Advanced settings` -> `Extensions` -> `Add custom extension`. Name to your liking, use type `STDIO`, and set the `command` to `npx @playwright/mcp`. Click "Add Extension".
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>LM Studio</summary>
|
||||
|
||||
#### Click the button to install:
|
||||
|
||||
[](https://lmstudio.ai/install-mcp?name=playwright&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyJAcGxheXdyaWdodC9tY3BAbGF0ZXN0Il19)
|
||||
|
||||
#### Or install manually:
|
||||
|
||||
Go to `Program` in the right sidebar -> `Install` -> `Edit mcp.json`. Use the standard config above.
|
||||
</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>
|
||||
<summary>Qodo Gen</summary>
|
||||
|
||||
Open [Qodo Gen](https://docs.qodo.ai/qodo-documentation/qodo-gen) chat panel in VSCode or IntelliJ → Connect more tools → + Add new MCP → Paste the standard config above.
|
||||
|
||||
Click <code>Save</code>.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>VS Code</summary>
|
||||
|
||||
#### Click the button to install:
|
||||
|
||||
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
|
||||
|
||||
#### Or install manually:
|
||||
|
||||
Follow the MCP install [guide](https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_add-an-mcp-server), use the standard config above. You can also install the Playwright MCP server using the VS Code CLI:
|
||||
|
||||
```bash
|
||||
# For VS Code
|
||||
code --add-mcp '{"name":"playwright","command":"npx","args":["@playwright/mcp@latest"]}'
|
||||
```
|
||||
|
||||
```bash
|
||||
# For VS Code Insiders
|
||||
code-insiders --add-mcp '{"name":"playwright","command":"npx","args":["@playwright/mcp@latest"]}'
|
||||
```
|
||||
|
||||
After installation, the Playwright MCP server will be available for use with your GitHub Copilot agent in VS Code.
|
||||
</details>
|
||||
|
||||
### CLI Options
|
||||
<details>
|
||||
<summary>Windsurf</summary>
|
||||
|
||||
The Playwright MCP server supports the following command-line options:
|
||||
Follow Windsurf MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp). Use the standard config above.
|
||||
|
||||
- `--browser <browser>`: Browser or chrome channel to use. Possible values:
|
||||
- `chrome`, `firefox`, `webkit`, `msedge`
|
||||
- Chrome channels: `chrome-beta`, `chrome-canary`, `chrome-dev`
|
||||
- Edge channels: `msedge-beta`, `msedge-canary`, `msedge-dev`
|
||||
- Default: `chrome`
|
||||
- `--caps <caps>`: Comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.
|
||||
- `--cdp-endpoint <endpoint>`: CDP endpoint to connect to
|
||||
- `--executable-path <path>`: Path to the browser executable
|
||||
- `--headless`: Run browser in headless mode (headed by default)
|
||||
- `--port <port>`: Port to listen on for SSE transport
|
||||
- `--user-data-dir <path>`: Path to the user data directory
|
||||
- `--vision`: Run server that uses screenshots (Aria snapshots are used by default)
|
||||
</details>
|
||||
|
||||
### User data directory
|
||||
### Configuration
|
||||
|
||||
Playwright MCP will launch the browser with the new profile, located at
|
||||
Playwright MCP server supports following arguments. They can be provided in the JSON configuration above, as a part of the `"args"` list:
|
||||
|
||||
<!--- Options generated by update-readme.js -->
|
||||
|
||||
```
|
||||
- `%USERPROFILE%\AppData\Local\ms-playwright\mcp-chrome-profile` on Windows
|
||||
- `~/Library/Caches/ms-playwright/mcp-chrome-profile` on macOS
|
||||
- `~/.cache/ms-playwright/mcp-chrome-profile` on Linux
|
||||
> npx @playwright/mcp@latest --help
|
||||
--allowed-origins <origins> semicolon-separated list of origins to allow
|
||||
the browser to request. Default is to allow
|
||||
all.
|
||||
--blocked-origins <origins> semicolon-separated list of origins to block
|
||||
the browser from requesting. Blocklist is
|
||||
evaluated before allowlist. If used without
|
||||
the allowlist, requests not matching the
|
||||
blocklist are still allowed.
|
||||
--block-service-workers block service workers
|
||||
--browser <browser> browser or chrome channel to use, possible
|
||||
values: chrome, firefox, webkit, msedge.
|
||||
--caps <caps> comma-separated list of additional
|
||||
capabilities to enable, possible values:
|
||||
vision, pdf.
|
||||
--cdp-endpoint <endpoint> CDP endpoint to connect to.
|
||||
--cdp-header <headers...> CDP headers to send with the connect request,
|
||||
multiple can be specified.
|
||||
--config <path> path to the configuration file.
|
||||
--device <device> device to emulate, for example: "iPhone 15"
|
||||
--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
|
||||
--host <host> host to bind server to. Default is localhost.
|
||||
Use 0.0.0.0 to bind to all interfaces.
|
||||
--ignore-https-errors ignore https errors
|
||||
--isolated keep the browser profile in memory, do not
|
||||
save it to disk.
|
||||
--image-responses <mode> whether to send image responses to the client.
|
||||
Can be "allow" or "omit", Defaults to "allow".
|
||||
--no-sandbox disable the sandbox for all process types that
|
||||
are normally sandboxed.
|
||||
--output-dir <path> path to the directory for output files.
|
||||
--port <port> port to listen on for SSE transport.
|
||||
--proxy-bypass <bypass> comma-separated domains to bypass proxy, for
|
||||
example ".com,chromium.org,.domain.com"
|
||||
--proxy-server <proxy> specify proxy server, for example
|
||||
"http://myproxy:3128" or
|
||||
"socks5://myproxy:8080"
|
||||
--save-session Whether to save the Playwright MCP session
|
||||
into the output directory.
|
||||
--save-trace Whether to save the Playwright Trace of the
|
||||
session into the output directory.
|
||||
--secrets <path> path to a file containing secrets in the
|
||||
dotenv format
|
||||
--storage-state <path> path to the storage state file for isolated
|
||||
sessions.
|
||||
--timeout-action <timeout> specify action timeout in milliseconds,
|
||||
defaults to 5000ms
|
||||
--timeout-navigation <timeout> specify navigation timeout in milliseconds,
|
||||
defaults to 60000ms
|
||||
--user-agent <ua string> specify user agent string
|
||||
--user-data-dir <path> path to the user data directory. If not
|
||||
specified, a temporary directory will be
|
||||
created.
|
||||
--viewport-size <size> specify browser viewport size in pixels, for
|
||||
example "1280, 720"
|
||||
```
|
||||
|
||||
All the logged in information will be stored in that profile, you can delete it between sessions if you'd like to clear the offline state.
|
||||
<!--- End of options generated section -->
|
||||
|
||||
### User profile
|
||||
|
||||
### Running headless browser (Browser without GUI).
|
||||
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.
|
||||
|
||||
This mode is useful for background or batch operations.
|
||||
**Persistent profile**
|
||||
|
||||
All the logged in information will be stored in the persistent profile, you can delete it between sessions if you'd like to clear the offline state.
|
||||
Persistent profile is located at the following locations and you can override it with the `--user-data-dir` argument.
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
%USERPROFILE%\AppData\Local\ms-playwright\mcp-{channel}-profile
|
||||
|
||||
# macOS
|
||||
- ~/Library/Caches/ms-playwright/mcp-{channel}-profile
|
||||
|
||||
# Linux
|
||||
- ~/.cache/ms-playwright/mcp-{channel}-profile
|
||||
```
|
||||
|
||||
**Isolated**
|
||||
|
||||
In the isolated mode, each session is started in the isolated profile. Every time you ask MCP to close the browser,
|
||||
the session is closed and all the storage state for this session is lost. You can provide initial storage state
|
||||
to the browser via the config's `contextOptions` or via the `--storage-state` argument. Learn more about the storage
|
||||
state [here](https://playwright.dev/docs/auth).
|
||||
|
||||
```js
|
||||
{
|
||||
@@ -100,97 +277,344 @@ This mode is useful for background or batch operations.
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest",
|
||||
"--headless"
|
||||
"--isolated",
|
||||
"--storage-state={path/to/storage.json}"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Running headed browser on Linux w/o DISPLAY
|
||||
**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
|
||||
|
||||
The Playwright MCP server can be configured using a JSON configuration file. You can specify the configuration file
|
||||
using the `--config` command line option:
|
||||
|
||||
```bash
|
||||
npx @playwright/mcp@latest --config path/to/config.json
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Configuration file schema</summary>
|
||||
|
||||
```typescript
|
||||
{
|
||||
// Browser configuration
|
||||
browser?: {
|
||||
// Browser type to use (chromium, firefox, or webkit)
|
||||
browserName?: 'chromium' | 'firefox' | 'webkit';
|
||||
|
||||
// Keep the browser profile in memory, do not save it to disk.
|
||||
isolated?: boolean;
|
||||
|
||||
// Path to user data directory for browser profile persistence
|
||||
userDataDir?: string;
|
||||
|
||||
// Browser launch options (see Playwright docs)
|
||||
// @see https://playwright.dev/docs/api/class-browsertype#browser-type-launch
|
||||
launchOptions?: {
|
||||
channel?: string; // Browser channel (e.g. 'chrome')
|
||||
headless?: boolean; // Run in headless mode
|
||||
executablePath?: string; // Path to browser executable
|
||||
// ... other Playwright launch options
|
||||
};
|
||||
|
||||
// Browser context options
|
||||
// @see https://playwright.dev/docs/api/class-browser#browser-new-context
|
||||
contextOptions?: {
|
||||
viewport?: { width: number, height: number };
|
||||
// ... other Playwright context options
|
||||
};
|
||||
|
||||
// CDP endpoint for connecting to existing browser
|
||||
cdpEndpoint?: string;
|
||||
|
||||
// Remote Playwright server endpoint
|
||||
remoteEndpoint?: string;
|
||||
},
|
||||
|
||||
// Server configuration
|
||||
server?: {
|
||||
port?: number; // Port to listen on
|
||||
host?: string; // Host to bind to (default: localhost)
|
||||
},
|
||||
|
||||
// List of additional capabilities
|
||||
capabilities?: Array<
|
||||
'tabs' | // Tab management
|
||||
'install' | // Browser installation
|
||||
'pdf' | // PDF generation
|
||||
'vision' | // Coordinate-based interactions
|
||||
>;
|
||||
|
||||
// Directory for output files
|
||||
outputDir?: string;
|
||||
|
||||
// Network configuration
|
||||
network?: {
|
||||
// List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
|
||||
allowedOrigins?: string[];
|
||||
|
||||
// List of origins to block the browser to request. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
|
||||
blockedOrigins?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether to send image responses to the client. Can be "allow" or "omit".
|
||||
* Defaults to "allow".
|
||||
*/
|
||||
imageResponses?: 'allow' | 'omit';
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
### Standalone MCP server
|
||||
|
||||
When running headed browser on system w/o display or from worker processes of the IDEs,
|
||||
run the MCP server from environment with the DISPLAY and pass the `--port` flag to enable SSE transport.
|
||||
run the MCP server from environment with the DISPLAY and pass the `--port` flag to enable HTTP transport.
|
||||
|
||||
```bash
|
||||
npx @playwright/mcp@latest --port 8931
|
||||
```
|
||||
|
||||
And then in MCP client config, set the `url` to the SSE endpoint:
|
||||
And then in MCP client config, set the `url` to the HTTP endpoint:
|
||||
|
||||
```js
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"url": "http://localhost:8931/sse"
|
||||
"url": "http://localhost:8931/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tool Modes
|
||||
<details>
|
||||
<summary><b>Docker</b></summary>
|
||||
|
||||
The tools are available in two modes:
|
||||
|
||||
1. **Snapshot Mode** (default): Uses accessibility snapshots for better performance and reliability
|
||||
2. **Vision Mode**: Uses screenshots for visual-based interactions
|
||||
|
||||
To use Vision Mode, add the `--vision` flag when starting the server:
|
||||
**NOTE:** The Docker implementation only supports headless chromium at the moment.
|
||||
|
||||
```js
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest",
|
||||
"--vision"
|
||||
]
|
||||
"command": "docker",
|
||||
"args": ["run", "-i", "--rm", "--init", "--pull=always", "mcr.microsoft.com/playwright/mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Vision Mode works best with the computer use models that are able to interact with elements using
|
||||
X Y coordinate space, based on the provided screenshot.
|
||||
You can build the Docker image yourself.
|
||||
|
||||
### Programmatic usage with custom transports
|
||||
```
|
||||
docker build -t mcr.microsoft.com/playwright/mcp .
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Programmatic usage</b></summary>
|
||||
|
||||
```js
|
||||
import { createServer } from '@playwright/mcp';
|
||||
import http from 'http';
|
||||
|
||||
// ...
|
||||
import { createConnection } from '@playwright/mcp';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
|
||||
const server = createServer({
|
||||
launchOptions: { headless: true }
|
||||
http.createServer(async (req, res) => {
|
||||
// ...
|
||||
|
||||
// Creates a headless Playwright MCP server with SSE transport
|
||||
const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
|
||||
const transport = new SSEServerTransport('/messages', res);
|
||||
await connection.sever.connect(transport);
|
||||
|
||||
// ...
|
||||
});
|
||||
transport = new SSEServerTransport("/messages", res);
|
||||
server.connect(transport);
|
||||
```
|
||||
</details>
|
||||
|
||||
### Snapshot-based Interactions
|
||||
### Tools
|
||||
|
||||
<!--- Tools generated by update-readme.js -->
|
||||
|
||||
<details>
|
||||
<summary><b>Core automation</b></summary>
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_click**
|
||||
- Title: Click
|
||||
- Description: Perform click on a web page
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
- `ref` (string): Exact target element reference from the page snapshot
|
||||
- `doubleClick` (boolean, optional): Whether to perform a double click instead of a single click
|
||||
- `button` (string, optional): Button to click, defaults to left
|
||||
- `modifiers` (array, optional): Modifier keys to press
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_close**
|
||||
- Title: Close browser
|
||||
- Description: Close the page
|
||||
- Parameters: None
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_console_messages**
|
||||
- Title: Get console messages
|
||||
- Description: Returns all console messages
|
||||
- Parameters: None
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_drag**
|
||||
- Title: Drag mouse
|
||||
- Description: Perform drag and drop between two elements
|
||||
- Parameters:
|
||||
- `startElement` (string): Human-readable source element description used to obtain the permission to interact with the element
|
||||
- `startRef` (string): Exact source element reference from the page snapshot
|
||||
- `endElement` (string): Human-readable target element description used to obtain the permission to interact with the element
|
||||
- `endRef` (string): Exact target element reference from the page snapshot
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_evaluate**
|
||||
- Title: Evaluate JavaScript
|
||||
- Description: Evaluate JavaScript expression on page or element
|
||||
- Parameters:
|
||||
- `function` (string): () => { /* code */ } or (element) => { /* code */ } when element is provided
|
||||
- `element` (string, optional): Human-readable element description used to obtain permission to interact with the element
|
||||
- `ref` (string, optional): Exact target element reference from the page snapshot
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_file_upload**
|
||||
- Title: Upload files
|
||||
- Description: Upload one or multiple files
|
||||
- Parameters:
|
||||
- `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files.
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_fill_form**
|
||||
- Title: Fill form
|
||||
- Description: Fill multiple form fields
|
||||
- Parameters:
|
||||
- `fields` (array): Fields to fill in
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_handle_dialog**
|
||||
- Title: Handle a dialog
|
||||
- Description: Handle a dialog
|
||||
- Parameters:
|
||||
- `accept` (boolean): Whether to accept the dialog.
|
||||
- `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_hover**
|
||||
- Title: Hover mouse
|
||||
- Description: Hover over element on page
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
- `ref` (string): Exact target element reference from the page snapshot
|
||||
- Read-only: **true**
|
||||
|
||||
- **browser_drag**
|
||||
- Description: Perform drag and drop between two elements
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_navigate**
|
||||
- Title: Navigate to a URL
|
||||
- Description: Navigate to a URL
|
||||
- Parameters:
|
||||
- `startElement` (string): Human-readable source element description used to obtain permission to interact with the element
|
||||
- `startRef` (string): Exact source element reference from the page snapshot
|
||||
- `endElement` (string): Human-readable target element description used to obtain permission to interact with the element
|
||||
- `endRef` (string): Exact target element reference from the page snapshot
|
||||
- `url` (string): The URL to navigate to
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_navigate_back**
|
||||
- Title: Go back
|
||||
- Description: Go back to the previous page
|
||||
- Parameters: None
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_network_requests**
|
||||
- Title: List network requests
|
||||
- Description: Returns all network requests since loading the page
|
||||
- Parameters: None
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_press_key**
|
||||
- Title: Press a key
|
||||
- Description: Press a key on the keyboard
|
||||
- Parameters:
|
||||
- `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_resize**
|
||||
- Title: Resize browser window
|
||||
- Description: Resize the browser window
|
||||
- Parameters:
|
||||
- `width` (number): Width of the browser window
|
||||
- `height` (number): Height of the browser window
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_select_option**
|
||||
- Title: Select option
|
||||
- Description: Select an option in a dropdown
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
- `ref` (string): Exact target element reference from the page snapshot
|
||||
- `values` (array): Array of values to select in the dropdown. This can be a single value or multiple values.
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_snapshot**
|
||||
- Title: Page snapshot
|
||||
- Description: Capture accessibility snapshot of the current page, this is better than screenshot
|
||||
- Parameters: None
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_take_screenshot**
|
||||
- Title: Take a screenshot
|
||||
- Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.
|
||||
- Parameters:
|
||||
- `type` (string, optional): Image format for the screenshot. Default is png.
|
||||
- `filename` (string, optional): File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.
|
||||
- `element` (string, optional): Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.
|
||||
- `ref` (string, optional): Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.
|
||||
- `fullPage` (boolean, optional): When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Cannot be used with element screenshots.
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_type**
|
||||
- Title: Type text
|
||||
- Description: Type text into editable element
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
@@ -198,131 +622,170 @@ server.connect(transport);
|
||||
- `text` (string): Text to type into the element
|
||||
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
|
||||
- `slowly` (boolean, optional): Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.
|
||||
- Read-only: **false**
|
||||
|
||||
- **browser_select_option**
|
||||
- Description: Select an option in a dropdown
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_wait_for**
|
||||
- Title: Wait for
|
||||
- Description: Wait for text to appear or disappear or a specified time to pass
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
- `ref` (string): Exact target element reference from the page snapshot
|
||||
- `values` (array): Array of values to select in the dropdown. This can be a single value or multiple values.
|
||||
- `time` (number, optional): The time to wait in seconds
|
||||
- `text` (string, optional): The text to wait for
|
||||
- `textGone` (string, optional): The text to wait for to disappear
|
||||
- Read-only: **true**
|
||||
|
||||
- **browser_snapshot**
|
||||
- Description: Capture accessibility snapshot of the current page, this is better than screenshot
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Tab management</b></summary>
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_tabs**
|
||||
- Title: Manage tabs
|
||||
- Description: List, create, close, or select a browser tab.
|
||||
- Parameters:
|
||||
- `action` (string): Operation to perform
|
||||
- `index` (number, optional): Tab index, used for close/select. If omitted for close, current tab is closed.
|
||||
- Read-only: **false**
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Browser installation</b></summary>
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_install**
|
||||
- Title: Install the browser specified in the config
|
||||
- Description: Install the browser specified in the config. Call this if you get an error about the browser not being installed.
|
||||
- Parameters: None
|
||||
- Read-only: **false**
|
||||
|
||||
- **browser_take_screenshot**
|
||||
- Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.
|
||||
- Parameters:
|
||||
- `raw` (boolean, optional): Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.
|
||||
</details>
|
||||
|
||||
### Vision-based Interactions
|
||||
<details>
|
||||
<summary><b>Coordinate-based (opt-in via --caps=vision)</b></summary>
|
||||
|
||||
- **browser_screen_move_mouse**
|
||||
- Description: Move mouse to a given position
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_mouse_click_xy**
|
||||
- Title: Click
|
||||
- Description: Click left mouse button at a given position
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
- `x` (number): X coordinate
|
||||
- `y` (number): Y coordinate
|
||||
- Read-only: **false**
|
||||
|
||||
- **browser_screen_capture**
|
||||
- Description: Take a screenshot of the current page
|
||||
- Parameters: None
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_screen_click**
|
||||
- Description: Click left mouse button
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
- `x` (number): X coordinate
|
||||
- `y` (number): Y coordinate
|
||||
|
||||
- **browser_screen_drag**
|
||||
- Description: Drag left mouse button
|
||||
- **browser_mouse_drag_xy**
|
||||
- Title: Drag mouse
|
||||
- Description: Drag left mouse button to a given position
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
- `startX` (number): Start X coordinate
|
||||
- `startY` (number): Start Y coordinate
|
||||
- `endX` (number): End X coordinate
|
||||
- `endY` (number): End Y coordinate
|
||||
- Read-only: **false**
|
||||
|
||||
- **browser_screen_type**
|
||||
- Description: Type text
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_mouse_move_xy**
|
||||
- Title: Move mouse
|
||||
- Description: Move mouse to a given position
|
||||
- Parameters:
|
||||
- `text` (string): Text to type
|
||||
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
- `x` (number): X coordinate
|
||||
- `y` (number): Y coordinate
|
||||
- Read-only: **true**
|
||||
|
||||
- **browser_press_key**
|
||||
- Description: Press a key on the keyboard
|
||||
- Parameters:
|
||||
- `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`
|
||||
</details>
|
||||
|
||||
### Tab Management
|
||||
<details>
|
||||
<summary><b>PDF generation (opt-in via --caps=pdf)</b></summary>
|
||||
|
||||
- **browser_tab_list**
|
||||
- Description: List browser tabs
|
||||
- Parameters: None
|
||||
|
||||
- **browser_tab_new**
|
||||
- Description: Open a new tab
|
||||
- Parameters:
|
||||
- `url` (string, optional): The URL to navigate to in the new tab. If not provided, the new tab will be blank.
|
||||
|
||||
- **browser_tab_select**
|
||||
- Description: Select a tab by index
|
||||
- Parameters:
|
||||
- `index` (number): The index of the tab to select
|
||||
|
||||
- **browser_tab_close**
|
||||
- Description: Close a tab
|
||||
- Parameters:
|
||||
- `index` (number, optional): The index of the tab to close. Closes current tab if not provided.
|
||||
|
||||
### Navigation
|
||||
|
||||
- **browser_navigate**
|
||||
- Description: Navigate to a URL
|
||||
- Parameters:
|
||||
- `url` (string): The URL to navigate to
|
||||
|
||||
- **browser_navigate_back**
|
||||
- Description: Go back to the previous page
|
||||
- Parameters: None
|
||||
|
||||
- **browser_navigate_forward**
|
||||
- Description: Go forward to the next page
|
||||
- Parameters: None
|
||||
|
||||
### Keyboard
|
||||
|
||||
- **browser_press_key**
|
||||
- Description: Press a key on the keyboard
|
||||
- Parameters:
|
||||
- `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`
|
||||
|
||||
### Files and Media
|
||||
|
||||
- **browser_file_upload**
|
||||
- Description: Choose one or multiple files to upload
|
||||
- Parameters:
|
||||
- `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files.
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_pdf_save**
|
||||
- Title: Save as PDF
|
||||
- Description: Save page as PDF
|
||||
- Parameters: None
|
||||
|
||||
### Utilities
|
||||
|
||||
- **browser_wait**
|
||||
- Description: Wait for a specified time in seconds
|
||||
- Parameters:
|
||||
- `time` (number): The time to wait in seconds (capped at 10 seconds)
|
||||
- `filename` (string, optional): File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.
|
||||
- Read-only: **true**
|
||||
|
||||
- **browser_close**
|
||||
- Description: Close the page
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Verify (opt-in via --caps=verify)</b></summary>
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_verify_element_visible**
|
||||
- Title: Verify element visible
|
||||
- Description: Verify element is visible on the page
|
||||
- Parameters:
|
||||
- `role` (string): ROLE of the element. Can be found in the snapshot like this: `- {ROLE} "Accessible Name":`
|
||||
- `accessibleName` (string): ACCESSIBLE_NAME of the element. Can be found in the snapshot like this: `- role "{ACCESSIBLE_NAME}"`
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_verify_list_visible**
|
||||
- Title: Verify list visible
|
||||
- Description: Verify list is visible on the page
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable list description
|
||||
- `ref` (string): Exact target element reference that points to the list
|
||||
- `items` (array): Items to verify
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_verify_text_visible**
|
||||
- Title: Verify text visible
|
||||
- Description: Verify text is visible on the page. Prefer browser_verify_element_visible if possible.
|
||||
- Parameters:
|
||||
- `text` (string): TEXT to verify. Can be found in the snapshot like this: `- role "Accessible Name": {TEXT}` or like this: `- text: {TEXT}`
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_verify_value**
|
||||
- Title: Verify value
|
||||
- Description: Verify element value
|
||||
- Parameters:
|
||||
- `type` (string): Type of the element
|
||||
- `element` (string): Human-readable element description
|
||||
- `ref` (string): Exact target element reference that points to the element
|
||||
- `value` (string): Value to verify. For checkbox, use "true" or "false".
|
||||
- Read-only: **true**
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Tracing (opt-in via --caps=tracing)</b></summary>
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_start_tracing**
|
||||
- Title: Start tracing
|
||||
- Description: Start trace recording
|
||||
- Parameters: None
|
||||
- Read-only: **true**
|
||||
|
||||
- **browser_install**
|
||||
- Description: Install the browser specified in the config. Call this if you get an error about the browser not being installed.
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_stop_tracing**
|
||||
- Title: Stop tracing
|
||||
- Description: Stop trace recording
|
||||
- Parameters: None
|
||||
- Read-only: **true**
|
||||
|
||||
### Vision Mode
|
||||
</details>
|
||||
|
||||
|
||||
<!--- End of tools generated section -->
|
||||
|
||||
8
cli.js
8
cli.js
@@ -15,4 +15,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
require('./lib/program');
|
||||
const { program } = require('playwright-core/lib/utilsBundle');
|
||||
const { decorateCommand } = require('playwright/lib/mcp/program');
|
||||
|
||||
const packageJSON = require('./package.json');
|
||||
const p = program.version('Version ' + packageJSON.version).name('Playwright MCP');
|
||||
decorateCommand(p, packageJSON.version)
|
||||
void program.parseAsync(process.argv);
|
||||
|
||||
143
config.d.ts
vendored
Normal file
143
config.d.ts
vendored
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type * as playwright from 'playwright';
|
||||
|
||||
export type ToolCapability = 'core' | 'core-tabs' | 'core-install' | 'vision' | 'pdf' | 'verify';
|
||||
|
||||
export type Config = {
|
||||
/**
|
||||
* The browser to use.
|
||||
*/
|
||||
browser?: {
|
||||
/**
|
||||
* The type of browser to use.
|
||||
*/
|
||||
browserName?: 'chromium' | 'firefox' | 'webkit';
|
||||
|
||||
/**
|
||||
* Keep the browser profile in memory, do not save it to disk.
|
||||
*/
|
||||
isolated?: boolean;
|
||||
|
||||
/**
|
||||
* Path to a user data directory for browser profile persistence.
|
||||
* Temporary directory is created by default.
|
||||
*/
|
||||
userDataDir?: string;
|
||||
|
||||
/**
|
||||
* Launch options passed to
|
||||
* @see https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context
|
||||
*
|
||||
* This is useful for settings options like `channel`, `headless`, `executablePath`, etc.
|
||||
*/
|
||||
launchOptions?: playwright.LaunchOptions;
|
||||
|
||||
/**
|
||||
* Context options for the browser context.
|
||||
*
|
||||
* This is useful for settings options like `viewport`.
|
||||
*/
|
||||
contextOptions?: playwright.BrowserContextOptions;
|
||||
|
||||
/**
|
||||
* Chrome DevTools Protocol endpoint to connect to an existing browser instance in case of Chromium family browsers.
|
||||
*/
|
||||
cdpEndpoint?: string;
|
||||
|
||||
/**
|
||||
* CDP headers to send with the connect request.
|
||||
*/
|
||||
cdpHeaders?: Record<string, string>;
|
||||
|
||||
/**
|
||||
* Remote endpoint to connect to an existing Playwright server.
|
||||
*/
|
||||
remoteEndpoint?: string;
|
||||
},
|
||||
|
||||
server?: {
|
||||
/**
|
||||
* The port to listen on for SSE or MCP transport.
|
||||
*/
|
||||
port?: number;
|
||||
|
||||
/**
|
||||
* The host to bind the server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.
|
||||
*/
|
||||
host?: string;
|
||||
},
|
||||
|
||||
/**
|
||||
* List of enabled tool capabilities. Possible values:
|
||||
* - 'core': Core browser automation features.
|
||||
* - 'pdf': PDF generation and manipulation.
|
||||
* - 'vision': Coordinate-based interactions.
|
||||
*/
|
||||
capabilities?: ToolCapability[];
|
||||
|
||||
/**
|
||||
* Whether to save the Playwright session into the output directory.
|
||||
*/
|
||||
saveSession?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to save the Playwright trace of the session into the output directory.
|
||||
*/
|
||||
saveTrace?: boolean;
|
||||
|
||||
/**
|
||||
* Secrets are used to prevent LLM from getting sensitive data while
|
||||
* automating scenarios such as authentication.
|
||||
* Prefer the browser.contextOptions.storageState over secrets file as a more secure alternative.
|
||||
*/
|
||||
secrets?: Record<string, string>;
|
||||
|
||||
/**
|
||||
* The directory to save output files.
|
||||
*/
|
||||
outputDir?: string;
|
||||
|
||||
network?: {
|
||||
/**
|
||||
* List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
|
||||
*/
|
||||
allowedOrigins?: string[];
|
||||
|
||||
/**
|
||||
* List of origins to block the browser to request. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
|
||||
*/
|
||||
blockedOrigins?: string[];
|
||||
};
|
||||
|
||||
timeouts?: {
|
||||
/*
|
||||
* Configures default action timeout: https://playwright.dev/docs/api/class-page#page-set-default-timeout. Defaults to 5000ms.
|
||||
*/
|
||||
action?: number;
|
||||
|
||||
/*
|
||||
* Configures default navigation timeout: https://playwright.dev/docs/api/class-page#page-set-default-navigation-timeout. Defaults to 60000ms.
|
||||
*/
|
||||
navigation?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether to send image responses to the client. Can be "allow", "omit", or "auto". Defaults to "auto", which sends images if the client can display them.
|
||||
*/
|
||||
imageResponses?: 'allow' | 'omit';
|
||||
};
|
||||
@@ -1,199 +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 typescriptEslint from "@typescript-eslint/eslint-plugin";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import notice from "eslint-plugin-notice";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import stylistic from "@stylistic/eslint-plugin";
|
||||
import importRules from "eslint-plugin-import";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const plugins = {
|
||||
"@stylistic": stylistic,
|
||||
"@typescript-eslint": typescriptEslint,
|
||||
notice,
|
||||
import: importRules,
|
||||
};
|
||||
|
||||
export const baseRules = {
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
2,
|
||||
{ args: "none", caughtErrors: "none" },
|
||||
],
|
||||
|
||||
/**
|
||||
* Enforced rules
|
||||
*/
|
||||
// syntax preferences
|
||||
"object-curly-spacing": ["error", "always"],
|
||||
quotes: [
|
||||
2,
|
||||
"single",
|
||||
{
|
||||
avoidEscape: true,
|
||||
allowTemplateLiterals: true,
|
||||
},
|
||||
],
|
||||
"jsx-quotes": [2, "prefer-single"],
|
||||
"no-extra-semi": 2,
|
||||
"@stylistic/semi": [2],
|
||||
"comma-style": [2, "last"],
|
||||
"wrap-iife": [2, "inside"],
|
||||
"spaced-comment": [
|
||||
2,
|
||||
"always",
|
||||
{
|
||||
markers: ["*"],
|
||||
},
|
||||
],
|
||||
eqeqeq: [2],
|
||||
"accessor-pairs": [
|
||||
2,
|
||||
{
|
||||
getWithoutSet: false,
|
||||
setWithoutGet: false,
|
||||
},
|
||||
],
|
||||
"brace-style": [2, "1tbs", { allowSingleLine: true }],
|
||||
curly: [2, "multi-or-nest", "consistent"],
|
||||
"new-parens": 2,
|
||||
"arrow-parens": [2, "as-needed"],
|
||||
"prefer-const": 2,
|
||||
"quote-props": [2, "consistent"],
|
||||
"nonblock-statement-body-position": [2, "below"],
|
||||
|
||||
// anti-patterns
|
||||
"no-var": 2,
|
||||
"no-with": 2,
|
||||
"no-multi-str": 2,
|
||||
"no-caller": 2,
|
||||
"no-implied-eval": 2,
|
||||
"no-labels": 2,
|
||||
"no-new-object": 2,
|
||||
"no-octal-escape": 2,
|
||||
"no-self-compare": 2,
|
||||
"no-shadow-restricted-names": 2,
|
||||
"no-cond-assign": 2,
|
||||
"no-debugger": 2,
|
||||
"no-dupe-keys": 2,
|
||||
"no-duplicate-case": 2,
|
||||
"no-empty-character-class": 2,
|
||||
"no-unreachable": 2,
|
||||
"no-unsafe-negation": 2,
|
||||
radix: 2,
|
||||
"valid-typeof": 2,
|
||||
"no-implicit-globals": [2],
|
||||
"no-unused-expressions": [
|
||||
2,
|
||||
{ allowShortCircuit: true, allowTernary: true, allowTaggedTemplates: true },
|
||||
],
|
||||
"no-proto": 2,
|
||||
|
||||
// es2015 features
|
||||
"require-yield": 2,
|
||||
"template-curly-spacing": [2, "never"],
|
||||
|
||||
// spacing details
|
||||
"space-infix-ops": 2,
|
||||
"space-in-parens": [2, "never"],
|
||||
"array-bracket-spacing": [2, "never"],
|
||||
"comma-spacing": [2, { before: false, after: true }],
|
||||
"keyword-spacing": [2, "always"],
|
||||
"space-before-function-paren": [
|
||||
2,
|
||||
{
|
||||
anonymous: "never",
|
||||
named: "never",
|
||||
asyncArrow: "always",
|
||||
},
|
||||
],
|
||||
"no-whitespace-before-property": 2,
|
||||
"keyword-spacing": [
|
||||
2,
|
||||
{
|
||||
overrides: {
|
||||
if: { after: true },
|
||||
else: { after: true },
|
||||
for: { after: true },
|
||||
while: { after: true },
|
||||
do: { after: true },
|
||||
switch: { after: true },
|
||||
return: { after: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
"arrow-spacing": [
|
||||
2,
|
||||
{
|
||||
after: true,
|
||||
before: true,
|
||||
},
|
||||
],
|
||||
"@stylistic/func-call-spacing": 2,
|
||||
"@stylistic/type-annotation-spacing": 2,
|
||||
|
||||
// file whitespace
|
||||
"no-multiple-empty-lines": [2, { max: 2, maxEOF: 0 }],
|
||||
"no-mixed-spaces-and-tabs": 2,
|
||||
"no-trailing-spaces": 2,
|
||||
"linebreak-style": [process.platform === "win32" ? 0 : 2, "unix"],
|
||||
indent: [
|
||||
2,
|
||||
2,
|
||||
{ SwitchCase: 1, CallExpression: { arguments: 2 }, MemberExpression: 2 },
|
||||
],
|
||||
"key-spacing": [
|
||||
2,
|
||||
{
|
||||
beforeColon: false,
|
||||
},
|
||||
],
|
||||
"eol-last": 2,
|
||||
|
||||
// copyright
|
||||
"notice/notice": [
|
||||
2,
|
||||
{
|
||||
mustMatch: "Copyright",
|
||||
templateFile: path.join(__dirname, "utils", "copyright.js"),
|
||||
},
|
||||
],
|
||||
|
||||
// react
|
||||
"react/react-in-jsx-scope": 0,
|
||||
};
|
||||
|
||||
const languageOptions = {
|
||||
parser: tsParser,
|
||||
ecmaVersion: 9,
|
||||
sourceType: "module",
|
||||
};
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ["**/*.js"],
|
||||
},
|
||||
{
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
plugins,
|
||||
languageOptions,
|
||||
rules: baseRules,
|
||||
},
|
||||
];
|
||||
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.
|
||||
|
||||
|
||||
BIN
extension/icons/icon-128.png
Normal file
BIN
extension/icons/icon-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
BIN
extension/icons/icon-16.png
Normal file
BIN
extension/icons/icon-16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 571 B |
BIN
extension/icons/icon-32.png
Normal file
BIN
extension/icons/icon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
extension/icons/icon-48.png
Normal file
BIN
extension/icons/icon-48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
35
extension/manifest.json
Normal file
35
extension/manifest.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Playwright MCP Bridge",
|
||||
"version": "0.0.37",
|
||||
"description": "Share browser tabs with Playwright MCP server",
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9nMS2b0WCohjVHPGb8D9qAdkbIngDqoAjTeSccHJijgcONejge+OJxOQOMLu7b0ovt1c9BiEJa5JcpM+EHFVGL1vluBxK71zmBy1m2f9vZF3HG0LSCp7YRkum9rAIEthDwbkxx6XTvpmAY5rjFa/NON6b9Hlbo+8peUSkoOK7HTwYnnI36asZ9eUTiveIf+DMPLojW2UX33vDWG2UKvMVDewzclb4+uLxAYshY7Mx8we/b44xu+Anb/EBLKjOPk9Yh541xJ5Ozc8EiP/5yxOp9c/lRiYUHaRW+4r0HKZyFt0eZ52ti2iM4Nfk7jRXR7an3JPsUIf5deC/1cVM/+1ZQIDAQAB",
|
||||
"permissions": [
|
||||
"debugger",
|
||||
"activeTab",
|
||||
"tabs",
|
||||
"storage"
|
||||
],
|
||||
"host_permissions": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "lib/background.mjs",
|
||||
"type": "module"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "Playwright MCP Bridge",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
}
|
||||
1884
extension/package-lock.json
generated
Normal file
1884
extension/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
extension/package.json
Normal file
35
extension/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@playwright/mcp-extension",
|
||||
"version": "0.0.37",
|
||||
"description": "Playwright MCP Browser Extension",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/microsoft/playwright-mcp.git"
|
||||
},
|
||||
"homepage": "https://playwright.dev",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"author": {
|
||||
"name": "Microsoft Corporation"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"build": "tsc --project . && tsc --project tsconfig.ui.json && vite build && vite build --config vite.sw.config.mts",
|
||||
"watch": "tsc --watch --project . & tsc --watch --project tsconfig.ui.json & vite build --watch & vite build --watch --config vite.sw.config.mts",
|
||||
"test": "playwright test",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.0.315",
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-static-copy": "^3.1.1"
|
||||
}
|
||||
}
|
||||
@@ -14,23 +14,18 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Context } from '../context';
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export type ResourceSchema = {
|
||||
uri: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
mimeType?: string;
|
||||
};
|
||||
import type { TestOptions } from '../tests/fixtures';
|
||||
|
||||
export type ResourceResult = {
|
||||
uri: string;
|
||||
mimeType?: string;
|
||||
text?: string;
|
||||
blob?: string;
|
||||
};
|
||||
|
||||
export type Resource = {
|
||||
schema: ResourceSchema;
|
||||
read: (context: Context, uri: string) => Promise<ResourceResult[]>;
|
||||
};
|
||||
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' } },
|
||||
],
|
||||
});
|
||||
222
extension/src/background.ts
Normal file
222
extension/src/background.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 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 { RelayConnection, debugLog } from './relayConnection';
|
||||
|
||||
type PageMessage = {
|
||||
type: 'connectToMCPRelay';
|
||||
mcpRelayUrl: string;
|
||||
} | {
|
||||
type: 'getTabs';
|
||||
} | {
|
||||
type: 'connectToTab';
|
||||
tabId?: number;
|
||||
windowId?: number;
|
||||
mcpRelayUrl: string;
|
||||
} | {
|
||||
type: 'getConnectionStatus';
|
||||
} | {
|
||||
type: 'disconnect';
|
||||
};
|
||||
|
||||
class TabShareExtension {
|
||||
private _activeConnection: RelayConnection | undefined;
|
||||
private _connectedTabId: number | null = null;
|
||||
private _pendingTabSelection = new Map<number, { connection: RelayConnection, timerId?: number }>();
|
||||
|
||||
constructor() {
|
||||
chrome.tabs.onRemoved.addListener(this._onTabRemoved.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.action.onClicked.addListener(this._onActionClicked.bind(this));
|
||||
}
|
||||
|
||||
// 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) {
|
||||
switch (message.type) {
|
||||
case 'connectToMCPRelay':
|
||||
this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl).then(
|
||||
() => sendResponse({ success: true }),
|
||||
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||
return true;
|
||||
case 'getTabs':
|
||||
this._getTabs().then(
|
||||
tabs => sendResponse({ success: true, tabs, currentTabId: sender.tab?.id }),
|
||||
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||
return true;
|
||||
case 'connectToTab':
|
||||
const tabId = message.tabId || sender.tab?.id!;
|
||||
const windowId = message.windowId || sender.tab?.windowId!;
|
||||
this._connectTab(sender.tab!.id!, tabId, windowId, message.mcpRelayUrl!).then(
|
||||
() => sendResponse({ success: true }),
|
||||
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||
return true; // Return true to indicate that the response will be sent asynchronously
|
||||
case 'getConnectionStatus':
|
||||
sendResponse({
|
||||
connectedTabId: this._connectedTabId
|
||||
});
|
||||
return false;
|
||||
case 'disconnect':
|
||||
this._disconnect().then(
|
||||
() => sendResponse({ success: true }),
|
||||
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async _connectToRelay(selectorTabId: number, mcpRelayUrl: string): Promise<void> {
|
||||
try {
|
||||
debugLog(`Connecting to relay at ${mcpRelayUrl}`);
|
||||
const socket = new WebSocket(mcpRelayUrl);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.onopen = () => resolve();
|
||||
socket.onerror = () => reject(new Error('WebSocket error'));
|
||||
setTimeout(() => reject(new Error('Connection timeout')), 5000);
|
||||
});
|
||||
|
||||
const connection = new RelayConnection(socket);
|
||||
connection.onclose = () => {
|
||||
debugLog('Connection closed');
|
||||
this._pendingTabSelection.delete(selectorTabId);
|
||||
// TODO: show error in the selector tab?
|
||||
};
|
||||
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);
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
this._setConnectedTabId(tabId),
|
||||
chrome.tabs.update(tabId, { active: true }),
|
||||
chrome.windows.update(windowId, { focused: true }),
|
||||
]);
|
||||
debugLog(`Connected to MCP bridge`);
|
||||
} catch (error: any) {
|
||||
await this._setConnectedTabId(null);
|
||||
debugLog(`Failed to connect tab ${tabId}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async _setConnectedTabId(tabId: number | null): Promise<void> {
|
||||
const oldTabId = this._connectedTabId;
|
||||
this._connectedTabId = tabId;
|
||||
if (oldTabId && oldTabId !== tabId)
|
||||
await this._updateBadge(oldTabId, { text: '' });
|
||||
if (tabId)
|
||||
await this._updateBadge(tabId, { text: '✓', color: '#4CAF50', title: 'Connected to MCP client' });
|
||||
}
|
||||
|
||||
private async _updateBadge(tabId: number, { text, color, title }: { text: string; color?: string, title?: string }): Promise<void> {
|
||||
try {
|
||||
await chrome.action.setBadgeText({ tabId, text });
|
||||
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> {
|
||||
const pendingConnection = this._pendingTabSelection.get(tabId)?.connection;
|
||||
if (pendingConnection) {
|
||||
this._pendingTabSelection.delete(tabId);
|
||||
pendingConnection.close('Browser tab closed');
|
||||
return;
|
||||
}
|
||||
if (this._connectedTabId !== tabId)
|
||||
return;
|
||||
this._activeConnection?.close('Browser tab closed');
|
||||
this._activeConnection = undefined;
|
||||
this._connectedTabId = null;
|
||||
}
|
||||
|
||||
private _onTabActivated(activeInfo: chrome.tabs.TabActiveInfo) {
|
||||
for (const [tabId, pending] of this._pendingTabSelection) {
|
||||
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[]> {
|
||||
const tabs = await chrome.tabs.query({});
|
||||
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();
|
||||
178
extension/src/relayConnection.ts
Normal file
178
extension/src/relayConnection.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export function debugLog(...args: unknown[]): void {
|
||||
const enabled = true;
|
||||
if (enabled) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[Extension]', ...args);
|
||||
}
|
||||
}
|
||||
|
||||
type ProtocolCommand = {
|
||||
id: number;
|
||||
method: string;
|
||||
params?: any;
|
||||
};
|
||||
|
||||
type ProtocolResponse = {
|
||||
id?: number;
|
||||
method?: string;
|
||||
params?: any;
|
||||
result?: any;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export class RelayConnection {
|
||||
private _debuggee: chrome.debugger.Debuggee;
|
||||
private _ws: WebSocket;
|
||||
private _eventListener: (source: chrome.debugger.DebuggerSession, method: string, params: any) => void;
|
||||
private _detachListener: (source: chrome.debugger.Debuggee, reason: string) => void;
|
||||
private _tabPromise: Promise<void>;
|
||||
private _tabPromiseResolve!: () => void;
|
||||
private _closed = false;
|
||||
|
||||
onclose?: () => void;
|
||||
|
||||
constructor(ws: WebSocket) {
|
||||
this._debuggee = { };
|
||||
this._tabPromise = new Promise(resolve => this._tabPromiseResolve = resolve);
|
||||
this._ws = ws;
|
||||
this._ws.onmessage = this._onMessage.bind(this);
|
||||
this._ws.onclose = () => this._onClose();
|
||||
// Store listeners for cleanup
|
||||
this._eventListener = this._onDebuggerEvent.bind(this);
|
||||
this._detachListener = this._onDebuggerDetach.bind(this);
|
||||
chrome.debugger.onEvent.addListener(this._eventListener);
|
||||
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 {
|
||||
this._ws.close(1000, message);
|
||||
// ws.onclose is called asynchronously, so we call it here to avoid forwarding
|
||||
// CDP events to the closed connection.
|
||||
this._onClose();
|
||||
}
|
||||
|
||||
private _onClose() {
|
||||
if (this._closed)
|
||||
return;
|
||||
this._closed = true;
|
||||
chrome.debugger.onEvent.removeListener(this._eventListener);
|
||||
chrome.debugger.onDetach.removeListener(this._detachListener);
|
||||
chrome.debugger.detach(this._debuggee).catch(() => {});
|
||||
this.onclose?.();
|
||||
}
|
||||
|
||||
private _onDebuggerEvent(source: chrome.debugger.DebuggerSession, method: string, params: any): void {
|
||||
if (source.tabId !== this._debuggee.tabId)
|
||||
return;
|
||||
debugLog('Forwarding CDP event:', method, params);
|
||||
const sessionId = source.sessionId;
|
||||
this._sendMessage({
|
||||
method: 'forwardCDPEvent',
|
||||
params: {
|
||||
sessionId,
|
||||
method,
|
||||
params,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _onDebuggerDetach(source: chrome.debugger.Debuggee, reason: string): void {
|
||||
if (source.tabId !== this._debuggee.tabId)
|
||||
return;
|
||||
this.close(`Debugger detached: ${reason}`);
|
||||
this._debuggee = { };
|
||||
}
|
||||
|
||||
private _onMessage(event: MessageEvent): void {
|
||||
this._onMessageAsync(event).catch(e => debugLog('Error handling message:', e));
|
||||
}
|
||||
|
||||
private async _onMessageAsync(event: MessageEvent): Promise<void> {
|
||||
let message: ProtocolCommand;
|
||||
try {
|
||||
message = JSON.parse(event.data);
|
||||
} catch (error: any) {
|
||||
debugLog('Error parsing message:', error);
|
||||
this._sendError(-32700, `Error parsing message: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
debugLog('Received message:', message);
|
||||
|
||||
const response: ProtocolResponse = {
|
||||
id: message.id,
|
||||
};
|
||||
try {
|
||||
response.result = await this._handleCommand(message);
|
||||
} catch (error: any) {
|
||||
debugLog('Error handling command:', error);
|
||||
response.error = error.message;
|
||||
}
|
||||
debugLog('Sending response:', response);
|
||||
this._sendMessage(response);
|
||||
}
|
||||
|
||||
private async _handleCommand(message: ProtocolCommand): Promise<any> {
|
||||
if (message.method === 'attachToTab') {
|
||||
await this._tabPromise;
|
||||
debugLog('Attaching debugger to tab:', this._debuggee);
|
||||
await chrome.debugger.attach(this._debuggee, '1.3');
|
||||
const result: any = await chrome.debugger.sendCommand(this._debuggee, 'Target.getTargetInfo');
|
||||
return {
|
||||
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') {
|
||||
const { sessionId, method, params } = message.params;
|
||||
debugLog('CDP command:', method, params);
|
||||
const debuggerSession: chrome.debugger.DebuggerSession = {
|
||||
...this._debuggee,
|
||||
sessionId,
|
||||
};
|
||||
// Forward CDP command to chrome.debugger
|
||||
return await chrome.debugger.sendCommand(
|
||||
debuggerSession,
|
||||
method,
|
||||
params
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _sendError(code: number, message: string): void {
|
||||
this._sendMessage({
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _sendMessage(message: any): void {
|
||||
if (this._ws.readyState === WebSocket.OPEN)
|
||||
this._ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
206
extension/src/ui/connect.css
Normal file
206
extension/src/ui/connect.css
Normal file
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
.app-container {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
|
||||
background-color: #ffffff;
|
||||
color: #1f2328;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
min-height: 100vh;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Status Banner */
|
||||
.status-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.status-banner {
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.status-banner.connected {
|
||||
color: #1f2328;
|
||||
}
|
||||
|
||||
.status-banner.connected::before {
|
||||
content: "\2705";
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.status-banner.error {
|
||||
color: #1f2328;
|
||||
}
|
||||
|
||||
.status-banner.error::before {
|
||||
content: "\274C";
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.button-container {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
margin-right: 8px;
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
background-color: #f8f9fa;
|
||||
color: #3c4043;
|
||||
border: 1px solid #dadce0;
|
||||
}
|
||||
|
||||
.button.primary:hover {
|
||||
background-color: #f1f3f4;
|
||||
border-color: #dadce0;
|
||||
box-shadow: 0 1px 2px 0 rgba(60,64,67,.1);
|
||||
}
|
||||
|
||||
.button.default {
|
||||
background-color: #f6f8fa;
|
||||
color: #24292f;
|
||||
}
|
||||
|
||||
.button.default:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.button.reject {
|
||||
background-color: #da3633;
|
||||
color: #ffffff;
|
||||
border: 1px solid #da3633;
|
||||
}
|
||||
|
||||
.button.reject:hover {
|
||||
background-color: #c73836;
|
||||
border-color: #c73836;
|
||||
}
|
||||
|
||||
/* Tab selection */
|
||||
.tab-section-title {
|
||||
padding-left: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
margin-bottom: 12px;
|
||||
color: #656d76;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
background-color: #ffffff;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.tab-item:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.tab-item.selected {
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
|
||||
.tab-item.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tab-radio {
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-favicon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
font-weight: 500;
|
||||
color: #1f2328;
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tab-url {
|
||||
font-size: 12px;
|
||||
color: #656d76;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Link-style button */
|
||||
.link-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #0066cc;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
}
|
||||
29
extension/src/ui/connect.html
Normal file
29
extension/src/ui/connect.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Playwright MCP extension</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="../../icons/icon-32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="../../icons/icon-16.png">
|
||||
<link rel="stylesheet" href="connect.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="connect.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
233
extension/src/ui/connect.tsx
Normal file
233
extension/src/ui/connect.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Button, TabItem } from './tabItem';
|
||||
import type { TabInfo } from './tabItem';
|
||||
|
||||
type Status =
|
||||
| { type: 'connecting'; message: string }
|
||||
| { type: 'connected'; message: string }
|
||||
| { type: 'error'; message: string }
|
||||
| { type: 'error'; versionMismatch: { extensionVersion: string; } };
|
||||
|
||||
const SUPPORTED_PROTOCOL_VERSION = 1;
|
||||
|
||||
const ConnectApp: React.FC = () => {
|
||||
const [tabs, setTabs] = useState<TabInfo[]>([]);
|
||||
const [status, setStatus] = useState<Status | null>(null);
|
||||
const [showButtons, setShowButtons] = useState(true);
|
||||
const [showTabList, setShowTabList] = useState(true);
|
||||
const [clientInfo, setClientInfo] = useState('unknown');
|
||||
const [mcpRelayUrl, setMcpRelayUrl] = useState('');
|
||||
const [newTab, setNewTab] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const relayUrl = params.get('mcpRelayUrl');
|
||||
|
||||
if (!relayUrl) {
|
||||
setShowButtons(false);
|
||||
setStatus({ type: 'error', message: 'Missing mcpRelayUrl parameter in URL.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setMcpRelayUrl(relayUrl);
|
||||
|
||||
try {
|
||||
const client = JSON.parse(params.get('client') || '{}');
|
||||
const info = `${client.name}/${client.version}`;
|
||||
setClientInfo(info);
|
||||
setStatus({
|
||||
type: 'connecting',
|
||||
message: `🎭 Playwright MCP started from "${info}" is trying to connect. Do you want to continue?`
|
||||
});
|
||||
} catch (e) {
|
||||
setStatus({ type: 'error', message: 'Failed to parse client version.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedVersion = parseInt(params.get('protocolVersion') ?? '', 10);
|
||||
const requiredVersion = isNaN(parsedVersion) ? 1 : parsedVersion;
|
||||
if (requiredVersion > SUPPORTED_PROTOCOL_VERSION) {
|
||||
const extensionVersion = chrome.runtime.getManifest().version;
|
||||
setShowButtons(false);
|
||||
setShowTabList(false);
|
||||
setStatus({
|
||||
type: 'error',
|
||||
versionMismatch: {
|
||||
extensionVersion,
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
void connectToMCPRelay(relayUrl);
|
||||
|
||||
// If this is a browser_navigate command, hide the tab list and show simple allow/reject
|
||||
if (params.get('newTab') === 'true') {
|
||||
setNewTab(true);
|
||||
setShowTabList(false);
|
||||
} else {
|
||||
void loadTabs();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleReject = useCallback((message: string) => {
|
||||
setShowButtons(false);
|
||||
setShowTabList(false);
|
||||
setStatus({ type: 'error', message });
|
||||
}, []);
|
||||
|
||||
const connectToMCPRelay = useCallback(async (mcpRelayUrl: string) => {
|
||||
|
||||
const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl });
|
||||
if (!response.success)
|
||||
handleReject(response.error);
|
||||
}, [handleReject]);
|
||||
|
||||
const loadTabs = useCallback(async () => {
|
||||
const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
|
||||
if (response.success)
|
||||
setTabs(response.tabs);
|
||||
else
|
||||
setStatus({ type: 'error', message: 'Failed to load tabs: ' + response.error });
|
||||
}, []);
|
||||
|
||||
const handleConnectToTab = useCallback(async (tab?: TabInfo) => {
|
||||
setShowButtons(false);
|
||||
setShowTabList(false);
|
||||
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'connectToTab',
|
||||
mcpRelayUrl,
|
||||
tabId: tab?.id,
|
||||
windowId: tab?.windowId,
|
||||
});
|
||||
|
||||
if (response?.success) {
|
||||
setStatus({ type: 'connected', message: `MCP client "${clientInfo}" connected.` });
|
||||
} else {
|
||||
setStatus({
|
||||
type: 'error',
|
||||
message: response?.error || `MCP client "${clientInfo}" failed to connect.`
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus({
|
||||
type: 'error',
|
||||
message: `MCP client "${clientInfo}" failed to connect: ${e}`
|
||||
});
|
||||
}
|
||||
}, [clientInfo, mcpRelayUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (message: any) => {
|
||||
if (message.type === 'connectionTimeout')
|
||||
handleReject('Connection timed out.');
|
||||
};
|
||||
chrome.runtime.onMessage.addListener(listener);
|
||||
return () => {
|
||||
chrome.runtime.onMessage.removeListener(listener);
|
||||
};
|
||||
}, [handleReject]);
|
||||
|
||||
return (
|
||||
<div className='app-container'>
|
||||
<div className='content-wrapper'>
|
||||
{status && (
|
||||
<div className='status-container'>
|
||||
<StatusBanner status={status} />
|
||||
{showButtons && (
|
||||
<div className='button-container'>
|
||||
{newTab ? (
|
||||
<>
|
||||
<Button variant='primary' onClick={() => handleConnectToTab()}>
|
||||
Allow
|
||||
</Button>
|
||||
<Button variant='reject' onClick={() => handleReject('Connection rejected. This tab can be closed.')}>
|
||||
Reject
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant='reject' onClick={() => handleReject('Connection rejected. This tab can be closed.')}>
|
||||
Reject
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showTabList && (
|
||||
<div>
|
||||
<div className='tab-section-title'>
|
||||
Select page to expose to MCP server:
|
||||
</div>
|
||||
<div>
|
||||
{tabs.map(tab => (
|
||||
<TabItem
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
button={
|
||||
<Button variant='primary' onClick={() => handleConnectToTab(tab)}>
|
||||
Connect
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const VersionMismatchError: React.FC<{ extensionVersion: string }> = ({ extensionVersion }) => {
|
||||
const readmeUrl = 'https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md';
|
||||
const latestReleaseUrl = 'https://github.com/microsoft/playwright-mcp/releases/latest';
|
||||
return (
|
||||
<div>
|
||||
Playwright MCP version trying to connect requires newer extension version (current version: {extensionVersion}).{' '}
|
||||
<a href={latestReleaseUrl}>Click here</a> to download latest version of the extension, then drag and drop it into the Chrome Extensions page.{' '}
|
||||
See <a href={readmeUrl} target='_blank' rel='noopener noreferrer'>installation instructions</a> for more details.
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StatusBanner: React.FC<{ status: Status }> = ({ status }) => {
|
||||
return (
|
||||
<div className={`status-banner ${status.type}`}>
|
||||
{'versionMismatch' in status ? (
|
||||
<VersionMismatchError
|
||||
extensionVersion={status.versionMismatch.extensionVersion}
|
||||
/>
|
||||
) : (
|
||||
status.message
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Initialize the React app
|
||||
const container = document.getElementById('root');
|
||||
if (container) {
|
||||
const root = createRoot(container);
|
||||
root.render(<ConnectApp />);
|
||||
}
|
||||
13
extension/src/ui/status.html
Normal file
13
extension/src/ui/status.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Playwright MCP Bridge Status</title>
|
||||
<link rel="stylesheet" href="connect.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="status.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
110
extension/src/ui/status.tsx
Normal file
110
extension/src/ui/status.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Button, TabItem } from './tabItem';
|
||||
|
||||
import type { TabInfo } from './tabItem';
|
||||
|
||||
interface ConnectionStatus {
|
||||
isConnected: boolean;
|
||||
connectedTabId: number | null;
|
||||
connectedTab?: TabInfo;
|
||||
}
|
||||
|
||||
const StatusApp: React.FC = () => {
|
||||
const [status, setStatus] = useState<ConnectionStatus>({
|
||||
isConnected: false,
|
||||
connectedTabId: null
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
}, []);
|
||||
|
||||
const loadStatus = async () => {
|
||||
// Get current connection status from background script
|
||||
const { connectedTabId } = await chrome.runtime.sendMessage({ type: 'getConnectionStatus' });
|
||||
if (connectedTabId) {
|
||||
const tab = await chrome.tabs.get(connectedTabId);
|
||||
setStatus({
|
||||
isConnected: true,
|
||||
connectedTabId,
|
||||
connectedTab: {
|
||||
id: tab.id!,
|
||||
windowId: tab.windowId!,
|
||||
title: tab.title!,
|
||||
url: tab.url!,
|
||||
favIconUrl: tab.favIconUrl
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setStatus({
|
||||
isConnected: false,
|
||||
connectedTabId: null
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const openConnectedTab = async () => {
|
||||
if (!status.connectedTabId)
|
||||
return;
|
||||
await chrome.tabs.update(status.connectedTabId, { active: true });
|
||||
window.close();
|
||||
};
|
||||
|
||||
const disconnect = async () => {
|
||||
await chrome.runtime.sendMessage({ type: 'disconnect' });
|
||||
window.close();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='app-container'>
|
||||
<div className='content-wrapper'>
|
||||
{status.isConnected && status.connectedTab ? (
|
||||
<div>
|
||||
<div className='tab-section-title'>
|
||||
Page with connected MCP client:
|
||||
</div>
|
||||
<div>
|
||||
<TabItem
|
||||
tab={status.connectedTab}
|
||||
button={
|
||||
<Button variant='primary' onClick={disconnect}>
|
||||
Disconnect
|
||||
</Button>
|
||||
}
|
||||
onClick={openConnectedTab}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='status-banner'>
|
||||
No MCP clients are currently connected.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Initialize the React app
|
||||
const container = document.getElementById('root');
|
||||
if (container) {
|
||||
const root = createRoot(container);
|
||||
root.render(<StatusApp />);
|
||||
}
|
||||
67
extension/src/ui/tabItem.tsx
Normal file
67
extension/src/ui/tabItem.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export interface TabInfo {
|
||||
id: number;
|
||||
windowId: number;
|
||||
title: string;
|
||||
url: string;
|
||||
favIconUrl?: string;
|
||||
}
|
||||
|
||||
export const Button: React.FC<{ variant: 'primary' | 'default' | 'reject'; onClick: () => void; children: React.ReactNode }> = ({
|
||||
variant,
|
||||
onClick,
|
||||
children
|
||||
}) => {
|
||||
return (
|
||||
<button className={`button ${variant}`} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export interface TabItemProps {
|
||||
tab: TabInfo;
|
||||
onClick?: () => void;
|
||||
button?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TabItem: React.FC<TabItemProps> = ({
|
||||
tab,
|
||||
onClick,
|
||||
button
|
||||
}) => {
|
||||
return (
|
||||
<div className='tab-item' onClick={onClick} style={onClick ? { cursor: 'pointer' } : undefined}>
|
||||
<img
|
||||
src={tab.favIconUrl || 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><rect width="16" height="16" fill="%23f6f8fa"/></svg>'}
|
||||
alt=''
|
||||
className='tab-favicon'
|
||||
/>
|
||||
<div className='tab-content'>
|
||||
<div className='tab-title'>
|
||||
{tab.title || 'Untitled'}
|
||||
</div>
|
||||
<div className='tab-url'>{tab.url}</div>
|
||||
</div>
|
||||
{button}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
4
extension/src/ui/tsconfig.json
Normal file
4
extension/src/ui/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
// Help VSCode to find right tsconfig file.
|
||||
{
|
||||
"extends": "../../tsconfig.ui.json"
|
||||
}
|
||||
306
extension/tests/extension.spec.ts
Normal file
306
extension/tests/extension.spec.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* 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 { chromium } from 'playwright';
|
||||
import { test as base, expect } from '../../tests/fixtures';
|
||||
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import type { StartClient } from '../../tests/fixtures';
|
||||
|
||||
type BrowserWithExtension = {
|
||||
userDataDir: string;
|
||||
launch: (mode?: 'disable-extension') => Promise<BrowserContext>;
|
||||
};
|
||||
|
||||
type TestFixtures = {
|
||||
browserWithExtension: BrowserWithExtension,
|
||||
pathToExtension: string,
|
||||
useShortConnectionTimeout: (timeoutMs: number) => void
|
||||
overrideProtocolVersion: (version: number) => void
|
||||
};
|
||||
|
||||
const test = base.extend<TestFixtures>({
|
||||
pathToExtension: async ({}, use) => {
|
||||
await use(path.resolve(__dirname, '../dist'));
|
||||
},
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
overrideProtocolVersion: async ({}, use) => {
|
||||
await use((version: number) => {
|
||||
process.env.PWMCP_TEST_PROTOCOL_VERSION = version.toString();
|
||||
});
|
||||
process.env.PWMCP_TEST_PROTOCOL_VERSION = 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 testWithOldExtensionVersion = test.extend({
|
||||
pathToExtension: async ({}, use, testInfo) => {
|
||||
const extensionDir = testInfo.outputPath('extension');
|
||||
const oldPath = path.resolve(__dirname, '../dist');
|
||||
|
||||
await fs.promises.cp(oldPath, extensionDir, { recursive: true });
|
||||
const manifestPath = path.join(extensionDir, 'manifest.json');
|
||||
const manifest = JSON.parse(await fs.promises.readFile(manifestPath, 'utf8'));
|
||||
manifest.version = '0.0.1';
|
||||
await fs.promises.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
||||
|
||||
await use(extensionDir);
|
||||
},
|
||||
});
|
||||
|
||||
for (const [mode, startClientMethod] of [
|
||||
['connect-tool', startAndCallConnectTool],
|
||||
['extension-flag', startWithExtensionFlag],
|
||||
] as const) {
|
||||
|
||||
test(`navigate with extension (${mode})`, async ({ browserWithExtension, startClient, server }) => {
|
||||
const browserContext = await browserWithExtension.launch();
|
||||
|
||||
const client = await startClientMethod(browserWithExtension, startClient);
|
||||
|
||||
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
|
||||
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
||||
});
|
||||
|
||||
const navigateResponse = client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
|
||||
const selectorPage = await confirmationPagePromise;
|
||||
// For browser_navigate command, the UI shows Allow/Reject buttons instead of tab selector
|
||||
await selectorPage.getByRole('button', { name: 'Allow' }).click();
|
||||
|
||||
expect(await navigateResponse).toHaveResponse({
|
||||
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||
});
|
||||
});
|
||||
|
||||
test(`snapshot of an existing page (${mode})`, async ({ browserWithExtension, startClient, server }) => {
|
||||
const browserContext = await browserWithExtension.launch();
|
||||
|
||||
const page = await browserContext.newPage();
|
||||
await page.goto(server.HELLO_WORLD);
|
||||
|
||||
// Another empty page.
|
||||
await browserContext.newPage();
|
||||
expect(browserContext.pages()).toHaveLength(3);
|
||||
|
||||
const client = await startClientMethod(browserWithExtension, startClient);
|
||||
expect(browserContext.pages()).toHaveLength(3);
|
||||
|
||||
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
|
||||
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
||||
});
|
||||
|
||||
const navigateResponse = client.callTool({
|
||||
name: 'browser_snapshot',
|
||||
arguments: { },
|
||||
});
|
||||
|
||||
const selectorPage = await confirmationPagePromise;
|
||||
expect(browserContext.pages()).toHaveLength(4);
|
||||
|
||||
await selectorPage.locator('.tab-item', { hasText: 'Title' }).getByRole('button', { name: 'Connect' }).click();
|
||||
|
||||
expect(await navigateResponse).toHaveResponse({
|
||||
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||
});
|
||||
|
||||
expect(browserContext.pages()).toHaveLength(4);
|
||||
});
|
||||
|
||||
test(`extension not installed timeout (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
|
||||
useShortConnectionTimeout(100);
|
||||
|
||||
const browserContext = await browserWithExtension.launch();
|
||||
|
||||
const client = await startClientMethod(browserWithExtension, startClient);
|
||||
|
||||
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
|
||||
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toHaveResponse({
|
||||
result: expect.stringContaining('Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed.'),
|
||||
isError: true,
|
||||
});
|
||||
|
||||
await confirmationPagePromise;
|
||||
});
|
||||
|
||||
testWithOldExtensionVersion(`works with old extension version (${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 selectorPage = await confirmationPagePromise;
|
||||
// For browser_navigate command, the UI shows Allow/Reject buttons instead of tab selector
|
||||
await selectorPage.getByRole('button', { name: 'Allow' }).click();
|
||||
|
||||
expect(await navigateResponse).toHaveResponse({
|
||||
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||
});
|
||||
});
|
||||
|
||||
test(`extension needs update (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout, overrideProtocolVersion }) => {
|
||||
useShortConnectionTimeout(500);
|
||||
overrideProtocolVersion(1000);
|
||||
|
||||
// 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')).toContainText(`Playwright MCP version trying to connect requires newer extension version`);
|
||||
|
||||
expect(await navigateResponse).toHaveResponse({
|
||||
result: expect.stringContaining('Extension connection timeout.'),
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
test(`custom executablePath`, async ({ startClient, server, useShortConnectionTimeout }) => {
|
||||
useShortConnectionTimeout(1000);
|
||||
|
||||
const executablePath = test.info().outputPath('echo.sh');
|
||||
await fs.promises.writeFile(executablePath, '#!/bin/bash\necho "Custom exec args: $@" > "$(dirname "$0")/output.txt"', { mode: 0o755 });
|
||||
|
||||
const { client } = await startClient({
|
||||
args: [`--extension`],
|
||||
config: {
|
||||
browser: {
|
||||
launchOptions: {
|
||||
executablePath,
|
||||
},
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const navigateResponse = await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
timeout: 1000,
|
||||
});
|
||||
expect(await navigateResponse).toHaveResponse({
|
||||
result: expect.stringContaining('Extension connection timeout.'),
|
||||
isError: true,
|
||||
});
|
||||
expect(await fs.promises.readFile(test.info().outputPath('output.txt'), 'utf8')).toContain('Custom exec args: chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html?');
|
||||
});
|
||||
22
extension/tsconfig.json
Normal file
22
extension/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"module": "ESNext",
|
||||
"rootDir": "src",
|
||||
"outDir": "./dist/lib",
|
||||
"resolveJsonModule": true,
|
||||
"types": ["chrome"],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
],
|
||||
"exclude": [
|
||||
"src/ui",
|
||||
]
|
||||
}
|
||||
19
extension/tsconfig.ui.json
Normal file
19
extension/tsconfig.ui.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"module": "ESNext",
|
||||
"rootDir": "src",
|
||||
"outDir": "./lib",
|
||||
"resolveJsonModule": true,
|
||||
"types": ["chrome"],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react",
|
||||
"noEmit": true,
|
||||
},
|
||||
"include": [
|
||||
"src/ui",
|
||||
],
|
||||
}
|
||||
54
extension/vite.config.mts
Normal file
54
extension/vite.config.mts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { resolve } from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: '../../icons/*',
|
||||
dest: 'icons'
|
||||
},
|
||||
{
|
||||
src: '../../manifest.json',
|
||||
dest: '.'
|
||||
}
|
||||
]
|
||||
})
|
||||
],
|
||||
root: resolve(__dirname, 'src/ui'),
|
||||
build: {
|
||||
outDir: resolve(__dirname, 'dist/'),
|
||||
emptyOutDir: false,
|
||||
minify: false,
|
||||
rollupOptions: {
|
||||
input: ['src/ui/connect.html', 'src/ui/status.html'],
|
||||
output: {
|
||||
manualChunks: undefined,
|
||||
entryFileNames: 'lib/ui/[name].js',
|
||||
chunkFileNames: 'lib/ui/[name].js',
|
||||
assetFileNames: 'lib/ui/[name].[ext]'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -13,3 +13,19 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { resolve } from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/background.ts'),
|
||||
fileName: 'lib/background',
|
||||
formats: ['es']
|
||||
},
|
||||
outDir: 'dist',
|
||||
emptyOutDir: false,
|
||||
minify: false
|
||||
}
|
||||
});
|
||||
32
index.d.ts
vendored
32
index.d.ts
vendored
@@ -15,33 +15,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { LaunchOptions } from 'playwright';
|
||||
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import type { Config } from './config';
|
||||
import type { BrowserContext } from 'playwright';
|
||||
|
||||
type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install';
|
||||
|
||||
type Options = {
|
||||
/**
|
||||
* Path to the user data directory.
|
||||
*/
|
||||
userDataDir?: string;
|
||||
|
||||
/**
|
||||
* Launch options for the browser.
|
||||
*/
|
||||
launchOptions?: LaunchOptions;
|
||||
|
||||
/**
|
||||
* Use screenshots instead of snapshots. Less accurate, reliable and overall
|
||||
* slower, but contains visual representation of the page.
|
||||
* @default false
|
||||
*/
|
||||
vision?: boolean;
|
||||
|
||||
/**
|
||||
* Capabilities to enable.
|
||||
*/
|
||||
capabilities?: ToolCapability[];
|
||||
};
|
||||
|
||||
export function createServer(options?: Options): Server;
|
||||
export declare function createConnection(config?: Config, contextGetter?: () => Promise<BrowserContext>): Promise<Server>;
|
||||
export {};
|
||||
|
||||
4
index.js
4
index.js
@@ -15,5 +15,5 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const { createServer } = require('./lib/index');
|
||||
module.exports = { createServer };
|
||||
const { createConnection } = require('playwright/lib/mcp/index');
|
||||
module.exports = { createConnection };
|
||||
|
||||
3588
package-lock.json
generated
3588
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
40
package.json
40
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.37",
|
||||
"description": "Playwright Tools for MCP",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -15,11 +15,14 @@
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"lint": "eslint .",
|
||||
"watch": "tsc --watch",
|
||||
"lint": "npm run update-readme",
|
||||
"update-readme": "node update-readme.js",
|
||||
"docker-build": "docker build --no-cache -t playwright-mcp-dev:latest .",
|
||||
"test": "playwright test",
|
||||
"clean": "rm -rf lib",
|
||||
"ctest": "playwright test --project=chrome",
|
||||
"ftest": "playwright test --project=firefox",
|
||||
"wtest": "playwright test --project=webkit",
|
||||
"dtest": "MCP_IN_DOCKER=1 playwright test --project=chromium-docker",
|
||||
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
|
||||
},
|
||||
"exports": {
|
||||
@@ -30,27 +33,16 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.6.1",
|
||||
"commander": "^13.1.0",
|
||||
"playwright": "^1.52.0-alpha-1743163434000",
|
||||
"yaml": "^2.7.1",
|
||||
"zod-to-json-schema": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@playwright/test": "^1.52.0-alpha-1743163434000",
|
||||
"@stylistic/eslint-plugin": "^3.0.1",
|
||||
"@types/node": "^22.13.10",
|
||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||
"@typescript-eslint/parser": "^8.26.1",
|
||||
"@typescript-eslint/utils": "^8.26.1",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-notice": "^1.0.0",
|
||||
"typescript": "^5.8.2"
|
||||
"playwright": "1.56.0-alpha-2025-09-06",
|
||||
"playwright-core": "1.56.0-alpha-2025-09-06"
|
||||
},
|
||||
"bin": {
|
||||
"mcp-server-playwright": "cli.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.17.5",
|
||||
"@playwright/test": "1.56.0-alpha-2025-09-06",
|
||||
"@types/node": "^24.3.0",
|
||||
"zod-to-json-schema": "^3.24.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,23 @@
|
||||
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
import type { TestOptions } from './tests/fixtures';
|
||||
|
||||
export default defineConfig<TestOptions>({
|
||||
testDir: './tests',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
workers: process.env.CI ? 2 : undefined,
|
||||
reporter: 'list',
|
||||
projects: [{ name: 'default' }],
|
||||
projects: [
|
||||
{ name: 'chrome' },
|
||||
...process.env.MCP_IN_DOCKER ? [{
|
||||
name: 'chromium-docker',
|
||||
grep: /browser_navigate|browser_click/,
|
||||
use: {
|
||||
mcpBrowser: 'chromium',
|
||||
mcpMode: 'docker' as const
|
||||
}
|
||||
}] : [],
|
||||
],
|
||||
});
|
||||
|
||||
3
src/README.md
Normal file
3
src/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Where is the source?
|
||||
|
||||
Playwright MCP source code is located in the Playwright monorepo. Please refer to the contributor's guide in [CONTRIBUTING.md](../CONTRIBUTING.md) for more details.
|
||||
361
src/context.ts
361
src/context.ts
@@ -1,361 +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 * as playwright from 'playwright';
|
||||
import yaml from 'yaml';
|
||||
|
||||
import { waitForCompletion } from './tools/utils';
|
||||
import { ToolResult } from './tools/tool';
|
||||
|
||||
export type ContextOptions = {
|
||||
browserName?: 'chromium' | 'firefox' | 'webkit';
|
||||
userDataDir: string;
|
||||
launchOptions?: playwright.LaunchOptions;
|
||||
cdpEndpoint?: string;
|
||||
remoteEndpoint?: string;
|
||||
};
|
||||
|
||||
type PageOrFrameLocator = playwright.Page | playwright.FrameLocator;
|
||||
|
||||
type RunOptions = {
|
||||
captureSnapshot?: boolean;
|
||||
waitForCompletion?: boolean;
|
||||
status?: string;
|
||||
noClearFileChooser?: boolean;
|
||||
};
|
||||
|
||||
export class Context {
|
||||
readonly options: ContextOptions;
|
||||
private _browser: playwright.Browser | undefined;
|
||||
private _browserContext: playwright.BrowserContext | undefined;
|
||||
private _tabs: Tab[] = [];
|
||||
private _currentTab: Tab | undefined;
|
||||
|
||||
constructor(options: ContextOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
tabs(): Tab[] {
|
||||
return this._tabs;
|
||||
}
|
||||
|
||||
currentTab(): Tab {
|
||||
if (!this._currentTab)
|
||||
throw new Error('Navigate to a location to create a tab');
|
||||
return this._currentTab;
|
||||
}
|
||||
|
||||
async newTab(): Promise<Tab> {
|
||||
const browserContext = await this._ensureBrowserContext();
|
||||
const page = await browserContext.newPage();
|
||||
this._currentTab = this._tabs.find(t => t.page === page)!;
|
||||
return this._currentTab;
|
||||
}
|
||||
|
||||
async selectTab(index: number) {
|
||||
this._currentTab = this._tabs[index - 1];
|
||||
await this._currentTab.page.bringToFront();
|
||||
}
|
||||
|
||||
async ensureTab(): Promise<Tab> {
|
||||
const context = await this._ensureBrowserContext();
|
||||
if (!this._currentTab)
|
||||
await context.newPage();
|
||||
return this._currentTab!;
|
||||
}
|
||||
|
||||
async listTabs(): Promise<string> {
|
||||
if (!this._tabs.length)
|
||||
return 'No tabs open';
|
||||
const lines: string[] = ['Open tabs:'];
|
||||
for (let i = 0; i < this._tabs.length; i++) {
|
||||
const tab = this._tabs[i];
|
||||
const title = await tab.page.title();
|
||||
const url = tab.page.url();
|
||||
const current = tab === this._currentTab ? ' (current)' : '';
|
||||
lines.push(`- ${i + 1}:${current} [${title}] (${url})`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async closeTab(index: number | undefined) {
|
||||
const tab = index === undefined ? this.currentTab() : this._tabs[index - 1];
|
||||
await tab.page.close();
|
||||
return await this.listTabs();
|
||||
}
|
||||
|
||||
private _onPageCreated(page: playwright.Page) {
|
||||
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
|
||||
this._tabs.push(tab);
|
||||
if (!this._currentTab)
|
||||
this._currentTab = tab;
|
||||
}
|
||||
|
||||
private _onPageClosed(tab: Tab) {
|
||||
const index = this._tabs.indexOf(tab);
|
||||
if (index === -1)
|
||||
return;
|
||||
this._tabs.splice(index, 1);
|
||||
|
||||
if (this._currentTab === tab)
|
||||
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
|
||||
const browser = this._browser;
|
||||
if (this._browserContext && !this._tabs.length) {
|
||||
void this._browserContext.close().then(() => browser?.close()).catch(() => {});
|
||||
this._browser = undefined;
|
||||
this._browserContext = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (!this._browserContext)
|
||||
return;
|
||||
await this._browserContext.close();
|
||||
}
|
||||
|
||||
private async _ensureBrowserContext() {
|
||||
if (!this._browserContext) {
|
||||
const context = await this._createBrowserContext();
|
||||
this._browser = context.browser;
|
||||
this._browserContext = context.browserContext;
|
||||
for (const page of this._browserContext.pages())
|
||||
this._onPageCreated(page);
|
||||
this._browserContext.on('page', page => this._onPageCreated(page));
|
||||
}
|
||||
return this._browserContext;
|
||||
}
|
||||
|
||||
private async _createBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
|
||||
if (this.options.remoteEndpoint) {
|
||||
const url = new URL(this.options.remoteEndpoint);
|
||||
if (this.options.browserName)
|
||||
url.searchParams.set('browser', this.options.browserName);
|
||||
if (this.options.launchOptions)
|
||||
url.searchParams.set('launch-options', JSON.stringify(this.options.launchOptions));
|
||||
const browser = await playwright[this.options.browserName ?? 'chromium'].connect(String(url));
|
||||
const browserContext = await browser.newContext();
|
||||
return { browser, browserContext };
|
||||
}
|
||||
|
||||
if (this.options.cdpEndpoint) {
|
||||
const browser = await playwright.chromium.connectOverCDP(this.options.cdpEndpoint);
|
||||
const browserContext = browser.contexts()[0];
|
||||
return { browser, browserContext };
|
||||
}
|
||||
|
||||
const browserContext = await this._launchPersistentContext();
|
||||
return { browserContext };
|
||||
}
|
||||
|
||||
private async _launchPersistentContext(): Promise<playwright.BrowserContext> {
|
||||
try {
|
||||
const browserType = this.options.browserName ? playwright[this.options.browserName] : playwright.chromium;
|
||||
return await browserType.launchPersistentContext(this.options.userDataDir, this.options.launchOptions);
|
||||
} catch (error: any) {
|
||||
if (error.message.includes('Executable doesn\'t exist'))
|
||||
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Tab {
|
||||
readonly context: Context;
|
||||
readonly page: playwright.Page;
|
||||
private _console: playwright.ConsoleMessage[] = [];
|
||||
private _fileChooser: playwright.FileChooser | undefined;
|
||||
private _snapshot: PageSnapshot | undefined;
|
||||
private _onPageClose: (tab: Tab) => void;
|
||||
|
||||
constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) {
|
||||
this.context = context;
|
||||
this.page = page;
|
||||
this._onPageClose = onPageClose;
|
||||
page.on('console', event => this._console.push(event));
|
||||
page.on('framenavigated', frame => {
|
||||
if (!frame.parentFrame())
|
||||
this._console.length = 0;
|
||||
});
|
||||
page.on('close', () => this._onClose());
|
||||
page.on('filechooser', chooser => this._fileChooser = chooser);
|
||||
page.setDefaultNavigationTimeout(60000);
|
||||
page.setDefaultTimeout(5000);
|
||||
}
|
||||
|
||||
private _onClose() {
|
||||
this._fileChooser = undefined;
|
||||
this._console.length = 0;
|
||||
this._onPageClose(this);
|
||||
}
|
||||
|
||||
async navigate(url: string) {
|
||||
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
// Cap load event to 5 seconds, the page is operational at this point.
|
||||
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
|
||||
}
|
||||
|
||||
async run(callback: (tab: Tab) => Promise<void>, options?: RunOptions): Promise<ToolResult> {
|
||||
try {
|
||||
if (!options?.noClearFileChooser)
|
||||
this._fileChooser = undefined;
|
||||
if (options?.waitForCompletion)
|
||||
await waitForCompletion(this.page, () => callback(this));
|
||||
else
|
||||
await callback(this);
|
||||
} finally {
|
||||
if (options?.captureSnapshot)
|
||||
this._snapshot = await PageSnapshot.create(this.page);
|
||||
}
|
||||
const tabList = this.context.tabs().length > 1 ? await this.context.listTabs() + '\n\nCurrent tab:' + '\n' : '';
|
||||
const snapshot = this._snapshot?.text({ status: options?.status, hasFileChooser: !!this._fileChooser }) ?? options?.status ?? '';
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: tabList + snapshot,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
async runAndWait(callback: (tab: Tab) => Promise<void>, options?: RunOptions): Promise<ToolResult> {
|
||||
return await this.run(callback, {
|
||||
waitForCompletion: true,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
async runAndWaitWithSnapshot(callback: (tab: Tab) => Promise<void>, options?: RunOptions): Promise<ToolResult> {
|
||||
return await this.run(callback, {
|
||||
captureSnapshot: true,
|
||||
waitForCompletion: true,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
lastSnapshot(): PageSnapshot {
|
||||
if (!this._snapshot)
|
||||
throw new Error('No snapshot available');
|
||||
return this._snapshot;
|
||||
}
|
||||
|
||||
async console(): Promise<playwright.ConsoleMessage[]> {
|
||||
return this._console;
|
||||
}
|
||||
|
||||
async submitFileChooser(paths: string[]) {
|
||||
if (!this._fileChooser)
|
||||
throw new Error('No file chooser visible');
|
||||
await this._fileChooser.setFiles(paths);
|
||||
this._fileChooser = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
class PageSnapshot {
|
||||
private _frameLocators: PageOrFrameLocator[] = [];
|
||||
private _text!: string;
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
static async create(page: playwright.Page): Promise<PageSnapshot> {
|
||||
const snapshot = new PageSnapshot();
|
||||
await snapshot._build(page);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
text(options?: { status?: string, hasFileChooser?: boolean }): string {
|
||||
const results: string[] = [];
|
||||
if (options?.status) {
|
||||
results.push(options.status);
|
||||
results.push('');
|
||||
}
|
||||
if (options?.hasFileChooser) {
|
||||
results.push('- There is a file chooser visible that requires browser_file_upload to be called');
|
||||
results.push('');
|
||||
}
|
||||
results.push(this._text);
|
||||
return results.join('\n');
|
||||
}
|
||||
|
||||
private async _build(page: playwright.Page) {
|
||||
const yamlDocument = await this._snapshotFrame(page);
|
||||
const lines = [];
|
||||
lines.push(
|
||||
`- Page URL: ${page.url()}`,
|
||||
`- Page Title: ${await page.title()}`
|
||||
);
|
||||
lines.push(
|
||||
`- Page Snapshot`,
|
||||
'```yaml',
|
||||
yamlDocument.toString().trim(),
|
||||
'```',
|
||||
''
|
||||
);
|
||||
this._text = lines.join('\n');
|
||||
}
|
||||
|
||||
private async _snapshotFrame(frame: playwright.Page | playwright.FrameLocator) {
|
||||
const frameIndex = this._frameLocators.push(frame) - 1;
|
||||
const snapshotString = await frame.locator('body').ariaSnapshot({ ref: true });
|
||||
const snapshot = yaml.parseDocument(snapshotString);
|
||||
|
||||
const visit = async (node: any): Promise<unknown> => {
|
||||
if (yaml.isPair(node)) {
|
||||
await Promise.all([
|
||||
visit(node.key).then(k => node.key = k),
|
||||
visit(node.value).then(v => node.value = v)
|
||||
]);
|
||||
} else if (yaml.isSeq(node) || yaml.isMap(node)) {
|
||||
node.items = await Promise.all(node.items.map(visit));
|
||||
} else if (yaml.isScalar(node)) {
|
||||
if (typeof node.value === 'string') {
|
||||
const value = node.value;
|
||||
if (frameIndex > 0)
|
||||
node.value = value.replace('[ref=', `[ref=f${frameIndex}`);
|
||||
if (value.startsWith('iframe ')) {
|
||||
const ref = value.match(/\[ref=(.*)\]/)?.[1];
|
||||
if (ref) {
|
||||
try {
|
||||
const childSnapshot = await this._snapshotFrame(frame.frameLocator(`aria-ref=${ref}`));
|
||||
return snapshot.createPair(node.value, childSnapshot);
|
||||
} catch (error) {
|
||||
return snapshot.createPair(node.value, '<could not take iframe snapshot>');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
await visit(snapshot.contents);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
refLocator(ref: string): playwright.Locator {
|
||||
let frame = this._frameLocators[0];
|
||||
const match = ref.match(/^f(\d+)(.*)/);
|
||||
if (match) {
|
||||
const frameIndex = parseInt(match[1], 10);
|
||||
frame = this._frameLocators[frameIndex];
|
||||
ref = match[2];
|
||||
}
|
||||
|
||||
if (!frame)
|
||||
throw new Error(`Frame does not exist. Provide ref from the most current snapshot.`);
|
||||
|
||||
return frame.locator(`aria-ref=${ref}`);
|
||||
}
|
||||
}
|
||||
84
src/index.ts
84
src/index.ts
@@ -1,84 +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 { createServerWithTools } from './server';
|
||||
import common from './tools/common';
|
||||
import files from './tools/files';
|
||||
import install from './tools/install';
|
||||
import keyboard from './tools/keyboard';
|
||||
import navigate from './tools/navigate';
|
||||
import pdf from './tools/pdf';
|
||||
import snapshot from './tools/snapshot';
|
||||
import tabs from './tools/tabs';
|
||||
import screen from './tools/screen';
|
||||
import { console as consoleResource } from './resources/console';
|
||||
|
||||
import type { Tool, ToolCapability } from './tools/tool';
|
||||
import type { Resource } from './resources/resource';
|
||||
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import type { LaunchOptions } from 'playwright';
|
||||
|
||||
const snapshotTools: Tool[] = [
|
||||
...common,
|
||||
...files(true),
|
||||
...install,
|
||||
...keyboard(true),
|
||||
...navigate(true),
|
||||
...pdf,
|
||||
...snapshot,
|
||||
...tabs(true),
|
||||
];
|
||||
|
||||
const screenshotTools: Tool[] = [
|
||||
...common,
|
||||
...files(false),
|
||||
...install,
|
||||
...keyboard(false),
|
||||
...navigate(false),
|
||||
...pdf,
|
||||
...screen,
|
||||
...tabs(false),
|
||||
];
|
||||
|
||||
const resources: Resource[] = [
|
||||
consoleResource,
|
||||
];
|
||||
|
||||
type Options = {
|
||||
browserName?: 'chromium' | 'firefox' | 'webkit';
|
||||
userDataDir?: string;
|
||||
launchOptions?: LaunchOptions;
|
||||
cdpEndpoint?: string;
|
||||
vision?: boolean;
|
||||
capabilities?: ToolCapability[];
|
||||
};
|
||||
|
||||
const packageJSON = require('../package.json');
|
||||
|
||||
export function createServer(options?: Options): Server {
|
||||
const allTools = options?.vision ? screenshotTools : snapshotTools;
|
||||
const tools = allTools.filter(tool => !options?.capabilities || tool.capability === 'core' || options.capabilities.includes(tool.capability));
|
||||
return createServerWithTools({
|
||||
name: 'Playwright',
|
||||
version: packageJSON.version,
|
||||
tools,
|
||||
resources,
|
||||
browserName: options?.browserName,
|
||||
userDataDir: options?.userDataDir ?? '',
|
||||
launchOptions: options?.launchOptions,
|
||||
cdpEndpoint: options?.cdpEndpoint,
|
||||
});
|
||||
}
|
||||
186
src/program.ts
186
src/program.ts
@@ -1,186 +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 http from 'http';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { program } from 'commander';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
|
||||
|
||||
import { createServer } from './index';
|
||||
import { ServerList } from './server';
|
||||
|
||||
import type { LaunchOptions } from 'playwright';
|
||||
import assert from 'assert';
|
||||
import { ToolCapability } from './tools/tool';
|
||||
|
||||
const packageJSON = require('../package.json');
|
||||
|
||||
program
|
||||
.version('Version ' + packageJSON.version)
|
||||
.name(packageJSON.name)
|
||||
.option('--browser <browser>', 'Browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
|
||||
.option('--caps <caps>', 'Comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
|
||||
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
|
||||
.option('--executable-path <path>', 'Path to the browser executable.')
|
||||
.option('--headless', 'Run browser in headless mode, headed by default')
|
||||
.option('--port <port>', 'Port to listen on for SSE transport.')
|
||||
.option('--user-data-dir <path>', 'Path to the user data directory')
|
||||
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
|
||||
.action(async options => {
|
||||
let browserName: 'chromium' | 'firefox' | 'webkit';
|
||||
let channel: string | undefined;
|
||||
switch (options.browser) {
|
||||
case 'chrome':
|
||||
case 'chrome-beta':
|
||||
case 'chrome-canary':
|
||||
case 'chrome-dev':
|
||||
case 'msedge':
|
||||
case 'msedge-beta':
|
||||
case 'msedge-canary':
|
||||
case 'msedge-dev':
|
||||
browserName = 'chromium';
|
||||
channel = options.browser;
|
||||
break;
|
||||
case 'chromium':
|
||||
browserName = 'chromium';
|
||||
break;
|
||||
case 'firefox':
|
||||
browserName = 'firefox';
|
||||
break;
|
||||
case 'webkit':
|
||||
browserName = 'webkit';
|
||||
break;
|
||||
default:
|
||||
browserName = 'chromium';
|
||||
channel = 'chrome';
|
||||
}
|
||||
|
||||
const launchOptions: LaunchOptions = {
|
||||
headless: !!options.headless,
|
||||
channel,
|
||||
executablePath: options.executablePath,
|
||||
};
|
||||
|
||||
const userDataDir = options.userDataDir ?? await createUserDataDir(browserName);
|
||||
|
||||
const serverList = new ServerList(() => createServer({
|
||||
browserName,
|
||||
userDataDir,
|
||||
launchOptions,
|
||||
vision: !!options.vision,
|
||||
cdpEndpoint: options.cdpEndpoint,
|
||||
capabilities: options.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
|
||||
}));
|
||||
setupExitWatchdog(serverList);
|
||||
|
||||
if (options.port) {
|
||||
startSSEServer(+options.port, serverList);
|
||||
} else {
|
||||
const server = await serverList.create();
|
||||
await server.connect(new StdioServerTransport());
|
||||
}
|
||||
});
|
||||
|
||||
function setupExitWatchdog(serverList: ServerList) {
|
||||
process.stdin.on('close', async () => {
|
||||
setTimeout(() => process.exit(0), 15000);
|
||||
await serverList.closeAll();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
program.parse(process.argv);
|
||||
|
||||
async function createUserDataDir(browserName: 'chromium' | 'firefox' | 'webkit') {
|
||||
let cacheDirectory: string;
|
||||
if (process.platform === 'linux')
|
||||
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
||||
else if (process.platform === 'darwin')
|
||||
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
|
||||
else if (process.platform === 'win32')
|
||||
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
||||
else
|
||||
throw new Error('Unsupported platform: ' + process.platform);
|
||||
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserName}-profile`);
|
||||
await fs.promises.mkdir(result, { recursive: true });
|
||||
return result;
|
||||
}
|
||||
|
||||
async function startSSEServer(port: number, serverList: ServerList) {
|
||||
const sessions = new Map<string, SSEServerTransport>();
|
||||
const httpServer = http.createServer(async (req, res) => {
|
||||
if (req.method === 'POST') {
|
||||
const searchParams = new URL(`http://localhost${req.url}`).searchParams;
|
||||
const sessionId = searchParams.get('sessionId');
|
||||
if (!sessionId) {
|
||||
res.statusCode = 400;
|
||||
res.end('Missing sessionId');
|
||||
return;
|
||||
}
|
||||
const transport = sessions.get(sessionId);
|
||||
if (!transport) {
|
||||
res.statusCode = 404;
|
||||
res.end('Session not found');
|
||||
return;
|
||||
}
|
||||
|
||||
await transport.handlePostMessage(req, res);
|
||||
return;
|
||||
} else if (req.method === 'GET') {
|
||||
const transport = new SSEServerTransport('/sse', res);
|
||||
sessions.set(transport.sessionId, transport);
|
||||
const server = await serverList.create();
|
||||
res.on('close', () => {
|
||||
sessions.delete(transport.sessionId);
|
||||
serverList.close(server).catch(e => console.error(e));
|
||||
});
|
||||
await server.connect(transport);
|
||||
return;
|
||||
} else {
|
||||
res.statusCode = 405;
|
||||
res.end('Method not allowed');
|
||||
}
|
||||
});
|
||||
|
||||
httpServer.listen(port, () => {
|
||||
const address = httpServer.address();
|
||||
assert(address, 'Could not bind server socket');
|
||||
let url: string;
|
||||
if (typeof address === 'string') {
|
||||
url = address;
|
||||
} else {
|
||||
const resolvedPort = address.port;
|
||||
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
|
||||
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
|
||||
resolvedHost = 'localhost';
|
||||
url = `http://${resolvedHost}:${resolvedPort}`;
|
||||
}
|
||||
console.log(`Listening on ${url}`);
|
||||
console.log('Put this in your client config:');
|
||||
console.log(JSON.stringify({
|
||||
'mcpServers': {
|
||||
'playwright': {
|
||||
'url': `${url}/sse`
|
||||
}
|
||||
}
|
||||
}, undefined, 2));
|
||||
});
|
||||
}
|
||||
@@ -1,35 +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 type { Resource } from './resource';
|
||||
|
||||
export const console: Resource = {
|
||||
schema: {
|
||||
uri: 'browser://console',
|
||||
name: 'Page console',
|
||||
mimeType: 'text/plain',
|
||||
},
|
||||
|
||||
read: async (context, uri) => {
|
||||
const messages = await context.currentTab().console();
|
||||
const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n');
|
||||
return [{
|
||||
uri,
|
||||
mimeType: 'text/plain',
|
||||
text: log
|
||||
}];
|
||||
},
|
||||
};
|
||||
114
src/server.ts
114
src/server.ts
@@ -1,114 +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 { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
import { Context } from './context';
|
||||
|
||||
import type { Tool } from './tools/tool';
|
||||
import type { Resource } from './resources/resource';
|
||||
import type { ContextOptions } from './context';
|
||||
|
||||
type Options = ContextOptions & {
|
||||
name: string;
|
||||
version: string;
|
||||
tools: Tool[];
|
||||
resources: Resource[],
|
||||
};
|
||||
|
||||
export function createServerWithTools(options: Options): Server {
|
||||
const { name, version, tools, resources } = options;
|
||||
const context = new Context(options);
|
||||
const server = new Server({ name, version }, {
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: {},
|
||||
}
|
||||
});
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return { tools: tools.map(tool => tool.schema) };
|
||||
});
|
||||
|
||||
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
||||
return { resources: resources.map(resource => resource.schema) };
|
||||
});
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async request => {
|
||||
const tool = tools.find(tool => tool.schema.name === request.params.name);
|
||||
if (!tool) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Tool "${request.params.name}" not found` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await tool.handle(context, request.params.arguments);
|
||||
return result;
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: String(error) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
server.setRequestHandler(ReadResourceRequestSchema, async request => {
|
||||
const resource = resources.find(resource => resource.schema.uri === request.params.uri);
|
||||
if (!resource)
|
||||
return { contents: [] };
|
||||
|
||||
const contents = await resource.read(context, request.params.uri);
|
||||
return { contents };
|
||||
});
|
||||
|
||||
const oldClose = server.close.bind(server);
|
||||
|
||||
server.close = async () => {
|
||||
await oldClose();
|
||||
await context.close();
|
||||
};
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
export class ServerList {
|
||||
private _servers: Server[] = [];
|
||||
private _serverFactory: () => Server;
|
||||
|
||||
constructor(serverFactory: () => Server) {
|
||||
this._serverFactory = serverFactory;
|
||||
}
|
||||
|
||||
async create() {
|
||||
const server = this._serverFactory();
|
||||
this._servers.push(server);
|
||||
return server;
|
||||
}
|
||||
|
||||
async close(server: Server) {
|
||||
const index = this._servers.indexOf(server);
|
||||
if (index !== -1)
|
||||
this._servers.splice(index, 1);
|
||||
await server.close();
|
||||
}
|
||||
|
||||
async closeAll() {
|
||||
await Promise.all(this._servers.map(server => server.close()));
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
import type { Tool } from './tool';
|
||||
|
||||
const waitSchema = z.object({
|
||||
time: z.number().describe('The time to wait in seconds'),
|
||||
});
|
||||
|
||||
const wait: Tool = {
|
||||
capability: 'wait',
|
||||
schema: {
|
||||
name: 'browser_wait',
|
||||
description: 'Wait for a specified time in seconds',
|
||||
inputSchema: zodToJsonSchema(waitSchema),
|
||||
},
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = waitSchema.parse(params);
|
||||
await new Promise(f => setTimeout(f, Math.min(10000, validatedParams.time * 1000)));
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Waited for ${validatedParams.time} seconds`,
|
||||
}],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const closeSchema = z.object({});
|
||||
|
||||
const close: Tool = {
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_close',
|
||||
description: 'Close the page',
|
||||
inputSchema: zodToJsonSchema(closeSchema),
|
||||
},
|
||||
handle: async context => {
|
||||
await context.close();
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Page closed`,
|
||||
}],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default [
|
||||
close,
|
||||
wait,
|
||||
];
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
import type { ToolFactory } from './tool';
|
||||
|
||||
const uploadFileSchema = z.object({
|
||||
paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'),
|
||||
});
|
||||
|
||||
const uploadFile: ToolFactory = captureSnapshot => ({
|
||||
capability: 'files',
|
||||
schema: {
|
||||
name: 'browser_file_upload',
|
||||
description: 'Upload one or multiple files',
|
||||
inputSchema: zodToJsonSchema(uploadFileSchema),
|
||||
},
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = uploadFileSchema.parse(params);
|
||||
const tab = context.currentTab();
|
||||
return await tab.runAndWait(async () => {
|
||||
await tab.submitFileChooser(validatedParams.paths);
|
||||
}, {
|
||||
status: `Chose files ${validatedParams.paths.join(', ')}`,
|
||||
captureSnapshot,
|
||||
noClearFileChooser: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default (captureSnapshot: boolean) => [
|
||||
uploadFile(captureSnapshot),
|
||||
];
|
||||
@@ -1,61 +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 { fork } from 'child_process';
|
||||
import path from 'path';
|
||||
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
import type { Tool } from './tool';
|
||||
|
||||
const install: Tool = {
|
||||
capability: 'install',
|
||||
schema: {
|
||||
name: 'browser_install',
|
||||
description: 'Install the browser specified in the config. Call this if you get an error about the browser not being installed.',
|
||||
inputSchema: zodToJsonSchema(z.object({})),
|
||||
},
|
||||
|
||||
handle: async context => {
|
||||
const channel = context.options.launchOptions?.channel ?? context.options.browserName ?? 'chrome';
|
||||
const cli = path.join(require.resolve('playwright/package.json'), '..', 'cli.js');
|
||||
const child = fork(cli, ['install', channel], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const output: string[] = [];
|
||||
child.stdout?.on('data', data => output.push(data.toString()));
|
||||
child.stderr?.on('data', data => output.push(data.toString()));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.on('close', code => {
|
||||
if (code === 0)
|
||||
resolve();
|
||||
else
|
||||
reject(new Error(`Failed to install browser: ${output.join('')}`));
|
||||
});
|
||||
});
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Browser ${channel} installed`,
|
||||
}],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default [
|
||||
install,
|
||||
];
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import zodToJsonSchema from 'zod-to-json-schema';
|
||||
|
||||
import type { ToolFactory } from './tool';
|
||||
|
||||
const pressKeySchema = z.object({
|
||||
key: z.string().describe('Name of the key to press or a character to generate, such as `ArrowLeft` or `a`'),
|
||||
});
|
||||
|
||||
const pressKey: ToolFactory = captureSnapshot => ({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_press_key',
|
||||
description: 'Press a key on the keyboard',
|
||||
inputSchema: zodToJsonSchema(pressKeySchema),
|
||||
},
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = pressKeySchema.parse(params);
|
||||
return await context.currentTab().runAndWait(async tab => {
|
||||
await tab.page.keyboard.press(validatedParams.key);
|
||||
}, {
|
||||
status: `Pressed key ${validatedParams.key}`,
|
||||
captureSnapshot,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default (captureSnapshot: boolean) => [
|
||||
pressKey(captureSnapshot),
|
||||
];
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
import type { ToolFactory } from './tool';
|
||||
|
||||
const navigateSchema = z.object({
|
||||
url: z.string().describe('The URL to navigate to'),
|
||||
});
|
||||
|
||||
const navigate: ToolFactory = captureSnapshot => ({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_navigate',
|
||||
description: 'Navigate to a URL',
|
||||
inputSchema: zodToJsonSchema(navigateSchema),
|
||||
},
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = navigateSchema.parse(params);
|
||||
const currentTab = await context.ensureTab();
|
||||
return await currentTab.run(async tab => {
|
||||
await tab.navigate(validatedParams.url);
|
||||
}, {
|
||||
status: `Navigated to ${validatedParams.url}`,
|
||||
captureSnapshot,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const goBackSchema = z.object({});
|
||||
|
||||
const goBack: ToolFactory = snapshot => ({
|
||||
capability: 'history',
|
||||
schema: {
|
||||
name: 'browser_navigate_back',
|
||||
description: 'Go back to the previous page',
|
||||
inputSchema: zodToJsonSchema(goBackSchema),
|
||||
},
|
||||
handle: async context => {
|
||||
return await context.currentTab().runAndWait(async tab => {
|
||||
await tab.page.goBack();
|
||||
}, {
|
||||
status: 'Navigated back',
|
||||
captureSnapshot: snapshot,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const goForwardSchema = z.object({});
|
||||
|
||||
const goForward: ToolFactory = snapshot => ({
|
||||
capability: 'history',
|
||||
schema: {
|
||||
name: 'browser_navigate_forward',
|
||||
description: 'Go forward to the next page',
|
||||
inputSchema: zodToJsonSchema(goForwardSchema),
|
||||
},
|
||||
handle: async context => {
|
||||
return await context.currentTab().runAndWait(async tab => {
|
||||
await tab.page.goForward();
|
||||
}, {
|
||||
status: 'Navigated forward',
|
||||
captureSnapshot: snapshot,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default (captureSnapshot: boolean) => [
|
||||
navigate(captureSnapshot),
|
||||
goBack(captureSnapshot),
|
||||
goForward(captureSnapshot),
|
||||
];
|
||||
@@ -1,51 +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 os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
import { sanitizeForFilePath } from './utils';
|
||||
|
||||
import type { Tool } from './tool';
|
||||
|
||||
const pdfSchema = z.object({});
|
||||
|
||||
const pdf: Tool = {
|
||||
capability: 'pdf',
|
||||
schema: {
|
||||
name: 'browser_pdf_save',
|
||||
description: 'Save page as PDF',
|
||||
inputSchema: zodToJsonSchema(pdfSchema),
|
||||
},
|
||||
handle: async context => {
|
||||
const tab = context.currentTab();
|
||||
const fileName = path.join(os.tmpdir(), sanitizeForFilePath(`page-${new Date().toISOString()}`)) + '.pdf';
|
||||
await tab.page.pdf({ path: fileName });
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Saved as ${fileName}`,
|
||||
}],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default [
|
||||
pdf,
|
||||
];
|
||||
@@ -1,150 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
import type { Tool } from './tool';
|
||||
|
||||
const screenshot: Tool = {
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_screen_capture',
|
||||
description: 'Take a screenshot of the current page',
|
||||
inputSchema: zodToJsonSchema(z.object({})),
|
||||
},
|
||||
|
||||
handle: async context => {
|
||||
const tab = context.currentTab();
|
||||
const screenshot = await tab.page.screenshot({ type: 'jpeg', quality: 50, scale: 'css' });
|
||||
return {
|
||||
content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: 'image/jpeg' }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const elementSchema = z.object({
|
||||
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
|
||||
});
|
||||
|
||||
const moveMouseSchema = elementSchema.extend({
|
||||
x: z.number().describe('X coordinate'),
|
||||
y: z.number().describe('Y coordinate'),
|
||||
});
|
||||
|
||||
const moveMouse: Tool = {
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_screen_move_mouse',
|
||||
description: 'Move mouse to a given position',
|
||||
inputSchema: zodToJsonSchema(moveMouseSchema),
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = moveMouseSchema.parse(params);
|
||||
const tab = context.currentTab();
|
||||
await tab.page.mouse.move(validatedParams.x, validatedParams.y);
|
||||
return {
|
||||
content: [{ type: 'text', text: `Moved mouse to (${validatedParams.x}, ${validatedParams.y})` }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const clickSchema = elementSchema.extend({
|
||||
x: z.number().describe('X coordinate'),
|
||||
y: z.number().describe('Y coordinate'),
|
||||
});
|
||||
|
||||
const click: Tool = {
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_screen_click',
|
||||
description: 'Click left mouse button',
|
||||
inputSchema: zodToJsonSchema(clickSchema),
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
return await context.currentTab().runAndWait(async tab => {
|
||||
const validatedParams = clickSchema.parse(params);
|
||||
await tab.page.mouse.move(validatedParams.x, validatedParams.y);
|
||||
await tab.page.mouse.down();
|
||||
await tab.page.mouse.up();
|
||||
}, {
|
||||
status: 'Clicked mouse',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const dragSchema = elementSchema.extend({
|
||||
startX: z.number().describe('Start X coordinate'),
|
||||
startY: z.number().describe('Start Y coordinate'),
|
||||
endX: z.number().describe('End X coordinate'),
|
||||
endY: z.number().describe('End Y coordinate'),
|
||||
});
|
||||
|
||||
const drag: Tool = {
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_screen_drag',
|
||||
description: 'Drag left mouse button',
|
||||
inputSchema: zodToJsonSchema(dragSchema),
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = dragSchema.parse(params);
|
||||
return await context.currentTab().runAndWait(async tab => {
|
||||
await tab.page.mouse.move(validatedParams.startX, validatedParams.startY);
|
||||
await tab.page.mouse.down();
|
||||
await tab.page.mouse.move(validatedParams.endX, validatedParams.endY);
|
||||
await tab.page.mouse.up();
|
||||
}, {
|
||||
status: `Dragged mouse from (${validatedParams.startX}, ${validatedParams.startY}) to (${validatedParams.endX}, ${validatedParams.endY})`,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const typeSchema = z.object({
|
||||
text: z.string().describe('Text to type into the element'),
|
||||
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
|
||||
});
|
||||
|
||||
const type: Tool = {
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_screen_type',
|
||||
description: 'Type text',
|
||||
inputSchema: zodToJsonSchema(typeSchema),
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = typeSchema.parse(params);
|
||||
return await context.currentTab().runAndWait(async tab => {
|
||||
await tab.page.keyboard.type(validatedParams.text);
|
||||
if (validatedParams.submit)
|
||||
await tab.page.keyboard.press('Enter');
|
||||
}, {
|
||||
status: `Typed text "${validatedParams.text}"`,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default [
|
||||
screenshot,
|
||||
moveMouse,
|
||||
click,
|
||||
drag,
|
||||
type,
|
||||
];
|
||||
@@ -1,190 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import zodToJsonSchema from 'zod-to-json-schema';
|
||||
|
||||
import type * as playwright from 'playwright';
|
||||
import type { Tool } from './tool';
|
||||
|
||||
const snapshot: Tool = {
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_snapshot',
|
||||
description: 'Capture accessibility snapshot of the current page, this is better than screenshot',
|
||||
inputSchema: zodToJsonSchema(z.object({})),
|
||||
},
|
||||
|
||||
handle: async context => {
|
||||
return await context.currentTab().run(async () => {}, { captureSnapshot: true });
|
||||
},
|
||||
};
|
||||
|
||||
const elementSchema = z.object({
|
||||
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
|
||||
ref: z.string().describe('Exact target element reference from the page snapshot'),
|
||||
});
|
||||
|
||||
const click: Tool = {
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_click',
|
||||
description: 'Perform click on a web page',
|
||||
inputSchema: zodToJsonSchema(elementSchema),
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = elementSchema.parse(params);
|
||||
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
|
||||
const locator = tab.lastSnapshot().refLocator(validatedParams.ref);
|
||||
await locator.click();
|
||||
}, {
|
||||
status: `Clicked "${validatedParams.element}"`,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const dragSchema = z.object({
|
||||
startElement: z.string().describe('Human-readable source element description used to obtain the permission to interact with the element'),
|
||||
startRef: z.string().describe('Exact source element reference from the page snapshot'),
|
||||
endElement: z.string().describe('Human-readable target element description used to obtain the permission to interact with the element'),
|
||||
endRef: z.string().describe('Exact target element reference from the page snapshot'),
|
||||
});
|
||||
|
||||
const drag: Tool = {
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_drag',
|
||||
description: 'Perform drag and drop between two elements',
|
||||
inputSchema: zodToJsonSchema(dragSchema),
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = dragSchema.parse(params);
|
||||
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
|
||||
const startLocator = tab.lastSnapshot().refLocator(validatedParams.startRef);
|
||||
const endLocator = tab.lastSnapshot().refLocator(validatedParams.endRef);
|
||||
await startLocator.dragTo(endLocator);
|
||||
}, {
|
||||
status: `Dragged "${validatedParams.startElement}" to "${validatedParams.endElement}"`,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const hover: Tool = {
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_hover',
|
||||
description: 'Hover over element on page',
|
||||
inputSchema: zodToJsonSchema(elementSchema),
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = elementSchema.parse(params);
|
||||
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
|
||||
const locator = tab.lastSnapshot().refLocator(validatedParams.ref);
|
||||
await locator.hover();
|
||||
}, {
|
||||
status: `Hovered over "${validatedParams.element}"`,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const typeSchema = elementSchema.extend({
|
||||
text: z.string().describe('Text to type into the element'),
|
||||
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
|
||||
slowly: z.boolean().optional().describe('Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.'),
|
||||
});
|
||||
|
||||
const type: Tool = {
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_type',
|
||||
description: 'Type text into editable element',
|
||||
inputSchema: zodToJsonSchema(typeSchema),
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = typeSchema.parse(params);
|
||||
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
|
||||
const locator = tab.lastSnapshot().refLocator(validatedParams.ref);
|
||||
if (validatedParams.slowly)
|
||||
await locator.pressSequentially(validatedParams.text);
|
||||
else
|
||||
await locator.fill(validatedParams.text);
|
||||
if (validatedParams.submit)
|
||||
await locator.press('Enter');
|
||||
}, {
|
||||
status: `Typed "${validatedParams.text}" into "${validatedParams.element}"`,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const selectOptionSchema = elementSchema.extend({
|
||||
values: z.array(z.string()).describe('Array of values to select in the dropdown. This can be a single value or multiple values.'),
|
||||
});
|
||||
|
||||
const selectOption: Tool = {
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_select_option',
|
||||
description: 'Select an option in a dropdown',
|
||||
inputSchema: zodToJsonSchema(selectOptionSchema),
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = selectOptionSchema.parse(params);
|
||||
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
|
||||
const locator = tab.lastSnapshot().refLocator(validatedParams.ref);
|
||||
await locator.selectOption(validatedParams.values);
|
||||
}, {
|
||||
status: `Selected option in "${validatedParams.element}"`,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const screenshotSchema = z.object({
|
||||
raw: z.boolean().optional().describe('Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.'),
|
||||
});
|
||||
|
||||
const screenshot: Tool = {
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_take_screenshot',
|
||||
description: `Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.`,
|
||||
inputSchema: zodToJsonSchema(screenshotSchema),
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = screenshotSchema.parse(params);
|
||||
const tab = context.currentTab();
|
||||
const options: playwright.PageScreenshotOptions = validatedParams.raw ? { type: 'png', scale: 'css' } : { type: 'jpeg', quality: 50, scale: 'css' };
|
||||
const screenshot = await tab.page.screenshot(options);
|
||||
return {
|
||||
content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: validatedParams.raw ? 'image/png' : 'image/jpeg' }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default [
|
||||
snapshot,
|
||||
click,
|
||||
drag,
|
||||
hover,
|
||||
type,
|
||||
selectOption,
|
||||
screenshot,
|
||||
];
|
||||
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
import type { ToolFactory, Tool } from './tool';
|
||||
|
||||
const listTabs: Tool = {
|
||||
capability: 'tabs',
|
||||
schema: {
|
||||
name: 'browser_tab_list',
|
||||
description: 'List browser tabs',
|
||||
inputSchema: zodToJsonSchema(z.object({})),
|
||||
},
|
||||
handle: async context => {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: await context.listTabs(),
|
||||
}],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const selectTabSchema = z.object({
|
||||
index: z.number().describe('The index of the tab to select'),
|
||||
});
|
||||
|
||||
const selectTab: ToolFactory = captureSnapshot => ({
|
||||
capability: 'tabs',
|
||||
schema: {
|
||||
name: 'browser_tab_select',
|
||||
description: 'Select a tab by index',
|
||||
inputSchema: zodToJsonSchema(selectTabSchema),
|
||||
},
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = selectTabSchema.parse(params);
|
||||
await context.selectTab(validatedParams.index);
|
||||
const currentTab = await context.ensureTab();
|
||||
return await currentTab.run(async () => {}, { captureSnapshot });
|
||||
},
|
||||
});
|
||||
|
||||
const newTabSchema = z.object({
|
||||
url: z.string().optional().describe('The URL to navigate to in the new tab. If not provided, the new tab will be blank.'),
|
||||
});
|
||||
|
||||
const newTab: Tool = {
|
||||
capability: 'tabs',
|
||||
schema: {
|
||||
name: 'browser_tab_new',
|
||||
description: 'Open a new tab',
|
||||
inputSchema: zodToJsonSchema(newTabSchema),
|
||||
},
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = newTabSchema.parse(params);
|
||||
await context.newTab();
|
||||
if (validatedParams.url)
|
||||
await context.currentTab().navigate(validatedParams.url);
|
||||
return await context.currentTab().run(async () => {}, { captureSnapshot: true });
|
||||
},
|
||||
};
|
||||
|
||||
const closeTabSchema = z.object({
|
||||
index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
|
||||
});
|
||||
|
||||
const closeTab: ToolFactory = captureSnapshot => ({
|
||||
capability: 'tabs',
|
||||
schema: {
|
||||
name: 'browser_tab_close',
|
||||
description: 'Close a tab',
|
||||
inputSchema: zodToJsonSchema(closeTabSchema),
|
||||
},
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = closeTabSchema.parse(params);
|
||||
await context.closeTab(validatedParams.index);
|
||||
const currentTab = await context.currentTab();
|
||||
if (currentTab)
|
||||
return await currentTab.run(async () => {}, { captureSnapshot });
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: await context.listTabs(),
|
||||
}],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default (captureSnapshot: boolean) => [
|
||||
listTabs,
|
||||
newTab,
|
||||
selectTab(captureSnapshot),
|
||||
closeTab(captureSnapshot),
|
||||
];
|
||||
@@ -1,40 +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 type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
|
||||
import type { JsonSchema7Type } from 'zod-to-json-schema';
|
||||
import type { Context } from '../context';
|
||||
|
||||
export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install';
|
||||
|
||||
export type ToolSchema = {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: JsonSchema7Type;
|
||||
};
|
||||
|
||||
export type ToolResult = {
|
||||
content: (ImageContent | TextContent)[];
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
export type Tool = {
|
||||
capability: ToolCapability;
|
||||
schema: ToolSchema;
|
||||
handle: (context: Context, params?: Record<string, any>) => Promise<ToolResult>;
|
||||
};
|
||||
|
||||
export type ToolFactory = (snapshot: boolean) => Tool;
|
||||
@@ -1,74 +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 type * as playwright from 'playwright';
|
||||
|
||||
export async function waitForCompletion<R>(page: playwright.Page, callback: () => Promise<R>): Promise<R> {
|
||||
const requests = new Set<playwright.Request>();
|
||||
let frameNavigated = false;
|
||||
let waitCallback: () => void = () => {};
|
||||
const waitBarrier = new Promise<void>(f => { waitCallback = f; });
|
||||
|
||||
const requestListener = (request: playwright.Request) => requests.add(request);
|
||||
const requestFinishedListener = (request: playwright.Request) => {
|
||||
requests.delete(request);
|
||||
if (!requests.size)
|
||||
waitCallback();
|
||||
};
|
||||
|
||||
const frameNavigateListener = (frame: playwright.Frame) => {
|
||||
if (frame.parentFrame())
|
||||
return;
|
||||
frameNavigated = true;
|
||||
dispose();
|
||||
clearTimeout(timeout);
|
||||
void frame.waitForLoadState('load').then(() => {
|
||||
waitCallback();
|
||||
});
|
||||
};
|
||||
|
||||
const onTimeout = () => {
|
||||
dispose();
|
||||
waitCallback();
|
||||
};
|
||||
|
||||
page.on('request', requestListener);
|
||||
page.on('requestfinished', requestFinishedListener);
|
||||
page.on('framenavigated', frameNavigateListener);
|
||||
const timeout = setTimeout(onTimeout, 10000);
|
||||
|
||||
const dispose = () => {
|
||||
page.off('request', requestListener);
|
||||
page.off('requestfinished', requestFinishedListener);
|
||||
page.off('framenavigated', frameNavigateListener);
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await callback();
|
||||
if (!requests.size && !frameNavigated)
|
||||
waitCallback();
|
||||
await waitBarrier;
|
||||
await page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
|
||||
return result;
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeForFilePath(s: string) {
|
||||
return s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('browser_navigate', async ({ client }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
})).toHaveTextContent(`
|
||||
Navigated to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
||||
|
||||
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
||||
- Page Title: Title
|
||||
- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- text: Hello, world!
|
||||
\`\`\`
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
test('browser_click', async ({ client }) => {
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><button>Submit</button></html>',
|
||||
},
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Submit button',
|
||||
ref: 's1e3',
|
||||
},
|
||||
})).toHaveTextContent(`Clicked "Submit button"
|
||||
|
||||
- Page URL: data:text/html,<html><title>Title</title><button>Submit</button></html>
|
||||
- Page Title: Title
|
||||
- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- button "Submit" [ref=s2e3]
|
||||
\`\`\`
|
||||
`);
|
||||
});
|
||||
|
||||
|
||||
test('browser_select_option', async ({ client }) => {
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><select><option value="foo">Foo</option><option value="bar">Bar</option></select></html>',
|
||||
},
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_select_option',
|
||||
arguments: {
|
||||
element: 'Select',
|
||||
ref: 's1e3',
|
||||
values: ['bar'],
|
||||
},
|
||||
})).toHaveTextContent(`Selected option in "Select"
|
||||
|
||||
- Page URL: data:text/html,<html><title>Title</title><select><option value="foo">Foo</option><option value="bar">Bar</option></select></html>
|
||||
- Page Title: Title
|
||||
- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- combobox [ref=s2e3]:
|
||||
- option "Foo" [ref=s2e4]
|
||||
- option "Bar" [selected] [ref=s2e5]
|
||||
\`\`\`
|
||||
`);
|
||||
});
|
||||
|
||||
test('browser_select_option (multiple)', async ({ client }) => {
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><select multiple><option value="foo">Foo</option><option value="bar">Bar</option><option value="baz">Baz</option></select></html>',
|
||||
},
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_select_option',
|
||||
arguments: {
|
||||
element: 'Select',
|
||||
ref: 's1e3',
|
||||
values: ['bar', 'baz'],
|
||||
},
|
||||
})).toHaveTextContent(`Selected option in "Select"
|
||||
|
||||
- Page URL: data:text/html,<html><title>Title</title><select multiple><option value="foo">Foo</option><option value="bar">Bar</option><option value="baz">Baz</option></select></html>
|
||||
- Page Title: Title
|
||||
- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- listbox [ref=s2e3]:
|
||||
- option "Foo" [ref=s2e4]
|
||||
- option "Bar" [selected] [ref=s2e5]
|
||||
- option "Baz" [selected] [ref=s2e6]
|
||||
\`\`\`
|
||||
`);
|
||||
});
|
||||
|
||||
test('browser_file_upload', async ({ client }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><input type="file" /><button>Button</button></html>',
|
||||
},
|
||||
})).toContainTextContent('- textbox [ref=s1e3]');
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Textbox',
|
||||
ref: 's1e3',
|
||||
},
|
||||
})).toContainTextContent('There is a file chooser visible that requires browser_file_upload to be called');
|
||||
|
||||
const filePath = test.info().outputPath('test.txt');
|
||||
await fs.writeFile(filePath, 'Hello, world!');
|
||||
|
||||
{
|
||||
const response = await client.callTool({
|
||||
name: 'browser_file_upload',
|
||||
arguments: {
|
||||
paths: [filePath],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response).not.toContainTextContent('There is a file chooser visible that requires browser_file_upload to be called');
|
||||
expect(response).toContainTextContent('textbox [ref=s3e3]: C:\\fakepath\\test.txt');
|
||||
}
|
||||
|
||||
{
|
||||
const response = await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Textbox',
|
||||
ref: 's3e3',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response).toContainTextContent('There is a file chooser visible that requires browser_file_upload to be called');
|
||||
expect(response).toContainTextContent('button "Button" [ref=s4e4]');
|
||||
}
|
||||
|
||||
{
|
||||
const response = await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Button',
|
||||
ref: 's4e4',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response, 'not submitting browser_file_upload dismisses file chooser').not.toContainTextContent('There is a file chooser visible that requires browser_file_upload to be called');
|
||||
}
|
||||
});
|
||||
|
||||
test('browser_type', async ({ client }) => {
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: `data:text/html,<input type='keypress' onkeypress="console.log('Key pressed:', event.key, ', Text:', event.target.value)"></input>`,
|
||||
},
|
||||
});
|
||||
await client.callTool({
|
||||
name: 'browser_type',
|
||||
arguments: {
|
||||
element: 'textbox',
|
||||
ref: 's1e3',
|
||||
text: 'Hi!',
|
||||
submit: true,
|
||||
},
|
||||
});
|
||||
const resource = await client.readResource({
|
||||
uri: 'browser://console',
|
||||
});
|
||||
expect(resource.contents).toEqual([{
|
||||
uri: 'browser://console',
|
||||
mimeType: 'text/plain',
|
||||
text: '[LOG] Key pressed: Enter , Text: Hi!',
|
||||
}]);
|
||||
});
|
||||
|
||||
test('browser_type (slowly)', async ({ client }) => {
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: `data:text/html,<input type='text' onkeydown="console.log('Key pressed:', event.key, 'Text:', event.target.value)"></input>`,
|
||||
},
|
||||
});
|
||||
await client.callTool({
|
||||
name: 'browser_type',
|
||||
arguments: {
|
||||
element: 'textbox',
|
||||
ref: 's1e3',
|
||||
text: 'Hi!',
|
||||
submit: true,
|
||||
slowly: true,
|
||||
},
|
||||
});
|
||||
const resource = await client.readResource({
|
||||
uri: 'browser://console',
|
||||
});
|
||||
expect(resource.contents).toEqual([{
|
||||
uri: 'browser://console',
|
||||
mimeType: 'text/plain',
|
||||
text: [
|
||||
'[LOG] Key pressed: H Text: ',
|
||||
'[LOG] Key pressed: i Text: H',
|
||||
'[LOG] Key pressed: ! Text: Hi',
|
||||
'[LOG] Key pressed: Enter Text: Hi!',
|
||||
].join('\n'),
|
||||
}]);
|
||||
});
|
||||
@@ -20,73 +20,87 @@ test('test snapshot tool list', async ({ client }) => {
|
||||
const { tools } = await client.listTools();
|
||||
expect(new Set(tools.map(t => t.name))).toEqual(new Set([
|
||||
'browser_click',
|
||||
'browser_console_messages',
|
||||
'browser_drag',
|
||||
'browser_evaluate',
|
||||
'browser_file_upload',
|
||||
'browser_fill_form',
|
||||
'browser_handle_dialog',
|
||||
'browser_hover',
|
||||
'browser_select_option',
|
||||
'browser_type',
|
||||
'browser_close',
|
||||
'browser_install',
|
||||
'browser_navigate_back',
|
||||
'browser_navigate_forward',
|
||||
'browser_navigate',
|
||||
'browser_pdf_save',
|
||||
'browser_network_requests',
|
||||
'browser_press_key',
|
||||
'browser_resize',
|
||||
'browser_snapshot',
|
||||
'browser_tab_close',
|
||||
'browser_tab_list',
|
||||
'browser_tab_new',
|
||||
'browser_tab_select',
|
||||
'browser_tabs',
|
||||
'browser_take_screenshot',
|
||||
'browser_wait',
|
||||
'browser_wait_for',
|
||||
]));
|
||||
});
|
||||
|
||||
test('test vision tool list', async ({ visionClient }) => {
|
||||
const { tools: visionTools } = await visionClient.listTools();
|
||||
expect(new Set(visionTools.map(t => t.name))).toEqual(new Set([
|
||||
'browser_close',
|
||||
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_fill_form',
|
||||
'browser_handle_dialog',
|
||||
'browser_hover',
|
||||
'browser_select_option',
|
||||
'browser_type',
|
||||
'browser_close',
|
||||
'browser_install',
|
||||
'browser_navigate_back',
|
||||
'browser_navigate_forward',
|
||||
'browser_navigate',
|
||||
'browser_pdf_save',
|
||||
'browser_network_requests',
|
||||
'browser_press_key',
|
||||
'browser_screen_capture',
|
||||
'browser_screen_click',
|
||||
'browser_screen_drag',
|
||||
'browser_screen_move_mouse',
|
||||
'browser_screen_type',
|
||||
'browser_tab_close',
|
||||
'browser_tab_list',
|
||||
'browser_tab_new',
|
||||
'browser_tab_select',
|
||||
'browser_wait',
|
||||
'browser_resize',
|
||||
'browser_snapshot',
|
||||
'browser_tabs',
|
||||
'browser_take_screenshot',
|
||||
'browser_wait_for',
|
||||
]));
|
||||
});
|
||||
|
||||
test('test resources list', async ({ client }) => {
|
||||
const { resources } = await client.listResources();
|
||||
expect(resources).toEqual([
|
||||
expect.objectContaining({
|
||||
uri: 'browser://console',
|
||||
mimeType: 'text/plain',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('test capabilities', async ({ startClient }) => {
|
||||
const client = await startClient({
|
||||
args: ['--caps="core"'],
|
||||
test('test capabilities (pdf)', async ({ startClient }) => {
|
||||
const { client } = await startClient({
|
||||
args: ['--caps=pdf'],
|
||||
});
|
||||
const { tools } = await client.listTools();
|
||||
const toolNames = tools.map(t => t.name);
|
||||
expect(toolNames).not.toContain('browser_file_upload');
|
||||
expect(toolNames).not.toContain('browser_pdf_save');
|
||||
expect(toolNames).not.toContain('browser_screen_capture');
|
||||
expect(toolNames).not.toContain('browser_screen_click');
|
||||
expect(toolNames).not.toContain('browser_screen_drag');
|
||||
expect(toolNames).not.toContain('browser_screen_move_mouse');
|
||||
expect(toolNames).not.toContain('browser_screen_type');
|
||||
expect(toolNames).toContain('browser_pdf_save');
|
||||
});
|
||||
|
||||
test('test capabilities (vision)', async ({ startClient }) => {
|
||||
const { client } = await startClient({
|
||||
args: ['--caps=vision'],
|
||||
});
|
||||
const { tools } = await client.listTools();
|
||||
const toolNames = tools.map(t => t.name);
|
||||
expect(toolNames).toContain('browser_mouse_move_xy');
|
||||
expect(toolNames).toContain('browser_mouse_click_xy');
|
||||
expect(toolNames).toContain('browser_mouse_drag_xy');
|
||||
});
|
||||
|
||||
test('support for legacy --vision option', async ({ startClient }) => {
|
||||
const { client } = await startClient({
|
||||
args: ['--vision'],
|
||||
});
|
||||
const { tools } = await client.listTools();
|
||||
const toolNames = tools.map(t => t.name);
|
||||
expect(toolNames).toContain('browser_mouse_move_xy');
|
||||
expect(toolNames).toContain('browser_mouse_click_xy');
|
||||
expect(toolNames).toContain('browser_mouse_drag_xy');
|
||||
});
|
||||
|
||||
@@ -16,28 +16,28 @@
|
||||
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('stitched aria frames', async ({ client }) => {
|
||||
test('browser_click', async ({ client, server, mcpBrowser }) => {
|
||||
server.setContent('/', `
|
||||
<title>Title</title>
|
||||
<button>Submit</button>
|
||||
`, 'text/html');
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: `data:text/html,<h1>Hello</h1><iframe src="data:text/html,<button>World</button><main><iframe src='data:text/html,<p>Nested</p>'></iframe></main>"></iframe><iframe src="data:text/html,<h1>Should be invisible</h1>" style="display: none;"></iframe>`,
|
||||
},
|
||||
})).toContainTextContent(`
|
||||
\`\`\`yaml
|
||||
- heading "Hello" [level=1] [ref=s1e3]
|
||||
- iframe [ref=s1e4]:
|
||||
- button "World" [ref=f1s1e3]
|
||||
- main [ref=f1s1e4]:
|
||||
- iframe [ref=f1s1e5]:
|
||||
- paragraph [ref=f2s1e3]: Nested
|
||||
\`\`\`
|
||||
`);
|
||||
arguments: { url: server.PREFIX },
|
||||
})).toHaveResponse({
|
||||
code: `await page.goto('${server.PREFIX}');`,
|
||||
pageState: expect.stringContaining(`- button \"Submit\" [ref=e2]`),
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'World',
|
||||
ref: 'f1s1e3',
|
||||
element: 'Submit button',
|
||||
ref: 'e2',
|
||||
},
|
||||
})).toContainTextContent('Clicked "World"');
|
||||
})).toHaveResponse({
|
||||
code: `await page.getByRole('button', { name: 'Submit' }).click();`,
|
||||
pageState: expect.stringContaining(`- button "Submit" ${mcpBrowser !== 'webkit' || process.platform === 'linux' ? '[active] ' : ''}[ref=e2]`),
|
||||
});
|
||||
});
|
||||
@@ -16,20 +16,17 @@
|
||||
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('browser://console', async ({ client }) => {
|
||||
await client.callTool({
|
||||
test('browser_navigate', async ({ client, server }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><script>console.log("Hello, world!");console.error("Error"); </script></html>',
|
||||
},
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toHaveResponse({
|
||||
code: `await page.goto('${server.HELLO_WORLD}');`,
|
||||
pageState: `- Page URL: ${server.HELLO_WORLD}
|
||||
- Page Title: Title
|
||||
- Page Snapshot:
|
||||
\`\`\`yaml
|
||||
- generic [active] [ref=e1]: Hello, world!
|
||||
\`\`\``,
|
||||
});
|
||||
|
||||
const resource = await client.readResource({
|
||||
uri: 'browser://console',
|
||||
});
|
||||
expect(resource.contents).toEqual([{
|
||||
uri: 'browser://console',
|
||||
mimeType: 'text/plain',
|
||||
text: '[LOG] Hello, world!\n[ERROR] Error',
|
||||
}]);
|
||||
});
|
||||
@@ -14,50 +14,108 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
import { test as baseTest, expect as baseExpect } from '@playwright/test';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { TestServer } from './testserver/index';
|
||||
|
||||
type Fixtures = {
|
||||
client: Client;
|
||||
visionClient: Client;
|
||||
startClient: (options?: { args?: string[] }) => Promise<Client>;
|
||||
wsEndpoint: string;
|
||||
cdpEndpoint: string;
|
||||
import type { Config } from '../config';
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import type { Stream } from 'stream';
|
||||
|
||||
export type TestOptions = {
|
||||
mcpArgs: string[] | undefined;
|
||||
mcpBrowser: string | undefined;
|
||||
mcpMode: 'docker' | undefined;
|
||||
};
|
||||
|
||||
export const test = baseTest.extend<Fixtures>({
|
||||
type CDPServer = {
|
||||
endpoint: string;
|
||||
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 = {
|
||||
client: Client;
|
||||
startClient: StartClient;
|
||||
wsEndpoint: string;
|
||||
cdpServer: CDPServer;
|
||||
server: TestServer;
|
||||
httpsServer: TestServer;
|
||||
mcpHeadless: boolean;
|
||||
};
|
||||
|
||||
type WorkerFixtures = {
|
||||
_workerServers: { server: TestServer, httpsServer: TestServer };
|
||||
};
|
||||
|
||||
export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>({
|
||||
|
||||
mcpArgs: [undefined, { option: true }],
|
||||
|
||||
client: async ({ startClient }, use) => {
|
||||
await use(await startClient());
|
||||
const { client } = await startClient();
|
||||
await use(client);
|
||||
},
|
||||
|
||||
visionClient: async ({ startClient }, use) => {
|
||||
await use(await startClient({ args: ['--vision'] }));
|
||||
},
|
||||
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode, mcpArgs }, use, testInfo) => {
|
||||
const configDir = path.dirname(test.info().config.configFile!);
|
||||
const clients: Client[] = [];
|
||||
|
||||
startClient: async ({ }, use, testInfo) => {
|
||||
const userDataDir = testInfo.outputPath('user-data-dir');
|
||||
let client: StdioClientTransport | undefined;
|
||||
|
||||
use(async options => {
|
||||
const args = ['--headless', '--user-data-dir', userDataDir];
|
||||
await use(async options => {
|
||||
const args: string[] = mcpArgs ?? [];
|
||||
if (process.env.CI && process.platform === 'linux')
|
||||
args.push('--no-sandbox');
|
||||
if (mcpHeadless)
|
||||
args.push('--headless');
|
||||
if (mcpBrowser)
|
||||
args.push(`--browser=${mcpBrowser}`);
|
||||
if (options?.args)
|
||||
args.push(...options.args);
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
args: [path.join(__dirname, '../cli.js'), ...args],
|
||||
if (options?.config) {
|
||||
const configFile = testInfo.outputPath('config.json');
|
||||
await fs.promises.writeFile(configFile, JSON.stringify(options.config, null, 2));
|
||||
args.push(`--config=${path.relative(configDir, configFile)}`);
|
||||
}
|
||||
|
||||
const client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }, options?.roots ? { capabilities: { roots: {} } } : undefined);
|
||||
if (options?.roots) {
|
||||
client.setRequestHandler(ListRootsRequestSchema, async request => {
|
||||
if (options.rootsResponseDelay)
|
||||
await new Promise(resolve => setTimeout(resolve, options.rootsResponseDelay));
|
||||
return {
|
||||
roots: options.roots,
|
||||
};
|
||||
});
|
||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||
}
|
||||
const { transport, stderr } = await createTransport(args, mcpMode, testInfo.outputPath('ms-playwright'));
|
||||
let stderrBuffer = '';
|
||||
stderr?.on('data', data => {
|
||||
if (process.env.PWMCP_DEBUG)
|
||||
process.stderr.write(data);
|
||||
stderrBuffer += data.toString();
|
||||
});
|
||||
clients.push(client);
|
||||
await client.connect(transport);
|
||||
await client.ping();
|
||||
return client;
|
||||
return { client, stderr: () => stderrBuffer };
|
||||
});
|
||||
|
||||
await client?.close();
|
||||
await Promise.all(clients.map(client => client.close()));
|
||||
},
|
||||
|
||||
wsEndpoint: async ({ }, use) => {
|
||||
@@ -66,58 +124,109 @@ export const test = baseTest.extend<Fixtures>({
|
||||
await browserServer.close();
|
||||
},
|
||||
|
||||
cdpEndpoint: async ({ }, use, testInfo) => {
|
||||
const port = 3200 + (+process.env.TEST_PARALLEL_INDEX!);
|
||||
const browser = await chromium.launchPersistentContext(testInfo.outputPath('user-data-dir'), {
|
||||
channel: 'chrome',
|
||||
args: [`--remote-debugging-port=${port}`],
|
||||
cdpServer: async ({ mcpBrowser }, use, testInfo) => {
|
||||
test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser!), 'CDP is not supported for non-Chromium browsers');
|
||||
|
||||
let browserContext: BrowserContext | undefined;
|
||||
const port = 3200 + test.info().parallelIndex;
|
||||
await use({
|
||||
endpoint: `http://localhost:${port}`,
|
||||
start: async () => {
|
||||
if (browserContext)
|
||||
throw new Error('CDP server already exists');
|
||||
browserContext = await chromium.launchPersistentContext(testInfo.outputPath('cdp-user-data-dir'), {
|
||||
channel: mcpBrowser,
|
||||
headless: true,
|
||||
args: [
|
||||
`--remote-debugging-port=${port}`,
|
||||
],
|
||||
});
|
||||
await use(`http://localhost:${port}`);
|
||||
await browser.close();
|
||||
return browserContext;
|
||||
}
|
||||
});
|
||||
await browserContext?.close();
|
||||
},
|
||||
|
||||
mcpHeadless: async ({ headless }, use) => {
|
||||
await use(headless);
|
||||
},
|
||||
|
||||
mcpBrowser: ['chrome', { option: true }],
|
||||
|
||||
mcpMode: [undefined, { option: true }],
|
||||
|
||||
_workerServers: [async ({ }, use, workerInfo) => {
|
||||
const port = 8907 + workerInfo.workerIndex * 4;
|
||||
const server = await TestServer.create(port);
|
||||
|
||||
const httpsPort = port + 1;
|
||||
const httpsServer = await TestServer.createHTTPS(httpsPort);
|
||||
|
||||
await use({ server, httpsServer });
|
||||
|
||||
await Promise.all([
|
||||
server.stop(),
|
||||
httpsServer.stop(),
|
||||
]);
|
||||
}, { scope: 'worker' }],
|
||||
|
||||
server: async ({ _workerServers }, use) => {
|
||||
_workerServers.server.reset();
|
||||
await use(_workerServers.server);
|
||||
},
|
||||
|
||||
httpsServer: async ({ _workerServers }, use) => {
|
||||
_workerServers.httpsServer.reset();
|
||||
await use(_workerServers.httpsServer);
|
||||
},
|
||||
});
|
||||
|
||||
async function createTransport(args: string[], mcpMode: TestOptions['mcpMode'], profilesDir: string): Promise<{
|
||||
transport: Transport,
|
||||
stderr: Stream | null,
|
||||
}> {
|
||||
if (mcpMode === 'docker') {
|
||||
const dockerArgs = ['run', '--rm', '-i', '--network=host', '-v', `${test.info().project.outputDir}:/app/test-results`];
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'docker',
|
||||
args: [...dockerArgs, 'playwright-mcp-dev:latest', ...args],
|
||||
});
|
||||
return {
|
||||
transport,
|
||||
stderr: transport.stderr,
|
||||
};
|
||||
}
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
args: [path.join(__dirname, '../cli.js'), ...args],
|
||||
cwd: path.dirname(test.info().config.configFile!),
|
||||
stderr: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
DEBUG: 'pw:mcp:test',
|
||||
DEBUG_COLORS: '0',
|
||||
DEBUG_HIDE_DATE: '1',
|
||||
PWMCP_PROFILES_DIR_FOR_TEST: profilesDir,
|
||||
},
|
||||
});
|
||||
return {
|
||||
transport,
|
||||
stderr: transport.stderr!,
|
||||
};
|
||||
}
|
||||
|
||||
type Response = Awaited<ReturnType<Client['callTool']>>;
|
||||
|
||||
export const expect = baseExpect.extend({
|
||||
toHaveTextContent(response: Response, content: string | RegExp) {
|
||||
toHaveResponse(response: Response, object: any) {
|
||||
const parsed = parseResponse(response);
|
||||
const isNot = this.isNot;
|
||||
try {
|
||||
const text = (response.content as any)[0].text;
|
||||
if (typeof content === 'string') {
|
||||
if (isNot)
|
||||
baseExpect(text.trim()).not.toBe(content.trim());
|
||||
expect(parsed).not.toEqual(expect.objectContaining(object));
|
||||
else
|
||||
baseExpect(text.trim()).toBe(content.trim());
|
||||
} else {
|
||||
if (isNot)
|
||||
baseExpect(text).not.toMatch(content);
|
||||
else
|
||||
baseExpect(text).toMatch(content);
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
pass: isNot,
|
||||
message: () => e.message,
|
||||
};
|
||||
}
|
||||
return {
|
||||
pass: !isNot,
|
||||
message: () => ``,
|
||||
};
|
||||
},
|
||||
|
||||
toContainTextContent(response: Response, content: string | string[]) {
|
||||
const isNot = this.isNot;
|
||||
try {
|
||||
content = Array.isArray(content) ? content : [content];
|
||||
const texts = (response.content as any).map(c => c.text);
|
||||
for (let i = 0; i < texts.length; i++) {
|
||||
if (isNot)
|
||||
expect(texts[i]).not.toContain(content[i]);
|
||||
else
|
||||
expect(texts[i]).toContain(content[i]);
|
||||
}
|
||||
expect(parsed).toEqual(expect.objectContaining(object));
|
||||
} catch (e) {
|
||||
return {
|
||||
pass: isNot,
|
||||
@@ -130,3 +239,52 @@ export const expect = baseExpect.extend({
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export function formatOutput(output: string): string[] {
|
||||
return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/user data dir.*/, 'user data dir').trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function parseResponse(response: any) {
|
||||
const text = response.content[0].text;
|
||||
const sections = parseSections(text);
|
||||
|
||||
const result = sections.get('Result');
|
||||
const code = sections.get('Ran Playwright code');
|
||||
const tabs = sections.get('Open tabs');
|
||||
const pageState = sections.get('Page state');
|
||||
const consoleMessages = sections.get('New console messages');
|
||||
const modalState = sections.get('Modal state');
|
||||
const downloads = sections.get('Downloads');
|
||||
const codeNoFrame = code?.replace(/^```js\n/, '').replace(/\n```$/, '');
|
||||
const isError = response.isError;
|
||||
const attachments = response.content.slice(1);
|
||||
|
||||
return {
|
||||
result,
|
||||
code: codeNoFrame,
|
||||
tabs,
|
||||
pageState,
|
||||
consoleMessages,
|
||||
modalState,
|
||||
downloads,
|
||||
isError,
|
||||
attachments,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSections(text: string): Map<string, string> {
|
||||
const sections = new Map<string, string>();
|
||||
const sectionHeaders = text.split(/^### /m).slice(1); // Remove empty first element
|
||||
|
||||
for (const section of sectionHeaders) {
|
||||
const firstNewlineIndex = section.indexOf('\n');
|
||||
if (firstNewlineIndex === -1)
|
||||
continue;
|
||||
|
||||
const sectionName = section.substring(0, firstNewlineIndex);
|
||||
const sectionContent = section.substring(firstNewlineIndex + 1).trim();
|
||||
sections.set(sectionName, sectionContent);
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
@@ -1,57 +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 { test, expect } from './fixtures';
|
||||
|
||||
test('test reopen browser', async ({ client }) => {
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_close',
|
||||
})).toHaveTextContent('Page closed');
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
})).toHaveTextContent(`
|
||||
Navigated to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
||||
|
||||
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
||||
- Page Title: Title
|
||||
- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- text: Hello, world!
|
||||
\`\`\`
|
||||
`);
|
||||
});
|
||||
|
||||
test('executable path', async ({ startClient }) => {
|
||||
const client = await startClient({ args: [`--executable-path=bogus`] });
|
||||
const response = await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
});
|
||||
expect(response).toContainTextContent(`executable doesn't exist`);
|
||||
});
|
||||
@@ -13,25 +13,16 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import child_process from 'child_process';
|
||||
import fs from 'fs/promises';
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('cdp server', async ({ cdpEndpoint, startClient }) => {
|
||||
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
})).toHaveTextContent(`
|
||||
Navigated to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
||||
|
||||
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
||||
- Page Title: Title
|
||||
- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- text: Hello, world!
|
||||
\`\`\`
|
||||
`
|
||||
);
|
||||
test('library can be used from CommonJS', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/456' } }, async ({}, testInfo) => {
|
||||
const file = testInfo.outputPath('main.cjs');
|
||||
await fs.writeFile(file, `
|
||||
import('@playwright/mcp')
|
||||
.then(playwrightMCP => playwrightMCP.createConnection())
|
||||
.then(() => console.log('OK'));
|
||||
`);
|
||||
expect(child_process.execSync(`node ${file}`, { encoding: 'utf-8' })).toContain('OK');
|
||||
});
|
||||
@@ -1,55 +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 { test, expect } from './fixtures';
|
||||
|
||||
test('save as pdf unavailable', async ({ startClient }) => {
|
||||
const client = await startClient({ args: ['--caps="no-pdf"'] });
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_pdf_save',
|
||||
})).toHaveTextContent(/Tool \"browser_pdf_save\" not found/);
|
||||
});
|
||||
|
||||
test('save as pdf', async ({ client }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
})).toHaveTextContent(`
|
||||
Navigated to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
||||
|
||||
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
||||
- Page Title: Title
|
||||
- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- text: Hello, world!
|
||||
\`\`\`
|
||||
`
|
||||
);
|
||||
|
||||
const response = await client.callTool({
|
||||
name: 'browser_pdf_save',
|
||||
});
|
||||
expect(response).toHaveTextContent(/^Saved as.*page-[^:]+.pdf$/);
|
||||
});
|
||||
@@ -1,42 +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 { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { test } from './fixtures';
|
||||
|
||||
test('sse transport', async () => {
|
||||
const cp = spawn('node', [path.join(__dirname, '../cli.js'), '--port', '0'], { stdio: 'pipe' });
|
||||
try {
|
||||
let stdout = '';
|
||||
const url = await new Promise<string>(resolve => cp.stdout?.on('data', data => {
|
||||
stdout += data.toString();
|
||||
const match = stdout.match(/Listening on (http:\/\/.*)/);
|
||||
if (match)
|
||||
resolve(match[1]);
|
||||
}));
|
||||
|
||||
// need dynamic import b/c of some ESM nonsense
|
||||
const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js');
|
||||
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
|
||||
const transport = new SSEClientTransport(new URL(url));
|
||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
await client.ping();
|
||||
} finally {
|
||||
cp.kill();
|
||||
}
|
||||
});
|
||||
@@ -1,121 +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 { chromium } from 'playwright';
|
||||
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
|
||||
async function createTab(client: Client, title: string, body: string) {
|
||||
return await client.callTool({
|
||||
name: 'browser_tab_new',
|
||||
arguments: {
|
||||
url: `data:text/html,<title>${title}</title><body>${body}</body>`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('create new tab', async ({ client }) => {
|
||||
expect(await createTab(client, 'Tab one', 'Body one')).toHaveTextContent(`
|
||||
Open tabs:
|
||||
- 1: [] (about:blank)
|
||||
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||
|
||||
Current tab:
|
||||
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
||||
- Page Title: Tab one
|
||||
- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- text: Body one
|
||||
\`\`\``);
|
||||
|
||||
expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(`
|
||||
Open tabs:
|
||||
- 1: [] (about:blank)
|
||||
- 2: [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||
- 3: (current) [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
|
||||
|
||||
Current tab:
|
||||
- Page URL: data:text/html,<title>Tab two</title><body>Body two</body>
|
||||
- Page Title: Tab two
|
||||
- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- text: Body two
|
||||
\`\`\``);
|
||||
});
|
||||
|
||||
test('select tab', async ({ client }) => {
|
||||
await createTab(client, 'Tab one', 'Body one');
|
||||
await createTab(client, 'Tab two', 'Body two');
|
||||
expect(await client.callTool({
|
||||
name: 'browser_tab_select',
|
||||
arguments: {
|
||||
index: 2,
|
||||
},
|
||||
})).toHaveTextContent(`
|
||||
Open tabs:
|
||||
- 1: [] (about:blank)
|
||||
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||
- 3: [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
|
||||
|
||||
Current tab:
|
||||
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
||||
- Page Title: Tab one
|
||||
- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- text: Body one
|
||||
\`\`\``);
|
||||
});
|
||||
|
||||
test('close tab', async ({ client }) => {
|
||||
await createTab(client, 'Tab one', 'Body one');
|
||||
await createTab(client, 'Tab two', 'Body two');
|
||||
expect(await client.callTool({
|
||||
name: 'browser_tab_close',
|
||||
arguments: {
|
||||
index: 3,
|
||||
},
|
||||
})).toHaveTextContent(`
|
||||
Open tabs:
|
||||
- 1: [] (about:blank)
|
||||
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||
|
||||
Current tab:
|
||||
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
||||
- Page Title: Tab one
|
||||
- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- text: Body one
|
||||
\`\`\``);
|
||||
});
|
||||
|
||||
test('reuse first tab when navigating', async ({ startClient, cdpEndpoint }) => {
|
||||
const browser = await chromium.connectOverCDP(cdpEndpoint);
|
||||
const [context] = browser.contexts();
|
||||
const pages = context.pages();
|
||||
|
||||
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<title>Title</title><body>Body</body>',
|
||||
},
|
||||
});
|
||||
|
||||
expect(pages.length).toBe(1);
|
||||
expect(await pages[0].title()).toBe('Title');
|
||||
});
|
||||
29
tests/testserver/cert.pem
Normal file
29
tests/testserver/cert.pem
Normal file
@@ -0,0 +1,29 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFCjCCAvKgAwIBAgIULU/gkDm8IqC7PG8u3RID0AYyP6gwDQYJKoZIhvcNAQEL
|
||||
BQAwGjEYMBYGA1UEAwwPcGxheXdyaWdodC10ZXN0MB4XDTIzMDgxMDIyNTc1MFoX
|
||||
DTMzMDgwNzIyNTc1MFowGjEYMBYGA1UEAwwPcGxheXdyaWdodC10ZXN0MIICIjAN
|
||||
BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArbS99qjKcnHr5G0Zc2xhDaOZnjQv
|
||||
Fbiqxf/nbXt/7WaqryzpVKu7AT1ainBvuPEo7If9DhVnfF//2pGl0gbU31OU4/mr
|
||||
ymQmczGEyZvOBDsZhtCif54o5OoO0BjhODNT8OWec9RT87n6RkH58MHlOi8xsPxQ
|
||||
9n5U1CN/h2DyQF3aRKunEFCgtwPKWSjG+J/TAI9i0aSENXPiR8wjTrjg79s8Ehuj
|
||||
NN8Wk6rKLU3sepG3GIMID5vLsVa2t9xqn562sP95Ee+Xp2YX3z7oYK99QCJdzacw
|
||||
alhMHob1GCEKjDyxsD2IFRi7Dysiutfyzy3pMo6NALxFrwKVhWX0L4zVFIsI6JlV
|
||||
dK8dHmDk0MRSqgB9sWXvEfSTXADEe8rncFSFpFz4Z8RNLmn5YSzQJzokNn41DUCP
|
||||
dZTlTkcGTqvn5NqoY4sOV8rkFbgmTcqyijV/sebPjxCbJNcNmaSWa9FJ5IjRTpzM
|
||||
38wLmxn+eKGK68n2JB3P7JP6LtsBShQEpXAF3rFfyNsP1bjquvGZVSjV8w/UwPE4
|
||||
kV5eq3j3D4913Zfxvzjp6PEmhStG0EQtIXvx/TRoYpaNWypIgZdbkZQp1HUIQL15
|
||||
D2Web4nazP3so1FC3ZgbrJZ2ozoadjLMp49NcSFdh+WRyVKuo0DIqR0zaiAzzf2D
|
||||
G1q7TLKimM3XBMUCAwEAAaNIMEYwCQYDVR0TBAIwADALBgNVHQ8EBAMCBeAwLAYD
|
||||
VR0RBCUwI4IJbG9jYWxob3N0hwR/AAABhxAAAAAAAAAAAAAAAAAAAAABMA0GCSqG
|
||||
SIb3DQEBCwUAA4ICAQAvC5M1JFc21WVSLPvE2iVbt4HmirO3EENdDqs+rTYG5VJG
|
||||
iE5ZuI6h/LjS5ptTfKovXQKaMr3pwp1pLMd/9q+6ZR1Hs9Z2wF6OZan4sb0uT32Y
|
||||
1KGlj86QMiiSLdrJ/1Z9JHskHYNCep1ZTsUhGk0qqiNv+G3K2y7ZpvrT/xlnYMth
|
||||
KLTuSVUwM8BBEPrCRLoXuaEy0LnvMvMVepIfP8tnMIL6zqmj3hXMPe4r4OFV/C5o
|
||||
XX25bC7GyuPWIRYn2OWP92J1CODZD1rGRoDtmvqrQpHdeX9RYcKH0ZLZoIf5L3Hf
|
||||
pPUtVkw3QGtjvKeG3b9usxaV9Od2Z08vKKk1PRkXFe8gqaeyicK7YVIOMTSuspAf
|
||||
JeJEHns6Hg61Exbo7GwdX76xlmQ/Z43E9BPHKgLyZ9WuJ0cysqN4aCyvS9yws9to
|
||||
ki7iMZqJUsmE2o09n9VaEsX6uQANZtLjI9wf+IgJuueDTNrkzQkhU7pbaPMsSG40
|
||||
AgGY/y4BR0H8sbhNnhqtZH7RcXV9VCJoPBAe+YiuXRiXyZHWxwBRyBE3e7g4MKHg
|
||||
hrWtaWUAs7gbavHwjqgU63iVItDSk7t4fCiEyObjK09AaNf2DjjaSGf8YGza4bNy
|
||||
BjYinYJ6/eX//gp+abqfocFbBP7D9zRDgMIbVmX/Ey6TghKiLkZOdbzcpO4Wgg==
|
||||
-----END CERTIFICATE-----
|
||||
168
tests/testserver/index.ts
Normal file
168
tests/testserver/index.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import http from 'http';
|
||||
import https from 'https';
|
||||
import path from 'path';
|
||||
import debug from 'debug';
|
||||
|
||||
const fulfillSymbol = Symbol('fulfil callback');
|
||||
const rejectSymbol = Symbol('reject callback');
|
||||
|
||||
export class TestServer {
|
||||
private _server: http.Server;
|
||||
readonly debugServer: any;
|
||||
private _routes = new Map<string, (request: http.IncomingMessage, response: http.ServerResponse) => any>();
|
||||
private _csp = new Map<string, string>();
|
||||
private _extraHeaders = new Map<string, object>();
|
||||
private _requestSubscribers = new Map<string, Promise<any>>();
|
||||
readonly PORT: number;
|
||||
readonly PREFIX: string;
|
||||
readonly CROSS_PROCESS_PREFIX: string;
|
||||
readonly HELLO_WORLD: string;
|
||||
|
||||
static async create(port: number): Promise<TestServer> {
|
||||
const server = new TestServer(port);
|
||||
await new Promise(x => server._server.once('listening', x));
|
||||
return server;
|
||||
}
|
||||
|
||||
static async createHTTPS(port: number): Promise<TestServer> {
|
||||
const server = new TestServer(port, {
|
||||
key: await fs.promises.readFile(path.join(path.dirname(__filename), 'key.pem')),
|
||||
cert: await fs.promises.readFile(path.join(path.dirname(__filename), 'cert.pem')),
|
||||
passphrase: 'aaaa',
|
||||
});
|
||||
await new Promise(x => server._server.once('listening', x));
|
||||
return server;
|
||||
}
|
||||
|
||||
constructor(port: number, sslOptions?: object) {
|
||||
if (sslOptions)
|
||||
this._server = https.createServer(sslOptions, this._onRequest.bind(this));
|
||||
else
|
||||
this._server = http.createServer(this._onRequest.bind(this));
|
||||
this._server.listen(port);
|
||||
this.debugServer = debug('pw:testserver');
|
||||
|
||||
const cross_origin = '127.0.0.1';
|
||||
const same_origin = 'localhost';
|
||||
const protocol = sslOptions ? 'https' : 'http';
|
||||
this.PORT = port;
|
||||
this.PREFIX = `${protocol}://${same_origin}:${port}/`;
|
||||
this.CROSS_PROCESS_PREFIX = `${protocol}://${cross_origin}:${port}/`;
|
||||
this.HELLO_WORLD = `${this.PREFIX}hello-world`;
|
||||
}
|
||||
|
||||
setCSP(path: string, csp: string) {
|
||||
this._csp.set(path, csp);
|
||||
}
|
||||
|
||||
setExtraHeaders(path: string, object: Record<string, string>) {
|
||||
this._extraHeaders.set(path, object);
|
||||
}
|
||||
|
||||
async stop() {
|
||||
this.reset();
|
||||
await new Promise(x => this._server.close(x));
|
||||
}
|
||||
|
||||
route(path: string, handler: (request: http.IncomingMessage, response: http.ServerResponse) => any) {
|
||||
this._routes.set(path, handler);
|
||||
}
|
||||
|
||||
setContent(path: string, content: string, mimeType: string) {
|
||||
this.route(path, (req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': mimeType });
|
||||
res.end(mimeType === 'text/html' ? `<!DOCTYPE html>${content}` : content);
|
||||
});
|
||||
}
|
||||
|
||||
redirect(from: string, to: string) {
|
||||
this.route(from, (req, res) => {
|
||||
const headers = this._extraHeaders.get(req.url!) || {};
|
||||
res.writeHead(302, { ...headers, location: to });
|
||||
res.end();
|
||||
});
|
||||
}
|
||||
|
||||
waitForRequest(path: string): Promise<http.IncomingMessage> {
|
||||
let promise = this._requestSubscribers.get(path);
|
||||
if (promise)
|
||||
return promise;
|
||||
let fulfill, reject;
|
||||
promise = new Promise((f, r) => {
|
||||
fulfill = f;
|
||||
reject = r;
|
||||
});
|
||||
promise[fulfillSymbol] = fulfill;
|
||||
promise[rejectSymbol] = reject;
|
||||
this._requestSubscribers.set(path, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this._routes.clear();
|
||||
this._csp.clear();
|
||||
this._extraHeaders.clear();
|
||||
this._server.closeAllConnections();
|
||||
const error = new Error('Static Server has been reset');
|
||||
for (const subscriber of this._requestSubscribers.values())
|
||||
subscriber[rejectSymbol].call(null, error);
|
||||
this._requestSubscribers.clear();
|
||||
|
||||
this.setContent('/favicon.ico', '', 'image/x-icon');
|
||||
|
||||
this.setContent('/', ``, 'text/html');
|
||||
|
||||
this.setContent('/hello-world', `
|
||||
<title>Title</title>
|
||||
<body>Hello, world!</body>
|
||||
`, 'text/html');
|
||||
}
|
||||
|
||||
_onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
|
||||
request.on('error', error => {
|
||||
if ((error as any).code === 'ECONNRESET')
|
||||
response.end();
|
||||
else
|
||||
throw error;
|
||||
});
|
||||
(request as any).postBody = new Promise(resolve => {
|
||||
const chunks: Buffer[] = [];
|
||||
request.on('data', chunk => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
request.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
const path = request.url || '/';
|
||||
this.debugServer(`request ${request.method} ${path}`);
|
||||
// Notify request subscriber.
|
||||
if (this._requestSubscribers.has(path)) {
|
||||
this._requestSubscribers.get(path)![fulfillSymbol].call(null, request);
|
||||
this._requestSubscribers.delete(path);
|
||||
}
|
||||
const handler = this._routes.get(path);
|
||||
if (handler) {
|
||||
handler.call(null, request, response);
|
||||
} else {
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
52
tests/testserver/key.pem
Normal file
52
tests/testserver/key.pem
Normal file
@@ -0,0 +1,52 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCttL32qMpycevk
|
||||
bRlzbGENo5meNC8VuKrF/+dte3/tZqqvLOlUq7sBPVqKcG+48Sjsh/0OFWd8X//a
|
||||
kaXSBtTfU5Tj+avKZCZzMYTJm84EOxmG0KJ/nijk6g7QGOE4M1Pw5Z5z1FPzufpG
|
||||
QfnwweU6LzGw/FD2flTUI3+HYPJAXdpEq6cQUKC3A8pZKMb4n9MAj2LRpIQ1c+JH
|
||||
zCNOuODv2zwSG6M03xaTqsotTex6kbcYgwgPm8uxVra33Gqfnraw/3kR75enZhff
|
||||
Puhgr31AIl3NpzBqWEwehvUYIQqMPLGwPYgVGLsPKyK61/LPLekyjo0AvEWvApWF
|
||||
ZfQvjNUUiwjomVV0rx0eYOTQxFKqAH2xZe8R9JNcAMR7yudwVIWkXPhnxE0uaflh
|
||||
LNAnOiQ2fjUNQI91lOVORwZOq+fk2qhjiw5XyuQVuCZNyrKKNX+x5s+PEJsk1w2Z
|
||||
pJZr0UnkiNFOnMzfzAubGf54oYrryfYkHc/sk/ou2wFKFASlcAXesV/I2w/VuOq6
|
||||
8ZlVKNXzD9TA8TiRXl6rePcPj3Xdl/G/OOno8SaFK0bQRC0he/H9NGhilo1bKkiB
|
||||
l1uRlCnUdQhAvXkPZZ5vidrM/eyjUULdmBuslnajOhp2Msynj01xIV2H5ZHJUq6j
|
||||
QMipHTNqIDPN/YMbWrtMsqKYzdcExQIDAQABAoICAGqXttpdyZ1g+vg5WpzRrNzJ
|
||||
v8KtExepMmI+Hq24U1BC6AqG7MfgeejQ1XaOeIBsvEgpSsgRqmdQIZjmN3Mibg59
|
||||
I6ih1SFlQ5L8mBd/XHSML6Xi8VSOoVmXp29bVRk/pgr1XL6HVN0DCumCIvXyhc+m
|
||||
lj+dFbGs5DEpd2CDxSRqcz4gd2wzjevAj7MWqsJ2kOyPEHzFD7wdWIXmZuQv3xhQ
|
||||
2BPkkcon+5qx+07BupOcR1brUU8Cs4QnSgiZYXSB2GnU215+P/mhVJTR7ZcnGRz5
|
||||
+cXxCmy3sj4pYs1juS1FMWSM3azUeDVeqvks+vrXmXpEr5H79mbmlwo8/hMPwNDO
|
||||
07HRZwa8T01aT9EYVm0lIOYjMF/2f6j6cu2apJtjXICOksR2HefRBVXQirOxRHma
|
||||
9XAYfNkZ/2164ZbgFmJv9khFnegPEuth9tLVdFIeGSmsG0aX9tH63zGT2NROyyLc
|
||||
QXPqsDl2CxCYPRs2oiGkM9dnfP1wAOp96sq42GIuN7ykfqfRnwAIvvnLKvyCq1vR
|
||||
pIno3CIX6vnzt+1/Hrmv13b0L6pJPitpXwKWHv9zJKBTpN8HEzP3Qmth2Ef60/7/
|
||||
CBo1PVTd1A6zcU7816flg7SCY+Vk+OxVHV3dGBIIqN9SfrQ8BPcOl6FNV5Anbrnv
|
||||
CpSw+LzH9n5xympDnk0BAoIBAQDjenvDfCnrNVeqx8+sYaYey4/WPVLXOQhREvRY
|
||||
oOtX9eqlNSi20+Wl+iuXmyj8wdHrDET7rfjCbpDQ7u105yzLw4gy4qIRDKZ1nE45
|
||||
YX+tm8mZgBqRnTp0DoGOArqmp3IKXJtUYmpbTz9tOfY7Usb1o1epb4winEB+Pl+8
|
||||
mgXOEo8xvWBzKeRA7tE73V64Mwbvbo9Ff2EguhXweQP29yBkEjT4iViayuHUmyPt
|
||||
hOVSMj2oFQuQGPdhAk7nUXojSGK/Zas/AGpH9CHH9De0h4m08vd3oM4vj0HwzgjU
|
||||
Co9aRa9SAH7EiaocOTcjDRPxWdZPHhxmrVRIYlF0MNmOAkXJAoIBAQDDfEqu4sNi
|
||||
pq74VXVatQqhzCILZo+o48bdgEjF7mF99mqPj8rwIDrEoEriDK861kenLc3vWKRY
|
||||
5wh1iX3S896re9kUMoxx6p4heYTcsOJ9BbkcpT8bJPZx9gBJb4jJENeVf1exf6sG
|
||||
RhFnulpzReRRaUjX2yAkyUPfc8YcUt+Nalrg+2W0fzeLCUpABCAcj2B1Vv7qRZHj
|
||||
oEtlCV5Nz+iMhrwIa16g9c8wGt5DZb4PI+VIJ6EYkdsjhgqIF0T/wDq9/habGBPo
|
||||
mHN+/DX3hCJWN2QgoVGJskHGt0zDMgiEgXfLZ2Grl02vQtq+mW2O2vGVeUd9Y5Ew
|
||||
RUiY4bSRTrUdAoIBAHxL1wiP9c/By+9TUtScXssA681ioLtdPIAgXUd4VmAvzVEM
|
||||
ZPzRd/BjbCJg89p4hZ1rjN4Ax6ZmB9dCVpnEH6QPaYJ0d53dTa+CAvQzpDJWp6eq
|
||||
adobEW+M5ZmVQCwD3rpus6k+RWMzQDMMstDjgDeEU0gP3YCj5FGW/3TsrDNXzMqe
|
||||
8e67ey9Hzyho43K+3xFBViPhYE8jnw1Q8quliRtlH3CWi8W5CgDD7LPCJBPvw+Tt
|
||||
6u2H1tQ5EKgwyw4wZVSz1wiLz4cVjMfXWADa9pHbGQFS6pbuLlfIHObQBliLLysd
|
||||
ficiGcNmOAx8/uKn9gQxLc+k8iLDJkLY1mdUMpECggEAJLl87k37ltTpmg2z9k58
|
||||
qNjIrIugAYKJIaOwCD84YYmhi0bgQSxM3hOe/ciUQuFupKGeRpDIj0sX87zYvoDC
|
||||
HEUwCvNUHzKMco15wFwasJIarJ7+tALFqbMlaqZhdCSN27AIsXfikVMogewoge9n
|
||||
bUPyQ1sPNtn4vknptfh7tv18BTg1aytbK+ua31vnDHaDEIg/a5OWTMUYZOrVpJii
|
||||
f4PwX0SMioCjY84oY1EB26ZKtLt9MDh2ir3rzJVSiRl776WEaa6kTtYVHI4VNWLF
|
||||
cJ0HWnnz74JliQd2jFUh9IK+FqBdYPcTyREuNxBr3KKVMBeQrqW96OubL913JrU6
|
||||
oQKCAQEA0yzORUouT0yleWs7RmzBlT9OLD/3cBYJMf/r1F8z8OQjB8fU1jKbO1Cs
|
||||
q4l+o9FmI+eHkgc3xbEG0hahOFWm/hTTli9vzksxurgdawZELThRkK33uTU9pKla
|
||||
Okqx3Ru/iMOW2+DQUx9UB+jK+hSAgq4gGqLeJVyaBerIdLQLlvqxrwSxjvvj+wJC
|
||||
Y66mgRzdCi6VDF1vV0knCrQHK6tRwcPozu/k4zjJzvdbMJnKEy2S7Vh6vO8lEPJm
|
||||
MQtaHPpmz+F4z14b9unNIiSbHO60Q4O+BwIBCzxApQQbFg63vBLYYwEMRd7hh92s
|
||||
ZkZVSOEp+sYBf/tmptlKr49nO+dTjQ==
|
||||
-----END PRIVATE KEY-----
|
||||
19
tests/testserver/san.cnf
Normal file
19
tests/testserver/san.cnf
Normal file
@@ -0,0 +1,19 @@
|
||||
# openssl req -new -x509 -days 3650 -key key.pem -out cert.pem -config san.cnf -extensions v3_req
|
||||
|
||||
[req]
|
||||
distinguished_name = req_distinguished_name
|
||||
req_extensions = v3_req
|
||||
prompt = no
|
||||
|
||||
[req_distinguished_name]
|
||||
CN = playwright-test
|
||||
|
||||
[v3_req]
|
||||
basicConstraints = CA:FALSE
|
||||
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
|
||||
subjectAltName = @alt_names
|
||||
|
||||
[alt_names]
|
||||
DNS.1 = localhost
|
||||
IP.1 = 127.0.0.1
|
||||
IP.2 = ::1
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"module": "CommonJS",
|
||||
"outDir": "./lib"
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
],
|
||||
}
|
||||
150
update-readme.js
Normal file
150
update-readme.js
Normal file
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
// @ts-check
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { zodToJsonSchema } = require('zod-to-json-schema')
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const { allTools } = require('playwright/lib/mcp/browser/tools');
|
||||
|
||||
const capabilities = {
|
||||
'core': 'Core automation',
|
||||
'core-tabs': 'Tab management',
|
||||
'core-install': 'Browser installation',
|
||||
'vision': 'Coordinate-based (opt-in via --caps=vision)',
|
||||
'pdf': 'PDF generation (opt-in via --caps=pdf)',
|
||||
'verify': 'Verify (opt-in via --caps=verify)',
|
||||
'tracing': 'Tracing (opt-in via --caps=tracing)',
|
||||
};
|
||||
|
||||
const toolsByCapability = Object.fromEntries(Object.entries(capabilities).map(([capability, title]) => [title, allTools.filter(tool => tool.capability === capability).sort((a, b) => a.schema.name.localeCompare(b.schema.name))]));
|
||||
|
||||
/**
|
||||
* @param {any} tool
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function formatToolForReadme(tool) {
|
||||
const lines = /** @type {string[]} */ ([]);
|
||||
lines.push(`<!-- NOTE: This has been generated via ${path.basename(__filename)} -->`);
|
||||
lines.push(``);
|
||||
lines.push(`- **${tool.name}**`);
|
||||
lines.push(` - Title: ${tool.title}`);
|
||||
lines.push(` - Description: ${tool.description}`);
|
||||
|
||||
const inputSchema = /** @type {any} */ (zodToJsonSchema(tool.inputSchema || {}));
|
||||
const requiredParams = inputSchema.required || [];
|
||||
if (inputSchema.properties && Object.keys(inputSchema.properties).length) {
|
||||
lines.push(` - Parameters:`);
|
||||
Object.entries(inputSchema.properties).forEach(([name, param]) => {
|
||||
const optional = !requiredParams.includes(name);
|
||||
const meta = /** @type {string[]} */ ([]);
|
||||
if (param.type)
|
||||
meta.push(param.type);
|
||||
if (optional)
|
||||
meta.push('optional');
|
||||
lines.push(` - \`${name}\` ${meta.length ? `(${meta.join(', ')})` : ''}: ${param.description}`);
|
||||
});
|
||||
} else {
|
||||
lines.push(` - Parameters: None`);
|
||||
}
|
||||
lines.push(` - Read-only: **${tool.type === 'readOnly'}**`);
|
||||
lines.push('');
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} content
|
||||
* @param {string} startMarker
|
||||
* @param {string} endMarker
|
||||
* @param {string[]} generatedLines
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function updateSection(content, startMarker, endMarker, generatedLines) {
|
||||
const startMarkerIndex = content.indexOf(startMarker);
|
||||
const endMarkerIndex = content.indexOf(endMarker);
|
||||
if (startMarkerIndex === -1 || endMarkerIndex === -1)
|
||||
throw new Error('Markers for generated section not found in README');
|
||||
|
||||
return [
|
||||
content.slice(0, startMarkerIndex + startMarker.length),
|
||||
'',
|
||||
generatedLines.join('\n'),
|
||||
'',
|
||||
content.slice(endMarkerIndex),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} content
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function updateTools(content) {
|
||||
console.log('Loading tool information from compiled modules...');
|
||||
|
||||
const generatedLines = /** @type {string[]} */ ([]);
|
||||
for (const [capability, tools] of Object.entries(toolsByCapability)) {
|
||||
console.log('Updating tools for capability:', capability);
|
||||
generatedLines.push(`<details>\n<summary><b>${capability}</b></summary>`);
|
||||
generatedLines.push('');
|
||||
for (const tool of tools)
|
||||
generatedLines.push(...formatToolForReadme(tool.schema));
|
||||
generatedLines.push(`</details>`);
|
||||
generatedLines.push('');
|
||||
}
|
||||
|
||||
const startMarker = `<!--- Tools generated by ${path.basename(__filename)} -->`;
|
||||
const endMarker = `<!--- End of tools generated section -->`;
|
||||
return updateSection(content, startMarker, endMarker, generatedLines);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} content
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function updateOptions(content) {
|
||||
console.log('Listing options...');
|
||||
const output = execSync('node cli.js --help');
|
||||
const lines = output.toString().split('\n');
|
||||
const firstLine = lines.findIndex(line => line.includes('--version'));
|
||||
lines.splice(0, firstLine + 1);
|
||||
const lastLine = lines.findIndex(line => line.includes('--help'));
|
||||
lines.splice(lastLine);
|
||||
const startMarker = `<!--- Options generated by ${path.basename(__filename)} -->`;
|
||||
const endMarker = `<!--- End of options generated section -->`;
|
||||
return updateSection(content, startMarker, endMarker, [
|
||||
'```',
|
||||
'> npx @playwright/mcp@latest --help',
|
||||
...lines,
|
||||
'```',
|
||||
]);
|
||||
}
|
||||
|
||||
async function updateReadme() {
|
||||
const readmePath = path.join(__dirname, 'README.md');
|
||||
const readmeContent = await fs.promises.readFile(readmePath, 'utf-8');
|
||||
const withTools = await updateTools(readmeContent);
|
||||
const withOptions = await updateOptions(withTools);
|
||||
await fs.promises.writeFile(readmePath, withOptions, 'utf-8');
|
||||
console.log('README updated successfully');
|
||||
}
|
||||
|
||||
updateReadme().catch(err => {
|
||||
console.error('Error updating README:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user