Compare commits
270 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38f0222d0e | ||
|
|
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 |
96
.github/workflows/ci.yml
vendored
96
.github/workflows/ci.yml
vendored
@@ -11,17 +11,15 @@ jobs:
|
||||
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
|
||||
- run: npm run build
|
||||
- name: Run ESLint
|
||||
run: npm run lint
|
||||
- run: npm run update-readme
|
||||
- name: Ensure no changes
|
||||
run: git diff --exit-code
|
||||
|
||||
@@ -29,33 +27,85 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
os: [ubuntu-latest, macos-15, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
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: Playwright install
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Install MS Edge
|
||||
if: ${{ matrix.os == 'macos-latest' }} # MS Edge is not preinstalled on macOS runners.
|
||||
run: npx playwright install msedge
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Install Playwright browsers
|
||||
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:
|
||||
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
|
||||
|
||||
47
.github/workflows/publish-canary.yml
vendored
Normal file
47
.github/workflows/publish-canary.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
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 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
|
||||
83
.github/workflows/publish.yml
vendored
83
.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
|
||||
@@ -16,9 +16,86 @@ jobs:
|
||||
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 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
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,4 +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
64
Dockerfile
@@ -1,22 +1,64 @@
|
||||
FROM node:22-bookworm-slim
|
||||
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
|
||||
|
||||
# Copy package.json and package-lock.json at this stage to leverage the build cache
|
||||
COPY package*.json ./
|
||||
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
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
# ------------------------------
|
||||
# Builder
|
||||
# ------------------------------
|
||||
FROM base AS builder
|
||||
|
||||
# Install chromium and its dependencies, but only for headless mode
|
||||
RUN npx -y playwright install --with-deps --only-shell chromium
|
||||
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 . .
|
||||
COPY *.json *.js *.ts .
|
||||
|
||||
# Build the app
|
||||
RUN npm run build
|
||||
# ------------------------------
|
||||
# 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"]
|
||||
ENTRYPOINT ["node", "cli.js", "--headless", "--browser", "chromium", "--no-sandbox"]
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
47
examples.md
47
examples.md
@@ -1,47 +0,0 @@
|
||||
# Examples
|
||||
|
||||
Below are some examples of prompts to use with Playwright MCP. Draw inspiration from them to create your own. If you come up with a new prompt that works well, feel free to let us know in an issue or pull request.
|
||||
|
||||
## Write a regression test for a bug report
|
||||
|
||||
We have received a user report about a bug on our website https://debs-obrien.github.io/playwright-movies-app. I'd like you to verify if this bug exists by trying to follow the steps. If you see different behaviour, explain how it's different. If you see the same behaviour, please write me a Playwright regression test that encapsulates the buggy user flow. While writing the test, draw inspiration from the Playwright code you ran. Here's the user report:
|
||||
|
||||
Howdy. I love your movies site, but there's something fishy going on. I'm looking at The Garfield Movie - a spectacular film - but somehow it's showing the wrong length. The movie is 90mins long, I measured myself yesterday! But somehow your site is claiming 101mins. Am I looking at it wrong, or is there a bug?
|
||||
|
||||
**Output:**
|
||||
|
||||
```ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Regression test for bug: The Garfield Movie showing incorrect runtime
|
||||
* User reported actual runtime is 90 minutes, but site shows 101 minutes
|
||||
*/
|
||||
test('verify The Garfield Movie runtime bug', async ({ page }) => {
|
||||
// Navigate to the movies app home page
|
||||
await page.goto('https://debs-obrien.github.io/playwright-movies-app');
|
||||
|
||||
// Wait for page to load completely
|
||||
await expect(page.getByRole('heading', { name: 'Popular', level: 1 })).toBeVisible();
|
||||
|
||||
// Look for The Garfield Movie and click on it
|
||||
const garfieldMovieLink = page.getByRole('link', { name: /poster of The Garfield Movie/i });
|
||||
await expect(garfieldMovieLink).toBeVisible();
|
||||
await garfieldMovieLink.click();
|
||||
|
||||
// Verify we're on the correct movie page
|
||||
await expect(page.getByRole('heading', { name: 'The Garfield Movie', level: 1 })).toBeVisible();
|
||||
|
||||
// Check the runtime information
|
||||
// The bug: Expected runtime should be 90 min, but site shows 101 min
|
||||
const movieInfoText = await page.locator('text=English / 101 min. / 2024').textContent();
|
||||
|
||||
// This test will fail because of the bug (which is what we want to demonstrate)
|
||||
// Once fixed, this assertion should be updated to the correct runtime (90 min)
|
||||
expect(movieInfoText).toContain('90 min');
|
||||
|
||||
// Alternative assertion that verifies the incorrect runtime is still present
|
||||
// Uncomment this and comment the above assertion to verify the bug exists
|
||||
// expect(movieInfoText).toContain('101 min');
|
||||
});
|
||||
```
|
||||
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
|
||||
}
|
||||
});
|
||||
43
index.d.ts
vendored
43
index.d.ts
vendored
@@ -16,45 +16,8 @@
|
||||
*/
|
||||
|
||||
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 = {
|
||||
/**
|
||||
* The browser to use (e.g., 'chrome', 'chromium', 'firefox', 'webkit', 'msedge').
|
||||
*/
|
||||
browser?: string;
|
||||
/**
|
||||
* Path to a user data directory for browser profile persistence.
|
||||
*/
|
||||
userDataDir?: string;
|
||||
/**
|
||||
* Whether to run the browser in headless mode (default: true).
|
||||
*/
|
||||
headless?: boolean;
|
||||
/**
|
||||
* Path to a custom browser executable.
|
||||
*/
|
||||
executablePath?: string;
|
||||
/**
|
||||
* Chrome DevTools Protocol endpoint to connect to an existing browser instance.
|
||||
*/
|
||||
cdpEndpoint?: string;
|
||||
/**
|
||||
* Enable vision capabilities (e.g., visual automation or OCR).
|
||||
*/
|
||||
vision?: boolean;
|
||||
/**
|
||||
* List of enabled tool capabilities. Possible values:
|
||||
* - 'core': Core browser automation features.
|
||||
* - 'tabs': Tab management features.
|
||||
* - 'pdf': PDF generation and manipulation.
|
||||
* - 'history': Browser history access.
|
||||
* - 'wait': Wait and timing utilities.
|
||||
* - 'files': File upload/download support.
|
||||
* - 'install': Browser installation utilities.
|
||||
*/
|
||||
capabilities?: ToolCapability[];
|
||||
};
|
||||
export declare function createServer(options?: Options): Promise<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 };
|
||||
|
||||
3598
package-lock.json
generated
3598
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.14",
|
||||
"version": "0.0.37",
|
||||
"description": "Playwright Tools for MCP",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -15,16 +15,15 @@
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"lint": "eslint .",
|
||||
"update-readme": "node utils/update-readme.js",
|
||||
"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",
|
||||
"ctest": "playwright test --project=chrome",
|
||||
"ftest": "playwright test --project=firefox",
|
||||
"wtest": "playwright test --project=webkit",
|
||||
"clean": "rm -rf lib",
|
||||
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
|
||||
"dtest": "MCP_IN_DOCKER=1 playwright test --project=chromium-docker",
|
||||
"npm-publish": "npm run clean && npm run test && npm publish"
|
||||
},
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
@@ -34,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,20 +16,23 @@
|
||||
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
import type { Project } from '@playwright/test';
|
||||
import type { TestOptions } from './tests/fixtures';
|
||||
|
||||
export default defineConfig({
|
||||
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: 'chrome' },
|
||||
{ name: 'msedge', use: { mcpBrowser: 'msedge' } },
|
||||
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
||||
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
||||
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
||||
].filter(Boolean) as Project[],
|
||||
...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.
|
||||
458
src/context.ts
458
src/context.ts
@@ -1,458 +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 { ManualPromise } from './manualPromise';
|
||||
|
||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
|
||||
import type { ModalState, Tool, ToolActionResult } 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 PendingAction = {
|
||||
dialogShown: ManualPromise<void>;
|
||||
};
|
||||
|
||||
export class Context {
|
||||
readonly tools: Tool[];
|
||||
readonly options: ContextOptions;
|
||||
private _browser: playwright.Browser | undefined;
|
||||
private _browserContext: playwright.BrowserContext | undefined;
|
||||
private _tabs: Tab[] = [];
|
||||
private _currentTab: Tab | undefined;
|
||||
private _modalStates: (ModalState & { tab: Tab })[] = [];
|
||||
private _pendingAction: PendingAction | undefined;
|
||||
|
||||
constructor(tools: Tool[], options: ContextOptions) {
|
||||
this.tools = tools;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
modalStates(): ModalState[] {
|
||||
return this._modalStates;
|
||||
}
|
||||
|
||||
setModalState(modalState: ModalState, inTab: Tab) {
|
||||
this._modalStates.push({ ...modalState, tab: inTab });
|
||||
}
|
||||
|
||||
clearModalState(modalState: ModalState) {
|
||||
this._modalStates = this._modalStates.filter(state => state !== modalState);
|
||||
}
|
||||
|
||||
modalStatesMarkdown(): string[] {
|
||||
const result: string[] = ['### Modal state'];
|
||||
for (const state of this._modalStates) {
|
||||
const tool = this.tools.find(tool => tool.clearsModalState === state.type);
|
||||
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
tabs(): Tab[] {
|
||||
return this._tabs;
|
||||
}
|
||||
|
||||
currentTabOrDie(): Tab {
|
||||
if (!this._currentTab)
|
||||
throw new Error('No current snapshot available. Capture a snapshot of navigate to a new location first.');
|
||||
return this._currentTab;
|
||||
}
|
||||
|
||||
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 listTabsMarkdown(): 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.listTabsMarkdown();
|
||||
}
|
||||
|
||||
async run(tool: Tool, params: Record<string, unknown> | undefined) {
|
||||
// Tab management is done outside of the action() call.
|
||||
const toolResult = await tool.handle(this, params);
|
||||
const { code, action, waitForNetwork, captureSnapshot, resultOverride } = toolResult;
|
||||
const racingAction = action ? () => this._raceAgainstModalDialogs(action) : undefined;
|
||||
|
||||
if (resultOverride)
|
||||
return resultOverride;
|
||||
|
||||
if (!this._currentTab) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'No open pages available. Use the "browser_navigate" tool to navigate to a page first.',
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
const tab = this.currentTabOrDie();
|
||||
// TODO: race against modal dialogs to resolve clicks.
|
||||
let actionResult: { content?: (ImageContent | TextContent)[] } | undefined;
|
||||
try {
|
||||
if (waitForNetwork)
|
||||
actionResult = await waitForCompletion(this, tab.page, async () => racingAction?.()) ?? undefined;
|
||||
else
|
||||
actionResult = await racingAction?.() ?? undefined;
|
||||
} finally {
|
||||
if (captureSnapshot && !this._javaScriptBlocked())
|
||||
await tab.captureSnapshot();
|
||||
}
|
||||
|
||||
const result: string[] = [];
|
||||
result.push(`- Ran Playwright code:
|
||||
\`\`\`js
|
||||
${code.join('\n')}
|
||||
\`\`\`
|
||||
`);
|
||||
|
||||
if (this.modalStates().length) {
|
||||
result.push(...this.modalStatesMarkdown());
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: result.join('\n'),
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
if (this.tabs().length > 1)
|
||||
result.push(await this.listTabsMarkdown(), '');
|
||||
|
||||
if (this.tabs().length > 1)
|
||||
result.push('### Current tab');
|
||||
|
||||
result.push(
|
||||
`- Page URL: ${tab.page.url()}`,
|
||||
`- Page Title: ${await tab.page.title()}`
|
||||
);
|
||||
|
||||
if (captureSnapshot && tab.hasSnapshot())
|
||||
result.push(tab.snapshotOrDie().text());
|
||||
|
||||
const content = actionResult?.content ?? [];
|
||||
|
||||
return {
|
||||
content: [
|
||||
...content,
|
||||
{
|
||||
type: 'text',
|
||||
text: result.join('\n'),
|
||||
}
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async waitForTimeout(time: number) {
|
||||
if (this._currentTab && !this._javaScriptBlocked())
|
||||
await this._currentTab.page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
|
||||
else
|
||||
await new Promise(f => setTimeout(f, time));
|
||||
}
|
||||
|
||||
private async _raceAgainstModalDialogs(action: () => Promise<ToolActionResult>): Promise<ToolActionResult> {
|
||||
this._pendingAction = {
|
||||
dialogShown: new ManualPromise(),
|
||||
};
|
||||
|
||||
let result: ToolActionResult | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
action().then(r => result = r),
|
||||
this._pendingAction.dialogShown,
|
||||
]);
|
||||
} finally {
|
||||
this._pendingAction = undefined;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private _javaScriptBlocked(): boolean {
|
||||
return this._modalStates.some(state => state.type === 'dialog');
|
||||
}
|
||||
|
||||
dialogShown(tab: Tab, dialog: playwright.Dialog) {
|
||||
this.setModalState({
|
||||
type: 'dialog',
|
||||
description: `"${dialog.type()}" dialog with message "${dialog.message()}"`,
|
||||
dialog,
|
||||
}, tab);
|
||||
this._pendingAction?.dialogShown.resolve();
|
||||
}
|
||||
|
||||
private _onPageCreated(page: playwright.Page) {
|
||||
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
|
||||
this._tabs.push(tab);
|
||||
if (!this._currentTab)
|
||||
this._currentTab = tab;
|
||||
}
|
||||
|
||||
private _onPageClosed(tab: Tab) {
|
||||
this._modalStates = this._modalStates.filter(state => state.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)];
|
||||
if (this._browserContext && !this._tabs.length)
|
||||
void this.close();
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (!this._browserContext)
|
||||
return;
|
||||
const browserContext = this._browserContext;
|
||||
const browser = this._browser;
|
||||
this._browserContext = undefined;
|
||||
this._browser = undefined;
|
||||
|
||||
await browserContext?.close().then(async () => {
|
||||
await browser?.close();
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Tab {
|
||||
readonly context: Context;
|
||||
readonly page: playwright.Page;
|
||||
private _console: playwright.ConsoleMessage[] = [];
|
||||
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.context.setModalState({
|
||||
type: 'fileChooser',
|
||||
description: 'File chooser',
|
||||
fileChooser: chooser,
|
||||
}, this);
|
||||
});
|
||||
page.on('dialog', dialog => this.context.dialogShown(this, dialog));
|
||||
page.setDefaultNavigationTimeout(60000);
|
||||
page.setDefaultTimeout(5000);
|
||||
}
|
||||
|
||||
private _onClose() {
|
||||
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(() => {});
|
||||
}
|
||||
|
||||
hasSnapshot(): boolean {
|
||||
return !!this._snapshot;
|
||||
}
|
||||
|
||||
snapshotOrDie(): PageSnapshot {
|
||||
if (!this._snapshot)
|
||||
throw new Error('No snapshot available');
|
||||
return this._snapshot;
|
||||
}
|
||||
|
||||
async console(): Promise<playwright.ConsoleMessage[]> {
|
||||
return this._console;
|
||||
}
|
||||
|
||||
async captureSnapshot() {
|
||||
this._snapshot = await PageSnapshot.create(this.page);
|
||||
}
|
||||
}
|
||||
|
||||
class PageSnapshot {
|
||||
private _frameLocators: PageOrFrameLocator[] = [];
|
||||
private _text!: string;
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
static async create(page: playwright.Page): Promise<PageSnapshot> {
|
||||
const snapshot = new PageSnapshot();
|
||||
await snapshot._build(page);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
text(): string {
|
||||
return this._text;
|
||||
}
|
||||
|
||||
private async _build(page: playwright.Page) {
|
||||
const yamlDocument = await this._snapshotFrame(page);
|
||||
this._text = [
|
||||
`- Page Snapshot`,
|
||||
'```yaml',
|
||||
yamlDocument.toString({ indentSeq: false }).trim(),
|
||||
'```',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
private async _snapshotFrame(frame: playwright.Page | playwright.FrameLocator) {
|
||||
const frameIndex = this._frameLocators.push(frame) - 1;
|
||||
const snapshotString = await frame.locator('body').ariaSnapshot({ ref: true });
|
||||
const snapshot = yaml.parseDocument(snapshotString);
|
||||
|
||||
const visit = async (node: any): Promise<unknown> => {
|
||||
if (yaml.isPair(node)) {
|
||||
await Promise.all([
|
||||
visit(node.key).then(k => node.key = k),
|
||||
visit(node.value).then(v => node.value = v)
|
||||
]);
|
||||
} else if (yaml.isSeq(node) || yaml.isMap(node)) {
|
||||
node.items = await Promise.all(node.items.map(visit));
|
||||
} else if (yaml.isScalar(node)) {
|
||||
if (typeof node.value === 'string') {
|
||||
const value = node.value;
|
||||
if (frameIndex > 0)
|
||||
node.value = value.replace('[ref=', `[ref=f${frameIndex}`);
|
||||
if (value.startsWith('iframe ')) {
|
||||
const ref = value.match(/\[ref=(.*)\]/)?.[1];
|
||||
if (ref) {
|
||||
try {
|
||||
const childSnapshot = await this._snapshotFrame(frame.frameLocator(`aria-ref=${ref}`));
|
||||
return snapshot.createPair(node.value, childSnapshot);
|
||||
} catch (error) {
|
||||
return snapshot.createPair(node.value, '<could not take iframe snapshot>');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
await visit(snapshot.contents);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
refLocator(ref: string): playwright.Locator {
|
||||
let frame = this._frameLocators[0];
|
||||
const match = ref.match(/^f(\d+)(.*)/);
|
||||
if (match) {
|
||||
const frameIndex = parseInt(match[1], 10);
|
||||
frame = this._frameLocators[frameIndex];
|
||||
ref = match[2];
|
||||
}
|
||||
|
||||
if (!frame)
|
||||
throw new Error(`Frame does not exist. Provide ref from the most current snapshot.`);
|
||||
|
||||
return frame.locator(`aria-ref=${ref}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
||||
return (locator as any)._generateLocatorString();
|
||||
}
|
||||
139
src/index.ts
139
src/index.ts
@@ -1,139 +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 path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
|
||||
import { createServerWithTools } from './server';
|
||||
import common from './tools/common';
|
||||
import console from './tools/console';
|
||||
import dialogs from './tools/dialogs';
|
||||
import files from './tools/files';
|
||||
import install from './tools/install';
|
||||
import keyboard from './tools/keyboard';
|
||||
import navigate from './tools/navigate';
|
||||
import pdf from './tools/pdf';
|
||||
import snapshot from './tools/snapshot';
|
||||
import tabs from './tools/tabs';
|
||||
import screen from './tools/screen';
|
||||
|
||||
import type { Tool, ToolCapability } from './tools/tool';
|
||||
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import type { LaunchOptions } from 'playwright';
|
||||
|
||||
const snapshotTools: Tool[] = [
|
||||
...common(true),
|
||||
...console,
|
||||
...dialogs(true),
|
||||
...files(true),
|
||||
...install,
|
||||
...keyboard(true),
|
||||
...navigate(true),
|
||||
...pdf,
|
||||
...snapshot,
|
||||
...tabs(true),
|
||||
];
|
||||
|
||||
const screenshotTools: Tool[] = [
|
||||
...common(false),
|
||||
...console,
|
||||
...dialogs(false),
|
||||
...files(false),
|
||||
...install,
|
||||
...keyboard(false),
|
||||
...navigate(false),
|
||||
...pdf,
|
||||
...screen,
|
||||
...tabs(false),
|
||||
];
|
||||
|
||||
type Options = {
|
||||
browser?: string;
|
||||
userDataDir?: string;
|
||||
headless?: boolean;
|
||||
executablePath?: string;
|
||||
cdpEndpoint?: string;
|
||||
vision?: boolean;
|
||||
capabilities?: ToolCapability[];
|
||||
};
|
||||
|
||||
const packageJSON = require('../package.json');
|
||||
|
||||
export async function createServer(options?: Options): Promise<Server> {
|
||||
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 userDataDir = options?.userDataDir ?? await createUserDataDir(browserName);
|
||||
|
||||
const launchOptions: LaunchOptions = {
|
||||
headless: !!(options?.headless ?? (os.platform() === 'linux' && !process.env.DISPLAY)),
|
||||
channel,
|
||||
executablePath: options?.executablePath,
|
||||
};
|
||||
|
||||
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,
|
||||
userDataDir,
|
||||
launchOptions,
|
||||
cdpEndpoint: options?.cdpEndpoint,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,53 +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.
|
||||
*/
|
||||
|
||||
// adapted from:
|
||||
// - https://github.com/microsoft/playwright/blob/76ee48dc9d4034536e3ec5b2c7ce8be3b79418a8/packages/playwright-core/src/utils/isomorphic/stringUtils.ts
|
||||
// - https://github.com/microsoft/playwright/blob/76ee48dc9d4034536e3ec5b2c7ce8be3b79418a8/packages/playwright-core/src/server/codegen/javascript.ts
|
||||
|
||||
// NOTE: this function should not be used to escape any selectors.
|
||||
export function escapeWithQuotes(text: string, char: string = '\'') {
|
||||
const stringified = JSON.stringify(text);
|
||||
const escapedText = stringified.substring(1, stringified.length - 1).replace(/\\"/g, '"');
|
||||
if (char === '\'')
|
||||
return char + escapedText.replace(/[']/g, '\\\'') + char;
|
||||
if (char === '"')
|
||||
return char + escapedText.replace(/["]/g, '\\"') + char;
|
||||
if (char === '`')
|
||||
return char + escapedText.replace(/[`]/g, '`') + char;
|
||||
throw new Error('Invalid escape char');
|
||||
}
|
||||
|
||||
export function quote(text: string) {
|
||||
return escapeWithQuotes(text, '\'');
|
||||
}
|
||||
|
||||
export function formatObject(value: any, indent = ' '): string {
|
||||
if (typeof value === 'string')
|
||||
return quote(value);
|
||||
if (Array.isArray(value))
|
||||
return `[${value.map(o => formatObject(o)).join(', ')}]`;
|
||||
if (typeof value === 'object') {
|
||||
const keys = Object.keys(value).filter(key => value[key] !== undefined).sort();
|
||||
if (!keys.length)
|
||||
return '{}';
|
||||
const tokens: string[] = [];
|
||||
for (const key of keys)
|
||||
tokens.push(`${key}: ${formatObject(value[key])}`);
|
||||
return `{\n${indent}${tokens.join(`,\n${indent}`)}\n}`;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
@@ -1,127 +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.
|
||||
*/
|
||||
|
||||
export class ManualPromise<T = void> extends Promise<T> {
|
||||
private _resolve!: (t: T) => void;
|
||||
private _reject!: (e: Error) => void;
|
||||
private _isDone: boolean;
|
||||
|
||||
constructor() {
|
||||
let resolve: (t: T) => void;
|
||||
let reject: (e: Error) => void;
|
||||
super((f, r) => {
|
||||
resolve = f;
|
||||
reject = r;
|
||||
});
|
||||
this._isDone = false;
|
||||
this._resolve = resolve!;
|
||||
this._reject = reject!;
|
||||
}
|
||||
|
||||
isDone() {
|
||||
return this._isDone;
|
||||
}
|
||||
|
||||
resolve(t: T) {
|
||||
this._isDone = true;
|
||||
this._resolve(t);
|
||||
}
|
||||
|
||||
reject(e: Error) {
|
||||
this._isDone = true;
|
||||
this._reject(e);
|
||||
}
|
||||
|
||||
static override get [Symbol.species]() {
|
||||
return Promise;
|
||||
}
|
||||
|
||||
override get [Symbol.toStringTag]() {
|
||||
return 'ManualPromise';
|
||||
}
|
||||
}
|
||||
|
||||
export class LongStandingScope {
|
||||
private _terminateError: Error | undefined;
|
||||
private _closeError: Error | undefined;
|
||||
private _terminatePromises = new Map<ManualPromise<Error>, string[]>();
|
||||
private _isClosed = false;
|
||||
|
||||
reject(error: Error) {
|
||||
this._isClosed = true;
|
||||
this._terminateError = error;
|
||||
for (const p of this._terminatePromises.keys())
|
||||
p.resolve(error);
|
||||
}
|
||||
|
||||
close(error: Error) {
|
||||
this._isClosed = true;
|
||||
this._closeError = error;
|
||||
for (const [p, frames] of this._terminatePromises)
|
||||
p.resolve(cloneError(error, frames));
|
||||
}
|
||||
|
||||
isClosed() {
|
||||
return this._isClosed;
|
||||
}
|
||||
|
||||
static async raceMultiple<T>(scopes: LongStandingScope[], promise: Promise<T>): Promise<T> {
|
||||
return Promise.race(scopes.map(s => s.race(promise)));
|
||||
}
|
||||
|
||||
async race<T>(promise: Promise<T> | Promise<T>[]): Promise<T> {
|
||||
return this._race(Array.isArray(promise) ? promise : [promise], false) as Promise<T>;
|
||||
}
|
||||
|
||||
async safeRace<T>(promise: Promise<T>, defaultValue?: T): Promise<T> {
|
||||
return this._race([promise], true, defaultValue);
|
||||
}
|
||||
|
||||
private async _race(promises: Promise<any>[], safe: boolean, defaultValue?: any): Promise<any> {
|
||||
const terminatePromise = new ManualPromise<Error>();
|
||||
const frames = captureRawStack();
|
||||
if (this._terminateError)
|
||||
terminatePromise.resolve(this._terminateError);
|
||||
if (this._closeError)
|
||||
terminatePromise.resolve(cloneError(this._closeError, frames));
|
||||
this._terminatePromises.set(terminatePromise, frames);
|
||||
try {
|
||||
return await Promise.race([
|
||||
terminatePromise.then(e => safe ? defaultValue : Promise.reject(e)),
|
||||
...promises
|
||||
]);
|
||||
} finally {
|
||||
this._terminatePromises.delete(terminatePromise);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cloneError(error: Error, frames: string[]) {
|
||||
const clone = new Error();
|
||||
clone.name = error.name;
|
||||
clone.message = error.message;
|
||||
clone.stack = [error.name + ':' + error.message, ...frames].join('\n');
|
||||
return clone;
|
||||
}
|
||||
|
||||
function captureRawStack(): string[] {
|
||||
const stackTraceLimit = Error.stackTraceLimit;
|
||||
Error.stackTraceLimit = 50;
|
||||
const error = new Error();
|
||||
const stack = error.stack || '';
|
||||
Error.stackTraceLimit = stackTraceLimit;
|
||||
return stack.split('\n');
|
||||
}
|
||||
136
src/program.ts
136
src/program.ts
@@ -1,136 +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 { 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 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 => {
|
||||
const serverList = new ServerList(() => createServer({
|
||||
browser: options.browser,
|
||||
userDataDir: options.userDataDir,
|
||||
headless: options.headless,
|
||||
executablePath: options.executablePath,
|
||||
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) {
|
||||
const handleExit = async () => {
|
||||
setTimeout(() => process.exit(0), 15000);
|
||||
await serverList.closeAll();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.stdin.on('close', handleExit);
|
||||
process.on('SIGINT', handleExit);
|
||||
process.on('SIGTERM', handleExit);
|
||||
}
|
||||
|
||||
program.parse(process.argv);
|
||||
|
||||
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));
|
||||
});
|
||||
}
|
||||
126
src/server.ts
126
src/server.ts
@@ -1,126 +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(tools, 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,
|
||||
};
|
||||
}
|
||||
|
||||
const modalStates = context.modalStates().map(state => state.type);
|
||||
if ((tool.clearsModalState && !modalStates.includes(tool.clearsModalState)) ||
|
||||
(!tool.clearsModalState && modalStates.length)) {
|
||||
const text = [
|
||||
`Tool "${request.params.name}" does not handle the modal state.`,
|
||||
...context.modalStatesMarkdown(),
|
||||
].join('\n');
|
||||
return {
|
||||
content: [{ type: 'text', text }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return await context.run(tool, request.params.arguments);
|
||||
} 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: () => Promise<Server>;
|
||||
|
||||
constructor(serverFactory: () => Promise<Server>) {
|
||||
this._serverFactory = serverFactory;
|
||||
}
|
||||
|
||||
async create() {
|
||||
const server = await 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,107 +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, ToolFactory } from './tool';
|
||||
|
||||
const waitSchema = z.object({
|
||||
time: z.number().describe('The time to wait in seconds'),
|
||||
});
|
||||
|
||||
const wait: ToolFactory = captureSnapshot => ({
|
||||
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 {
|
||||
code: [`// Waited for ${validatedParams.time} seconds`],
|
||||
captureSnapshot,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
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 {
|
||||
code: [`// Internal to close the page`],
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const resizeSchema = z.object({
|
||||
width: z.number().describe('Width of the browser window'),
|
||||
height: z.number().describe('Height of the browser window'),
|
||||
});
|
||||
|
||||
const resize: ToolFactory = captureSnapshot => ({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_resize',
|
||||
description: 'Resize the browser window',
|
||||
inputSchema: zodToJsonSchema(resizeSchema),
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = resizeSchema.parse(params);
|
||||
|
||||
const tab = context.currentTabOrDie();
|
||||
|
||||
const code = [
|
||||
`// Resize browser window to ${validatedParams.width}x${validatedParams.height}`,
|
||||
`await page.setViewportSize({ width: ${validatedParams.width}, height: ${validatedParams.height} });`
|
||||
];
|
||||
|
||||
const action = async () => {
|
||||
await tab.page.setViewportSize({ width: validatedParams.width, height: validatedParams.height });
|
||||
};
|
||||
|
||||
return {
|
||||
code,
|
||||
action,
|
||||
captureSnapshot,
|
||||
waitForNetwork: true
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default (captureSnapshot: boolean) => [
|
||||
close,
|
||||
wait(captureSnapshot),
|
||||
resize(captureSnapshot)
|
||||
];
|
||||
@@ -1,49 +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 consoleSchema = z.object({});
|
||||
|
||||
const console: Tool = {
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_console_messages',
|
||||
description: 'Returns all console messages',
|
||||
inputSchema: zodToJsonSchema(consoleSchema),
|
||||
},
|
||||
handle: async context => {
|
||||
const messages = await context.currentTabOrDie().console();
|
||||
const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n');
|
||||
return {
|
||||
code: [`// <internal code to get console messages>`],
|
||||
action: async () => {
|
||||
return {
|
||||
content: [{ type: 'text', text: log }]
|
||||
};
|
||||
},
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default [
|
||||
console,
|
||||
];
|
||||
@@ -1,65 +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 handleDialogSchema = z.object({
|
||||
accept: z.boolean().describe('Whether to accept the dialog.'),
|
||||
promptText: z.string().optional().describe('The text of the prompt in case of a prompt dialog.'),
|
||||
});
|
||||
|
||||
const handleDialog: ToolFactory = captureSnapshot => ({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_handle_dialog',
|
||||
description: 'Handle a dialog',
|
||||
inputSchema: zodToJsonSchema(handleDialogSchema),
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = handleDialogSchema.parse(params);
|
||||
const dialogState = context.modalStates().find(state => state.type === 'dialog');
|
||||
if (!dialogState)
|
||||
throw new Error('No dialog visible');
|
||||
|
||||
if (validatedParams.accept)
|
||||
await dialogState.dialog.accept(validatedParams.promptText);
|
||||
else
|
||||
await dialogState.dialog.dismiss();
|
||||
|
||||
context.clearModalState(dialogState);
|
||||
|
||||
const code = [
|
||||
`// <internal code to handle "${dialogState.dialog.type()}" dialog>`,
|
||||
];
|
||||
|
||||
return {
|
||||
code,
|
||||
captureSnapshot,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
|
||||
clearsModalState: 'dialog',
|
||||
});
|
||||
|
||||
export default (captureSnapshot: boolean) => [
|
||||
handleDialog(captureSnapshot),
|
||||
];
|
||||
@@ -1,62 +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 modalState = context.modalStates().find(state => state.type === 'fileChooser');
|
||||
if (!modalState)
|
||||
throw new Error('No file chooser visible');
|
||||
|
||||
const code = [
|
||||
`// <internal code to chose files ${validatedParams.paths.join(', ')}`,
|
||||
];
|
||||
|
||||
const action = async () => {
|
||||
await modalState.fileChooser.setFiles(validatedParams.paths);
|
||||
context.clearModalState(modalState);
|
||||
};
|
||||
|
||||
return {
|
||||
code,
|
||||
action,
|
||||
captureSnapshot,
|
||||
waitForNetwork: true,
|
||||
};
|
||||
},
|
||||
clearsModalState: 'fileChooser',
|
||||
});
|
||||
|
||||
export default (captureSnapshot: boolean) => [
|
||||
uploadFile(captureSnapshot),
|
||||
];
|
||||
@@ -1,60 +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 {
|
||||
code: [`// Browser ${channel} installed`],
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default [
|
||||
install,
|
||||
];
|
||||
@@ -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 { 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);
|
||||
const tab = context.currentTabOrDie();
|
||||
|
||||
const code = [
|
||||
`// Press ${validatedParams.key}`,
|
||||
`await page.keyboard.press('${validatedParams.key}');`,
|
||||
];
|
||||
|
||||
const action = () => tab.page.keyboard.press(validatedParams.key);
|
||||
|
||||
return {
|
||||
code,
|
||||
action,
|
||||
captureSnapshot,
|
||||
waitForNetwork: true
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default (captureSnapshot: boolean) => [
|
||||
pressKey(captureSnapshot),
|
||||
];
|
||||
@@ -1,107 +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 tab = await context.ensureTab();
|
||||
await tab.navigate(validatedParams.url);
|
||||
|
||||
const code = [
|
||||
`// Navigate to ${validatedParams.url}`,
|
||||
`await page.goto('${validatedParams.url}');`,
|
||||
];
|
||||
|
||||
return {
|
||||
code,
|
||||
captureSnapshot,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const goBackSchema = z.object({});
|
||||
|
||||
const goBack: ToolFactory = captureSnapshot => ({
|
||||
capability: 'history',
|
||||
schema: {
|
||||
name: 'browser_navigate_back',
|
||||
description: 'Go back to the previous page',
|
||||
inputSchema: zodToJsonSchema(goBackSchema),
|
||||
},
|
||||
|
||||
handle: async context => {
|
||||
const tab = await context.ensureTab();
|
||||
await tab.page.goBack();
|
||||
const code = [
|
||||
`// Navigate back`,
|
||||
`await page.goBack();`,
|
||||
];
|
||||
|
||||
return {
|
||||
code,
|
||||
captureSnapshot,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const goForwardSchema = z.object({});
|
||||
|
||||
const goForward: ToolFactory = captureSnapshot => ({
|
||||
capability: 'history',
|
||||
schema: {
|
||||
name: 'browser_navigate_forward',
|
||||
description: 'Go forward to the next page',
|
||||
inputSchema: zodToJsonSchema(goForwardSchema),
|
||||
},
|
||||
handle: async context => {
|
||||
const tab = context.currentTabOrDie();
|
||||
await tab.page.goForward();
|
||||
const code = [
|
||||
`// Navigate forward`,
|
||||
`await page.goForward();`,
|
||||
];
|
||||
return {
|
||||
code,
|
||||
captureSnapshot,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default (captureSnapshot: boolean) => [
|
||||
navigate(captureSnapshot),
|
||||
goBack(captureSnapshot),
|
||||
goForward(captureSnapshot),
|
||||
];
|
||||
@@ -1,59 +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 * as javascript from '../javascript';
|
||||
|
||||
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.currentTabOrDie();
|
||||
const fileName = path.join(os.tmpdir(), sanitizeForFilePath(`page-${new Date().toISOString()}`)) + '.pdf';
|
||||
|
||||
const code = [
|
||||
`// Save page as ${fileName}`,
|
||||
`await page.pdf(${javascript.formatObject({ path: fileName })});`,
|
||||
];
|
||||
|
||||
return {
|
||||
code,
|
||||
action: async () => tab.page.pdf({ path: fileName }).then(() => {}),
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default [
|
||||
pdf,
|
||||
];
|
||||
@@ -1,219 +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 * as javascript from '../javascript';
|
||||
|
||||
import type { Tool } from './tool';
|
||||
|
||||
const screenshot: Tool = {
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_screen_capture',
|
||||
description: 'Take a screenshot of the current page',
|
||||
inputSchema: zodToJsonSchema(z.object({})),
|
||||
},
|
||||
|
||||
handle: async context => {
|
||||
const tab = await context.ensureTab();
|
||||
const options = { type: 'jpeg' as 'jpeg', quality: 50, scale: 'css' as 'css' };
|
||||
|
||||
const code = [
|
||||
`// Take a screenshot of the current page`,
|
||||
`await page.screenshot(${javascript.formatObject(options)});`,
|
||||
];
|
||||
|
||||
const action = () => tab.page.screenshot(options).then(buffer => {
|
||||
return {
|
||||
content: [{ type: 'image' as 'image', data: buffer.toString('base64'), mimeType: 'image/jpeg' }],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
code,
|
||||
action,
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: false
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const 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.currentTabOrDie();
|
||||
const code = [
|
||||
`// Move mouse to (${validatedParams.x}, ${validatedParams.y})`,
|
||||
`await page.mouse.move(${validatedParams.x}, ${validatedParams.y});`,
|
||||
];
|
||||
const action = () => tab.page.mouse.move(validatedParams.x, validatedParams.y);
|
||||
return {
|
||||
code,
|
||||
action,
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: false
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const clickSchema = elementSchema.extend({
|
||||
x: z.number().describe('X coordinate'),
|
||||
y: z.number().describe('Y coordinate'),
|
||||
});
|
||||
|
||||
const click: Tool = {
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_screen_click',
|
||||
description: 'Click left mouse button',
|
||||
inputSchema: zodToJsonSchema(clickSchema),
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = clickSchema.parse(params);
|
||||
const tab = context.currentTabOrDie();
|
||||
const code = [
|
||||
`// Click mouse at coordinates (${validatedParams.x}, ${validatedParams.y})`,
|
||||
`await page.mouse.move(${validatedParams.x}, ${validatedParams.y});`,
|
||||
`await page.mouse.down();`,
|
||||
`await page.mouse.up();`,
|
||||
];
|
||||
const action = async () => {
|
||||
await tab.page.mouse.move(validatedParams.x, validatedParams.y);
|
||||
await tab.page.mouse.down();
|
||||
await tab.page.mouse.up();
|
||||
};
|
||||
return {
|
||||
code,
|
||||
action,
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const dragSchema = elementSchema.extend({
|
||||
startX: z.number().describe('Start X coordinate'),
|
||||
startY: z.number().describe('Start Y coordinate'),
|
||||
endX: z.number().describe('End X coordinate'),
|
||||
endY: z.number().describe('End Y coordinate'),
|
||||
});
|
||||
|
||||
const drag: Tool = {
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_screen_drag',
|
||||
description: 'Drag left mouse button',
|
||||
inputSchema: zodToJsonSchema(dragSchema),
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = dragSchema.parse(params);
|
||||
const tab = context.currentTabOrDie();
|
||||
|
||||
const code = [
|
||||
`// Drag mouse from (${validatedParams.startX}, ${validatedParams.startY}) to (${validatedParams.endX}, ${validatedParams.endY})`,
|
||||
`await page.mouse.move(${validatedParams.startX}, ${validatedParams.startY});`,
|
||||
`await page.mouse.down();`,
|
||||
`await page.mouse.move(${validatedParams.endX}, ${validatedParams.endY});`,
|
||||
`await page.mouse.up();`,
|
||||
];
|
||||
|
||||
const action = async () => {
|
||||
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();
|
||||
};
|
||||
|
||||
return {
|
||||
code,
|
||||
action,
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
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);
|
||||
const tab = context.currentTabOrDie();
|
||||
|
||||
const code = [
|
||||
`// Type ${validatedParams.text}`,
|
||||
`await page.keyboard.type('${validatedParams.text}');`,
|
||||
];
|
||||
|
||||
const action = async () => {
|
||||
await tab.page.keyboard.type(validatedParams.text);
|
||||
if (validatedParams.submit)
|
||||
await tab.page.keyboard.press('Enter');
|
||||
};
|
||||
|
||||
if (validatedParams.submit) {
|
||||
code.push(`// Submit text`);
|
||||
code.push(`await page.keyboard.press('Enter');`);
|
||||
}
|
||||
|
||||
return {
|
||||
code,
|
||||
action,
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default [
|
||||
screenshot,
|
||||
moveMouse,
|
||||
click,
|
||||
drag,
|
||||
type,
|
||||
];
|
||||
@@ -1,289 +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 path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
import { z } from 'zod';
|
||||
import zodToJsonSchema from 'zod-to-json-schema';
|
||||
|
||||
import { sanitizeForFilePath } from './utils';
|
||||
import { generateLocator } from '../context';
|
||||
import * as javascript from '../javascript';
|
||||
|
||||
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 => {
|
||||
await context.ensureTab();
|
||||
|
||||
return {
|
||||
code: [`// <internal code to capture accessibility snapshot>`],
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const elementSchema = z.object({
|
||||
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
|
||||
ref: z.string().describe('Exact target element reference from the page snapshot'),
|
||||
});
|
||||
|
||||
const click: Tool = {
|
||||
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);
|
||||
const tab = context.currentTabOrDie();
|
||||
const locator = tab.snapshotOrDie().refLocator(validatedParams.ref);
|
||||
|
||||
const code = [
|
||||
`// Click ${validatedParams.element}`,
|
||||
`await page.${await generateLocator(locator)}.click();`
|
||||
];
|
||||
|
||||
return {
|
||||
code,
|
||||
action: () => locator.click(),
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
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);
|
||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||
const startLocator = snapshot.refLocator(validatedParams.startRef);
|
||||
const endLocator = snapshot.refLocator(validatedParams.endRef);
|
||||
|
||||
const code = [
|
||||
`// Drag ${validatedParams.startElement} to ${validatedParams.endElement}`,
|
||||
`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`
|
||||
];
|
||||
|
||||
return {
|
||||
code,
|
||||
action: () => startLocator.dragTo(endLocator),
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
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);
|
||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||
const locator = snapshot.refLocator(validatedParams.ref);
|
||||
|
||||
const code = [
|
||||
`// Hover over ${validatedParams.element}`,
|
||||
`await page.${await generateLocator(locator)}.hover();`
|
||||
];
|
||||
|
||||
return {
|
||||
code,
|
||||
action: () => locator.hover(),
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const typeSchema = elementSchema.extend({
|
||||
text: z.string().describe('Text to type into the element'),
|
||||
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);
|
||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||
const locator = snapshot.refLocator(validatedParams.ref);
|
||||
|
||||
const code: string[] = [];
|
||||
const steps: (() => Promise<void>)[] = [];
|
||||
|
||||
if (validatedParams.slowly) {
|
||||
code.push(`// Press "${validatedParams.text}" sequentially into "${validatedParams.element}"`);
|
||||
code.push(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(validatedParams.text)});`);
|
||||
steps.push(() => locator.pressSequentially(validatedParams.text));
|
||||
} else {
|
||||
code.push(`// Fill "${validatedParams.text}" into "${validatedParams.element}"`);
|
||||
code.push(`await page.${await generateLocator(locator)}.fill(${javascript.quote(validatedParams.text)});`);
|
||||
steps.push(() => locator.fill(validatedParams.text));
|
||||
}
|
||||
|
||||
if (validatedParams.submit) {
|
||||
code.push(`// Submit text`);
|
||||
code.push(`await page.${await generateLocator(locator)}.press('Enter');`);
|
||||
steps.push(() => locator.press('Enter'));
|
||||
}
|
||||
|
||||
return {
|
||||
code,
|
||||
action: () => steps.reduce((acc, step) => acc.then(step), Promise.resolve()),
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const selectOptionSchema = elementSchema.extend({
|
||||
values: z.array(z.string()).describe('Array of values to select in the dropdown. This can be a single value or multiple values.'),
|
||||
});
|
||||
|
||||
const selectOption: Tool = {
|
||||
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);
|
||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||
const locator = snapshot.refLocator(validatedParams.ref);
|
||||
|
||||
const code = [
|
||||
`// Select options [${validatedParams.values.join(', ')}] in ${validatedParams.element}`,
|
||||
`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(validatedParams.values)});`
|
||||
];
|
||||
|
||||
return {
|
||||
code,
|
||||
action: () => locator.selectOption(validatedParams.values).then(() => {}),
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const screenshotSchema = z.object({
|
||||
raw: z.boolean().optional().describe('Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.'),
|
||||
element: z.string().optional().describe('Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.'),
|
||||
ref: z.string().optional().describe('Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.'),
|
||||
}).refine(data => {
|
||||
return !!data.element === !!data.ref;
|
||||
}, {
|
||||
message: 'Both element and ref must be provided or neither.',
|
||||
path: ['ref', 'element']
|
||||
});
|
||||
|
||||
const screenshot: Tool = {
|
||||
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.currentTabOrDie();
|
||||
const snapshot = tab.snapshotOrDie();
|
||||
const fileType = validatedParams.raw ? 'png' : 'jpeg';
|
||||
const fileName = path.join(os.tmpdir(), sanitizeForFilePath(`page-${new Date().toISOString()}`)) + `.${fileType}`;
|
||||
const options: playwright.PageScreenshotOptions = { type: fileType, quality: fileType === 'png' ? undefined : 50, scale: 'css', path: fileName };
|
||||
const isElementScreenshot = validatedParams.element && validatedParams.ref;
|
||||
|
||||
const code = [
|
||||
`// Screenshot ${isElementScreenshot ? validatedParams.element : 'viewport'} and save it as ${fileName}`,
|
||||
];
|
||||
|
||||
const locator = validatedParams.ref ? snapshot.refLocator(validatedParams.ref) : null;
|
||||
|
||||
if (locator)
|
||||
code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
|
||||
else
|
||||
code.push(`await page.screenshot(${javascript.formatObject(options)});`);
|
||||
|
||||
const action = async () => {
|
||||
const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
|
||||
return {
|
||||
content: [{
|
||||
type: 'image' as 'image',
|
||||
data: screenshot.toString('base64'),
|
||||
mimeType: fileType === 'png' ? 'image/png' : 'image/jpeg',
|
||||
}]
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
code,
|
||||
action,
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
export default [
|
||||
snapshot,
|
||||
click,
|
||||
drag,
|
||||
hover,
|
||||
type,
|
||||
selectOption,
|
||||
screenshot,
|
||||
];
|
||||
@@ -1,137 +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 => {
|
||||
await context.ensureTab();
|
||||
return {
|
||||
code: [`// <internal code to list tabs>`],
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: false,
|
||||
resultOverride: {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: await context.listTabsMarkdown(),
|
||||
}],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const selectTabSchema = z.object({
|
||||
index: z.number().describe('The index of the tab to select'),
|
||||
});
|
||||
|
||||
const selectTab: ToolFactory = captureSnapshot => ({
|
||||
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 code = [
|
||||
`// <internal code to select tab ${validatedParams.index}>`,
|
||||
];
|
||||
|
||||
return {
|
||||
code,
|
||||
captureSnapshot,
|
||||
waitForNetwork: false
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const newTabSchema = z.object({
|
||||
url: z.string().optional().describe('The URL to navigate to in the new tab. If not provided, the new tab will be blank.'),
|
||||
});
|
||||
|
||||
const newTab: ToolFactory = captureSnapshot => ({
|
||||
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.currentTabOrDie().navigate(validatedParams.url);
|
||||
|
||||
const code = [
|
||||
`// <internal code to open a new tab>`,
|
||||
];
|
||||
return {
|
||||
code,
|
||||
captureSnapshot,
|
||||
waitForNetwork: false
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
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 code = [
|
||||
`// <internal code to close tab ${validatedParams.index}>`,
|
||||
];
|
||||
return {
|
||||
code,
|
||||
captureSnapshot,
|
||||
waitForNetwork: false
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default (captureSnapshot: boolean) => [
|
||||
listTabs,
|
||||
newTab(captureSnapshot),
|
||||
selectTab(captureSnapshot),
|
||||
closeTab(captureSnapshot),
|
||||
];
|
||||
@@ -1,60 +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';
|
||||
import type * as playwright from 'playwright';
|
||||
export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install';
|
||||
|
||||
export type ToolSchema = {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: JsonSchema7Type;
|
||||
};
|
||||
|
||||
export type FileUploadModalState = {
|
||||
type: 'fileChooser';
|
||||
description: string;
|
||||
fileChooser: playwright.FileChooser;
|
||||
};
|
||||
|
||||
export type DialogModalState = {
|
||||
type: 'dialog';
|
||||
description: string;
|
||||
dialog: playwright.Dialog;
|
||||
};
|
||||
|
||||
export type ModalState = FileUploadModalState | DialogModalState;
|
||||
|
||||
export type ToolActionResult = { content?: (ImageContent | TextContent)[] } | undefined | void;
|
||||
|
||||
export type ToolResult = {
|
||||
code: string[];
|
||||
action?: () => Promise<ToolActionResult>;
|
||||
captureSnapshot: boolean;
|
||||
waitForNetwork: boolean;
|
||||
resultOverride?: ToolActionResult;
|
||||
};
|
||||
|
||||
export type Tool = {
|
||||
capability: ToolCapability;
|
||||
schema: ToolSchema;
|
||||
clearsModalState?: ModalState['type'];
|
||||
handle: (context: Context, params?: Record<string, any>) => Promise<ToolResult>;
|
||||
};
|
||||
|
||||
export type ToolFactory = (snapshot: boolean) => Tool;
|
||||
@@ -1,75 +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';
|
||||
import type { Context } from '../context';
|
||||
|
||||
export async function waitForCompletion<R>(context: Context, page: playwright.Page, callback: () => Promise<R>): Promise<R> {
|
||||
const requests = new Set<playwright.Request>();
|
||||
let frameNavigated = false;
|
||||
let waitCallback: () => void = () => {};
|
||||
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 context.waitForTimeout(1000);
|
||||
return result;
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeForFilePath(s: string) {
|
||||
return s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
|
||||
}
|
||||
@@ -22,7 +22,9 @@ test('test snapshot tool list', async ({ client }) => {
|
||||
'browser_click',
|
||||
'browser_console_messages',
|
||||
'browser_drag',
|
||||
'browser_evaluate',
|
||||
'browser_file_upload',
|
||||
'browser_fill_form',
|
||||
'browser_handle_dialog',
|
||||
'browser_hover',
|
||||
'browser_select_option',
|
||||
@@ -30,64 +32,75 @@ test('test snapshot tool list', async ({ client }) => {
|
||||
'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_resize',
|
||||
'browser_screen_capture',
|
||||
'browser_screen_click',
|
||||
'browser_screen_drag',
|
||||
'browser_screen_move_mouse',
|
||||
'browser_screen_type',
|
||||
'browser_tab_close',
|
||||
'browser_tab_list',
|
||||
'browser_tab_new',
|
||||
'browser_tab_select',
|
||||
'browser_wait',
|
||||
'browser_snapshot',
|
||||
'browser_tabs',
|
||||
'browser_take_screenshot',
|
||||
'browser_wait_for',
|
||||
]));
|
||||
});
|
||||
|
||||
test('test resources list', async ({ client }) => {
|
||||
const { resources } = await client.listResources();
|
||||
expect(resources).toEqual([]);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -1,56 +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('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>',
|
||||
},
|
||||
})).toContainTextContent(`- text: Hello, world!`);
|
||||
});
|
||||
|
||||
test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
|
||||
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Hello, world!',
|
||||
ref: 'f0',
|
||||
},
|
||||
})).toHaveTextContent(`Error: No current snapshot available. Capture a snapshot of navigate to a new location first.`);
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_snapshot',
|
||||
arguments: {},
|
||||
})).toHaveTextContent(`
|
||||
- Ran Playwright code:
|
||||
\`\`\`js
|
||||
// <internal code to capture accessibility snapshot>
|
||||
\`\`\`
|
||||
|
||||
- Page URL: data:text/html,hello world
|
||||
- Page Title:
|
||||
- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- text: hello world
|
||||
\`\`\`
|
||||
`);
|
||||
});
|
||||
@@ -16,27 +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(`// Click World`);
|
||||
})).toHaveResponse({
|
||||
code: `await page.getByRole('button', { name: 'Submit' }).click();`,
|
||||
pageState: expect.stringContaining(`- button "Submit" ${mcpBrowser !== 'webkit' || process.platform === 'linux' ? '[active] ' : ''}[ref=e2]`),
|
||||
});
|
||||
});
|
||||
@@ -16,195 +16,17 @@
|
||||
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('browser_navigate', async ({ client }) => {
|
||||
test('browser_navigate', async ({ client, server }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
})).toHaveTextContent(`
|
||||
- Ran Playwright code:
|
||||
\`\`\`js
|
||||
// Navigate to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
||||
await page.goto('data:text/html,<html><title>Title</title><body>Hello, world!</body></html>');
|
||||
\`\`\`
|
||||
|
||||
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toHaveResponse({
|
||||
code: `await page.goto('${server.HELLO_WORLD}');`,
|
||||
pageState: `- Page URL: ${server.HELLO_WORLD}
|
||||
- Page Title: Title
|
||||
- Page Snapshot
|
||||
- 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(`
|
||||
- Ran Playwright code:
|
||||
\`\`\`js
|
||||
// Click Submit button
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
\`\`\`
|
||||
|
||||
- Page URL: data:text/html,<html><title>Title</title><button>Submit</button></html>
|
||||
- Page Title: Title
|
||||
- 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(`
|
||||
- Ran Playwright code:
|
||||
\`\`\`js
|
||||
// Select options [bar] in Select
|
||||
await page.getByRole('combobox').selectOption(['bar']);
|
||||
\`\`\`
|
||||
|
||||
- Page URL: data:text/html,<html><title>Title</title><select><option value="foo">Foo</option><option value="bar">Bar</option></select></html>
|
||||
- Page Title: Title
|
||||
- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- combobox [ref=s2e3]:
|
||||
- option "Foo" [ref=s2e4]
|
||||
- option "Bar" [selected] [ref=s2e5]
|
||||
\`\`\`
|
||||
`);
|
||||
});
|
||||
|
||||
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(`
|
||||
- Ran Playwright code:
|
||||
\`\`\`js
|
||||
// Select options [bar, baz] in Select
|
||||
await page.getByRole('listbox').selectOption(['bar', 'baz']);
|
||||
\`\`\`
|
||||
|
||||
- Page URL: data:text/html,<html><title>Title</title><select multiple><option value="foo">Foo</option><option value="bar">Bar</option><option value="baz">Baz</option></select></html>
|
||||
- Page Title: Title
|
||||
- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- listbox [ref=s2e3]:
|
||||
- option "Foo" [ref=s2e4]
|
||||
- option "Bar" [selected] [ref=s2e5]
|
||||
- option "Baz" [selected] [ref=s2e6]
|
||||
\`\`\`
|
||||
`);
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
expect(await client.callTool({
|
||||
name: 'browser_console_messages',
|
||||
arguments: {},
|
||||
})).toHaveTextContent('[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,
|
||||
},
|
||||
});
|
||||
expect(await client.callTool({
|
||||
name: 'browser_console_messages',
|
||||
arguments: {},
|
||||
})).toHaveTextContent([
|
||||
'[LOG] Key pressed: H Text: ',
|
||||
'[LOG] Key pressed: i Text: H',
|
||||
'[LOG] Key pressed: ! Text: Hi',
|
||||
'[LOG] Key pressed: Enter Text: Hi!',
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
test('browser_resize', async ({ client }) => {
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Resize Test</title><body><div id="size">Waiting for resize...</div><script>new ResizeObserver(() => { document.getElementById("size").textContent = `Window size: ${window.innerWidth}x${window.innerHeight}`; }).observe(document.body);</script></body></html>',
|
||||
},
|
||||
});
|
||||
|
||||
const response = await client.callTool({
|
||||
name: 'browser_resize',
|
||||
arguments: {
|
||||
width: 390,
|
||||
height: 780,
|
||||
},
|
||||
});
|
||||
expect(response).toContainTextContent(`- Ran Playwright code:
|
||||
\`\`\`js
|
||||
// Resize browser window to 390x780
|
||||
await page.setViewportSize({ width: 390, height: 780 });
|
||||
\`\`\``);
|
||||
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent('Window size: 390x780');
|
||||
- generic [active] [ref=e1]: Hello, world!
|
||||
\`\`\``,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,192 +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';
|
||||
|
||||
// https://github.com/microsoft/playwright/issues/35663
|
||||
test.skip(({ mcpBrowser, mcpHeadless }) => mcpBrowser === 'webkit' && mcpHeadless);
|
||||
|
||||
test('alert dialog', async ({ client }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><button onclick="alert(\'Alert\')">Button</button></html>',
|
||||
},
|
||||
})).toContainTextContent('- button "Button" [ref=s1e3]');
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Button',
|
||||
ref: 's1e3',
|
||||
},
|
||||
})).toHaveTextContent(`- Ran Playwright code:
|
||||
\`\`\`js
|
||||
// Click Button
|
||||
await page.getByRole('button', { name: 'Button' }).click();
|
||||
\`\`\`
|
||||
|
||||
### Modal state
|
||||
- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`);
|
||||
|
||||
const result = await client.callTool({
|
||||
name: 'browser_handle_dialog',
|
||||
arguments: {
|
||||
accept: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).not.toContainTextContent('### Modal state');
|
||||
expect(result).toHaveTextContent(`- Ran Playwright code:
|
||||
\`\`\`js
|
||||
// <internal code to handle "alert" dialog>
|
||||
\`\`\`
|
||||
|
||||
- Page URL: data:text/html,<html><title>Title</title><button onclick="alert('Alert')">Button</button></html>
|
||||
- Page Title: Title
|
||||
- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- button "Button" [ref=s2e3]
|
||||
\`\`\`
|
||||
`);
|
||||
});
|
||||
|
||||
test('two alert dialogs', async ({ client }) => {
|
||||
test.fixme(true, 'Race between the dialog and ariaSnapshot');
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><button onclick="alert(\'Alert 1\');alert(\'Alert 2\');">Button</button></html>',
|
||||
},
|
||||
})).toContainTextContent('- button "Button" [ref=s1e3]');
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Button',
|
||||
ref: 's1e3',
|
||||
},
|
||||
})).toHaveTextContent(`- Ran Playwright code:
|
||||
\`\`\`js
|
||||
// Click Button
|
||||
await page.getByRole('button', { name: 'Button' }).click();
|
||||
\`\`\`
|
||||
|
||||
### Modal state
|
||||
- ["alert" dialog with message "Alert 1"]: can be handled by the "browser_handle_dialog" tool`);
|
||||
|
||||
const result = await client.callTool({
|
||||
name: 'browser_handle_dialog',
|
||||
arguments: {
|
||||
accept: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).not.toContainTextContent('### Modal state');
|
||||
});
|
||||
|
||||
test('confirm dialog (true)', async ({ client }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><button onclick="document.body.textContent = confirm(\'Confirm\')">Button</button></html>',
|
||||
},
|
||||
})).toContainTextContent('- button "Button" [ref=s1e3]');
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Button',
|
||||
ref: 's1e3',
|
||||
},
|
||||
})).toContainTextContent(`### Modal state
|
||||
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
|
||||
|
||||
const result = await client.callTool({
|
||||
name: 'browser_handle_dialog',
|
||||
arguments: {
|
||||
accept: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).not.toContainTextContent('### Modal state');
|
||||
expect(result).toContainTextContent('// <internal code to handle "confirm" dialog>');
|
||||
expect(result).toContainTextContent(`- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- text: "true"
|
||||
\`\`\``);
|
||||
});
|
||||
|
||||
test('confirm dialog (false)', async ({ client }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><button onclick="document.body.textContent = confirm(\'Confirm\')">Button</button></html>',
|
||||
},
|
||||
})).toContainTextContent('- button "Button" [ref=s1e3]');
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Button',
|
||||
ref: 's1e3',
|
||||
},
|
||||
})).toContainTextContent(`### Modal state
|
||||
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
|
||||
|
||||
const result = await client.callTool({
|
||||
name: 'browser_handle_dialog',
|
||||
arguments: {
|
||||
accept: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toContainTextContent(`- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- text: "false"
|
||||
\`\`\``);
|
||||
});
|
||||
|
||||
test('prompt dialog', async ({ client }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><button onclick="document.body.textContent = prompt(\'Prompt\')">Button</button></html>',
|
||||
},
|
||||
})).toContainTextContent('- button "Button" [ref=s1e3]');
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Button',
|
||||
ref: 's1e3',
|
||||
},
|
||||
})).toContainTextContent(`### Modal state
|
||||
- ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool`);
|
||||
|
||||
const result = await client.callTool({
|
||||
name: 'browser_handle_dialog',
|
||||
arguments: {
|
||||
accept: true,
|
||||
promptText: 'Answer',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toContainTextContent(`- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- text: Answer
|
||||
\`\`\``);
|
||||
});
|
||||
@@ -1,77 +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';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
test('browser_file_upload', async ({ client }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><input type="file" /><button>Button</button></html>',
|
||||
},
|
||||
})).toContainTextContent('- textbox [ref=s1e3]');
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Textbox',
|
||||
ref: 's1e3',
|
||||
},
|
||||
})).toContainTextContent(`### Modal state
|
||||
- [File chooser]: can be handled by the "browser_file_upload" tool`);
|
||||
|
||||
const filePath = test.info().outputPath('test.txt');
|
||||
await fs.writeFile(filePath, 'Hello, world!');
|
||||
|
||||
{
|
||||
const response = await client.callTool({
|
||||
name: 'browser_file_upload',
|
||||
arguments: {
|
||||
paths: [filePath],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response).not.toContainTextContent('### Modal state');
|
||||
expect(response).toContainTextContent('textbox [ref=s3e3]: C:\\fakepath\\test.txt');
|
||||
}
|
||||
|
||||
{
|
||||
const response = await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Textbox',
|
||||
ref: 's3e3',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response).toContainTextContent('- [File chooser]: can be handled by the \"browser_file_upload\" tool');
|
||||
}
|
||||
|
||||
{
|
||||
const response = await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Button',
|
||||
ref: 's4e4',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response).toContainTextContent(`Tool "browser_click" does not handle the modal state.
|
||||
### Modal state
|
||||
- [File chooser]: can be handled by the "browser_file_upload" tool`);
|
||||
}
|
||||
});
|
||||
@@ -14,60 +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 { spawn } from 'child_process';
|
||||
import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { TestServer } from './testserver/index';
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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;
|
||||
visionClient: Client;
|
||||
startClient: (options?: { args?: string[] }) => Promise<Client>;
|
||||
startClient: StartClient;
|
||||
wsEndpoint: string;
|
||||
cdpEndpoint: string;
|
||||
cdpServer: CDPServer;
|
||||
server: TestServer;
|
||||
httpsServer: TestServer;
|
||||
mcpHeadless: boolean;
|
||||
};
|
||||
|
||||
type WorkerFixtures = {
|
||||
mcpHeadless: boolean;
|
||||
mcpBrowser: string | undefined;
|
||||
_workerServers: { server: TestServer, httpsServer: TestServer };
|
||||
};
|
||||
|
||||
export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
|
||||
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 ({ mcpHeadless, mcpBrowser }, use, testInfo) => {
|
||||
const userDataDir = testInfo.outputPath('user-data-dir');
|
||||
let client: StdioClientTransport | undefined;
|
||||
|
||||
use(async options => {
|
||||
const args = ['--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 { 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();
|
||||
});
|
||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||
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) => {
|
||||
@@ -76,77 +124,109 @@ export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
|
||||
await browserServer.close();
|
||||
},
|
||||
|
||||
cdpEndpoint: async ({ }, use, testInfo) => {
|
||||
const port = 3200 + (+process.env.TEST_PARALLEL_INDEX!);
|
||||
const executablePath = chromium.executablePath();
|
||||
const browserProcess = spawn(executablePath, [
|
||||
`--user-data-dir=${testInfo.outputPath('user-data-dir')}`,
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--no-first-run`,
|
||||
`--no-sandbox`,
|
||||
`--headless`,
|
||||
`data:text/html,hello world`,
|
||||
], {
|
||||
stdio: 'pipe',
|
||||
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}`,
|
||||
],
|
||||
});
|
||||
return browserContext;
|
||||
}
|
||||
});
|
||||
await new Promise<void>(resolve => {
|
||||
browserProcess.stderr.on('data', data => {
|
||||
if (data.toString().includes('DevTools listening on '))
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await use(`http://localhost:${port}`);
|
||||
browserProcess.kill();
|
||||
await browserContext?.close();
|
||||
},
|
||||
|
||||
mcpHeadless: [async ({ headless }, use) => {
|
||||
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' }],
|
||||
|
||||
mcpBrowser: ['chromium', { option: true, 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());
|
||||
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]);
|
||||
}
|
||||
if (isNot)
|
||||
expect(parsed).not.toEqual(expect.objectContaining(object));
|
||||
else
|
||||
expect(parsed).toEqual(expect.objectContaining(object));
|
||||
} catch (e) {
|
||||
return {
|
||||
pass: isNot,
|
||||
@@ -159,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,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 { 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',
|
||||
})).toContainTextContent('No open pages available');
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
})).toContainTextContent(`- 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,23 +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('browser_console_messages', async ({ client }) => {
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><script>console.log("Hello, world!");console.error("Error"); </script></html>',
|
||||
},
|
||||
});
|
||||
|
||||
const resource = await client.callTool({
|
||||
name: 'browser_console_messages',
|
||||
arguments: {},
|
||||
});
|
||||
expect(resource).toHaveTextContent([
|
||||
'[LOG] Hello, world!',
|
||||
'[ERROR] Error',
|
||||
].join('\n'));
|
||||
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,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 { 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, mcpBrowser }) => {
|
||||
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
})).toContainTextContent(`- text: Hello, world!`);
|
||||
|
||||
const response = await client.callTool({
|
||||
name: 'browser_pdf_save',
|
||||
});
|
||||
expect(response).toHaveTextContent(/Save page as.*page-[^:]+.pdf/);
|
||||
});
|
||||
@@ -1,72 +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('browser_take_screenshot (viewport)', async ({ client }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
})).toContainTextContent(`Navigate to data:text/html`);
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_take_screenshot',
|
||||
arguments: {},
|
||||
})).toEqual({
|
||||
content: [
|
||||
{
|
||||
data: expect.any(String),
|
||||
mimeType: 'image/jpeg',
|
||||
type: 'image',
|
||||
},
|
||||
{
|
||||
text: expect.stringContaining(`Screenshot viewport and save it as`),
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('browser_take_screenshot (element)', async ({ client }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><button>Hello, world!</button></html>',
|
||||
},
|
||||
})).toContainTextContent(`[ref=s1e3]`);
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_take_screenshot',
|
||||
arguments: {
|
||||
element: 'hello button',
|
||||
ref: 's1e3',
|
||||
},
|
||||
})).toEqual({
|
||||
content: [
|
||||
{
|
||||
data: expect.any(String),
|
||||
mimeType: 'image/jpeg',
|
||||
type: 'image',
|
||||
},
|
||||
{
|
||||
text: expect.stringContaining(`page.getByRole('button', { name: 'Hello, world!' }).screenshot`),
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -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,157 +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('list initial tabs', async ({ client }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_tab_list',
|
||||
})).toHaveTextContent(`### Open tabs
|
||||
- 1: (current) [] (about:blank)`);
|
||||
});
|
||||
|
||||
test('list first tab', async ({ client }) => {
|
||||
await createTab(client, 'Tab one', 'Body one');
|
||||
expect(await client.callTool({
|
||||
name: 'browser_tab_list',
|
||||
})).toHaveTextContent(`### Open tabs
|
||||
- 1: [] (about:blank)
|
||||
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`);
|
||||
});
|
||||
|
||||
test('create new tab', async ({ client }) => {
|
||||
expect(await createTab(client, 'Tab one', 'Body one')).toHaveTextContent(`
|
||||
- Ran Playwright code:
|
||||
\`\`\`js
|
||||
// <internal code to open a new tab>
|
||||
\`\`\`
|
||||
|
||||
### Open tabs
|
||||
- 1: [] (about:blank)
|
||||
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||
|
||||
### Current tab
|
||||
- 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(`
|
||||
- Ran Playwright code:
|
||||
\`\`\`js
|
||||
// <internal code to open a new tab>
|
||||
\`\`\`
|
||||
|
||||
### Open tabs
|
||||
- 1: [] (about:blank)
|
||||
- 2: [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||
- 3: (current) [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
|
||||
|
||||
### Current tab
|
||||
- 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(`
|
||||
- Ran Playwright code:
|
||||
\`\`\`js
|
||||
// <internal code to select tab 2>
|
||||
\`\`\`
|
||||
|
||||
### Open tabs
|
||||
- 1: [] (about:blank)
|
||||
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||
- 3: [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
|
||||
|
||||
### Current tab
|
||||
- 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(`
|
||||
- Ran Playwright code:
|
||||
\`\`\`js
|
||||
// <internal code to close tab 3>
|
||||
\`\`\`
|
||||
|
||||
### Open tabs
|
||||
- 1: [] (about:blank)
|
||||
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||
|
||||
### Current tab
|
||||
- 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);
|
||||
});
|
||||
@@ -1,178 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
// @ts-check
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const commonTools = require('../lib/tools/common').default;
|
||||
const consoleTools = require('../lib/tools/console').default;
|
||||
const dialogsTools = require('../lib/tools/dialogs').default;
|
||||
const filesTools = require('../lib/tools/files').default;
|
||||
const installTools = require('../lib/tools/install').default;
|
||||
const keyboardTools = require('../lib/tools/keyboard').default;
|
||||
const navigateTools = require('../lib/tools/navigate').default;
|
||||
const pdfTools = require('../lib/tools/pdf').default;
|
||||
const snapshotTools = require('../lib/tools/snapshot').default;
|
||||
const tabsTools = require('../lib/tools/tabs').default;
|
||||
const screenTools = require('../lib/tools/screen').default;
|
||||
|
||||
// Category definitions for tools
|
||||
const categories = {
|
||||
'Snapshot-based Interactions': [
|
||||
...snapshotTools,
|
||||
],
|
||||
'Vision-based Interactions': [
|
||||
...screenTools
|
||||
],
|
||||
'Tab Management': [
|
||||
...tabsTools(true),
|
||||
],
|
||||
'Navigation': [
|
||||
...navigateTools(true),
|
||||
],
|
||||
'Keyboard': [
|
||||
...keyboardTools(true)
|
||||
],
|
||||
'Console': [
|
||||
...consoleTools
|
||||
],
|
||||
'Files and Media': [
|
||||
...filesTools(true),
|
||||
...pdfTools
|
||||
],
|
||||
'Utilities': [
|
||||
...commonTools(true),
|
||||
...installTools,
|
||||
...dialogsTools(true),
|
||||
],
|
||||
};
|
||||
|
||||
const kStartMarker = `<!--- Generated by ${path.basename(__filename)} -->`;
|
||||
const kEndMarker = `<!--- End of generated section -->`;
|
||||
|
||||
/**
|
||||
* @param {ParsedToolSchema} tool
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatToolForReadme(tool) {
|
||||
const lines = /** @type {string[]} */ ([]);
|
||||
lines.push(`<!-- NOTE: This has been generated via ${path.basename(__filename)} -->\n\n`);
|
||||
lines.push(`- **${tool.name}**\n`);
|
||||
lines.push(` - Description: ${tool.description}\n`);
|
||||
|
||||
if (tool.parameters && tool.parameters.length > 0) {
|
||||
lines.push(` - Parameters:\n`);
|
||||
tool.parameters.forEach(param => {
|
||||
const meta = /** @type {string[]} */ ([]);
|
||||
if (param.type)
|
||||
meta.push(param.type);
|
||||
if (param.optional)
|
||||
meta.push('optional');
|
||||
lines.push(` - \`${param.name}\` ${meta.length ? `(${meta.join(', ')})` : ''}: ${param.description}\n`);
|
||||
});
|
||||
} else {
|
||||
lines.push(` - Parameters: None\n`);
|
||||
}
|
||||
|
||||
lines.push('\n');
|
||||
return lines.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* name: any;
|
||||
* description: any;
|
||||
* parameters: {
|
||||
* name: string;
|
||||
* description: string;
|
||||
* optional: boolean;
|
||||
* type: string;
|
||||
* }[];
|
||||
*}} ParsedToolSchema
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {import('../src/tools/tool').ToolSchema} schema
|
||||
* @returns {ParsedToolSchema}
|
||||
*/
|
||||
function processToolSchema(schema) {
|
||||
const inputSchema = /** @type {import('zod-to-json-schema').JsonSchema7ObjectType} */ (schema.inputSchema || {});
|
||||
if (inputSchema.type !== 'object')
|
||||
throw new Error(`Tool ${schema.name} input schema is not an object`);
|
||||
|
||||
// In JSON Schema, properties are considered optional unless listed in the required array
|
||||
const requiredParams = inputSchema?.required || [];
|
||||
|
||||
const parameters = Object.entries(inputSchema.properties).map(([name, prop]) => {
|
||||
return {
|
||||
name,
|
||||
description: prop.description || '',
|
||||
optional: !requiredParams.includes(name),
|
||||
type: /** @type {any} */ (prop).type,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
name: schema.name,
|
||||
description: schema.description,
|
||||
parameters
|
||||
};
|
||||
}
|
||||
|
||||
async function updateReadme() {
|
||||
console.log('Loading tool information from compiled modules...');
|
||||
|
||||
// Count the tools processed
|
||||
const totalTools = Object.values(categories).flat().length;
|
||||
console.log(`Found ${totalTools} tools`);
|
||||
|
||||
const generatedLines = /** @type {string[]} */ ([]);
|
||||
|
||||
for (const [category, categoryTools] of Object.entries(categories)) {
|
||||
generatedLines.push(`### ${category}\n\n`);
|
||||
for (const tool of categoryTools) {
|
||||
const scheme = processToolSchema(tool.schema);
|
||||
generatedLines.push(formatToolForReadme(scheme));
|
||||
}
|
||||
}
|
||||
|
||||
const readmePath = path.join(__dirname, '..', 'README.md');
|
||||
const readmeContent = await fs.promises.readFile(readmePath, 'utf-8');
|
||||
const startMarker = readmeContent.indexOf(kStartMarker);
|
||||
const endMarker = readmeContent.indexOf(kEndMarker);
|
||||
if (startMarker === -1 || endMarker === -1)
|
||||
throw new Error('Markers for generated section not found in README');
|
||||
|
||||
const newReadmeContent = [
|
||||
readmeContent.slice(0, startMarker),
|
||||
kStartMarker + '\n\n',
|
||||
generatedLines.join(''),
|
||||
kEndMarker,
|
||||
readmeContent.slice(endMarker + kEndMarker.length),
|
||||
].join('');
|
||||
|
||||
// Write updated README
|
||||
await fs.promises.writeFile(readmePath, newReadmeContent, 'utf-8');
|
||||
console.log('README updated successfully');
|
||||
}
|
||||
|
||||
// Run the update
|
||||
updateReadme().catch(err => {
|
||||
console.error('Error updating README:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user