Compare commits
189 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 |
62
.github/workflows/ci.yml
vendored
62
.github/workflows/ci.yml
vendored
@@ -11,14 +11,13 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js 18
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '20'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
- run: npm run build
|
|
||||||
- name: Run ESLint
|
- name: Run ESLint
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
- name: Ensure no changes
|
- name: Ensure no changes
|
||||||
@@ -28,44 +27,35 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
os: [ubuntu-latest, macos-15, windows-latest]
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js 18
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
# https://github.com/microsoft/playwright-mcp/issues/344
|
node-version: '20'
|
||||||
node-version: '18.19'
|
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
- name: Playwright install
|
- name: Playwright install
|
||||||
run: npx playwright install --with-deps
|
run: npx playwright install --with-deps
|
||||||
- name: Install MS Edge
|
|
||||||
# MS Edge is not preinstalled on macOS runners.
|
|
||||||
if: ${{ matrix.os == 'macos-latest' }}
|
|
||||||
run: npx playwright install msedge
|
|
||||||
- name: Build
|
|
||||||
run: npm run build
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npm test
|
run: npm run test
|
||||||
|
|
||||||
test_docker:
|
test_docker:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js 18
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '20'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
- name: Playwright install
|
- name: Playwright install
|
||||||
run: npx playwright install --with-deps chromium
|
run: npx playwright install --with-deps chromium
|
||||||
- name: Build
|
|
||||||
run: npm run build
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
@@ -83,3 +73,39 @@ jobs:
|
|||||||
npm run test -- --project=chromium-docker
|
npm run test -- --project=chromium-docker
|
||||||
env:
|
env:
|
||||||
MCP_IN_DOCKER: 1
|
MCP_IN_DOCKER: 1
|
||||||
|
|
||||||
|
test_extension:
|
||||||
|
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
|
||||||
48
.github/workflows/publish.yml
vendored
48
.github/workflows/publish.yml
vendored
@@ -16,7 +16,6 @@ jobs:
|
|||||||
registry-url: https://registry.npmjs.org/
|
registry-url: https://registry.npmjs.org/
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npx playwright install --with-deps
|
- run: npx playwright install --with-deps
|
||||||
- run: npm run build
|
|
||||||
- run: npm run lint
|
- run: npm run lint
|
||||||
- run: npm run ctest
|
- run: npm run ctest
|
||||||
- run: npm publish --provenance
|
- run: npm publish --provenance
|
||||||
@@ -44,6 +43,7 @@ jobs:
|
|||||||
- name: Login to ACR
|
- name: Login to ACR
|
||||||
run: az acr login --name playwright
|
run: az acr login --name playwright
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
|
id: build-push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
@@ -53,3 +53,49 @@ jobs:
|
|||||||
tags: |
|
tags: |
|
||||||
playwright.azurecr.io/public/playwright/mcp:${{ github.event.release.tag_name }}
|
playwright.azurecr.io/public/playwright/mcp:${{ github.event.release.tag_name }}
|
||||||
playwright.azurecr.io/public/playwright/mcp:latest
|
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
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,8 +1,10 @@
|
|||||||
lib/
|
lib/
|
||||||
|
dist/
|
||||||
node_modules/
|
node_modules/
|
||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
.vscode/mcp.json
|
.vscode/mcp.json
|
||||||
|
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.env
|
||||||
|
sessions/
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
**/*
|
**/*
|
||||||
README.md
|
README.md
|
||||||
LICENSE
|
LICENSE
|
||||||
!lib/**/*.js
|
|
||||||
!cli.js
|
!cli.js
|
||||||
!index.*
|
!index.*
|
||||||
!config.d.ts
|
!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.
|
||||||
@@ -32,10 +32,6 @@ RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \
|
|||||||
|
|
||||||
# Copy the rest of the app
|
# Copy the rest of the app
|
||||||
COPY *.json *.js *.ts .
|
COPY *.json *.js *.ts .
|
||||||
COPY src src/
|
|
||||||
|
|
||||||
# Build the app
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# ------------------------------
|
# ------------------------------
|
||||||
# Browser
|
# Browser
|
||||||
@@ -63,7 +59,6 @@ USER ${USERNAME}
|
|||||||
|
|
||||||
COPY --from=browser --chown=${USERNAME}:${USERNAME} ${PLAYWRIGHT_BROWSERS_PATH} ${PLAYWRIGHT_BROWSERS_PATH}
|
COPY --from=browser --chown=${USERNAME}:${USERNAME} ${PLAYWRIGHT_BROWSERS_PATH} ${PLAYWRIGHT_BROWSERS_PATH}
|
||||||
COPY --chown=${USERNAME}:${USERNAME} cli.js package.json ./
|
COPY --chown=${USERNAME}:${USERNAME} cli.js package.json ./
|
||||||
COPY --from=builder --chown=${USERNAME}:${USERNAME} /app/lib /app/lib
|
|
||||||
|
|
||||||
# Run in headless and only with chromium (other browsers need more dependencies not included in this image)
|
# Run in headless and only with chromium (other browsers need more dependencies not included in this image)
|
||||||
ENTRYPOINT ["node", "cli.js", "--headless", "--browser", "chromium", "--no-sandbox"]
|
ENTRYPOINT ["node", "cli.js", "--headless", "--browser", "chromium", "--no-sandbox"]
|
||||||
|
|||||||
739
README.md
739
README.md
@@ -10,7 +10,7 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
|
|||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
- Node.js 18 or newer
|
- Node.js 18 or newer
|
||||||
- VS Code, Cursor, Windsurf, Claude Desktop or any other MCP client
|
- VS Code, Cursor, Windsurf, Claude Desktop, Goose or any other MCP client
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
// Generate using:
|
// Generate using:
|
||||||
@@ -19,7 +19,9 @@ node utils/generate-links.js
|
|||||||
|
|
||||||
### Getting started
|
### Getting started
|
||||||
|
|
||||||
First, install the Playwright MCP server with your client. A typical configuration looks like this:
|
First, install the Playwright MCP server with your client.
|
||||||
|
|
||||||
|
**Standard config** works in most of the tools:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
{
|
{
|
||||||
@@ -37,9 +39,123 @@ First, install the Playwright MCP server with your client. A typical configurati
|
|||||||
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
|
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
|
||||||
|
|
||||||
|
|
||||||
<details><summary><b>Install in VS Code</b></summary>
|
<details>
|
||||||
|
<summary>Claude Code</summary>
|
||||||
|
|
||||||
You can also install the Playwright MCP server using the VS Code CLI:
|
Use the Claude Code CLI to add the Playwright MCP server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp add playwright npx @playwright/mcp@latest
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Claude Desktop</summary>
|
||||||
|
|
||||||
|
Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user), use the standard config above.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Codex</summary>
|
||||||
|
|
||||||
|
Create or edit the configuration file `~/.codex/config.toml` and add:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[mcp_servers.playwright]
|
||||||
|
command = "npx"
|
||||||
|
args = ["@playwright/mcp@latest"]
|
||||||
|
```
|
||||||
|
|
||||||
|
For more information, see the [Codex MCP documentation](https://github.com/openai/codex/blob/main/codex-rs/config.md#mcp_servers).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Cursor</summary>
|
||||||
|
|
||||||
|
#### Click the button to install:
|
||||||
|
|
||||||
|
[<img src="https://cursor.com/deeplink/mcp-install-dark.svg" alt="Install in Cursor">](cursor://anysphere.cursor-deeplink/mcp/install?name=Playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
|
||||||
|
|
||||||
|
#### Or install manually:
|
||||||
|
|
||||||
|
Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, use `command` type with the command `npx @playwright/mcp@latest`. You can also verify config or add command like arguments via clicking `Edit`.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Gemini CLI</summary>
|
||||||
|
|
||||||
|
Follow the MCP install [guide](https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md#configure-the-mcp-server-in-settingsjson), use the standard config above.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Goose</summary>
|
||||||
|
|
||||||
|
#### Click the button to install:
|
||||||
|
|
||||||
|
[](https://block.github.io/goose/extension?cmd=npx&arg=%40playwright%2Fmcp%40latest&id=playwright&name=Playwright&description=Interact%20with%20web%20pages%20through%20structured%20accessibility%20snapshots%20using%20Playwright)
|
||||||
|
|
||||||
|
#### Or install manually:
|
||||||
|
|
||||||
|
Go to `Advanced settings` -> `Extensions` -> `Add custom extension`. Name to your liking, use type `STDIO`, and set the `command` to `npx @playwright/mcp`. Click "Add Extension".
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>LM Studio</summary>
|
||||||
|
|
||||||
|
#### Click the button to install:
|
||||||
|
|
||||||
|
[](https://lmstudio.ai/install-mcp?name=playwright&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyJAcGxheXdyaWdodC9tY3BAbGF0ZXN0Il19)
|
||||||
|
|
||||||
|
#### Or install manually:
|
||||||
|
|
||||||
|
Go to `Program` in the right sidebar -> `Install` -> `Edit mcp.json`. Use the standard config above.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>opencode</summary>
|
||||||
|
|
||||||
|
Follow the MCP Servers [documentation](https://opencode.ai/docs/mcp-servers/). For example in `~/.config/opencode/opencode.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"mcp": {
|
||||||
|
"playwright": {
|
||||||
|
"type": "local",
|
||||||
|
"command": [
|
||||||
|
"npx",
|
||||||
|
"@playwright/mcp@latest"
|
||||||
|
],
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Qodo Gen</summary>
|
||||||
|
|
||||||
|
Open [Qodo Gen](https://docs.qodo.ai/qodo-documentation/qodo-gen) chat panel in VSCode or IntelliJ → Connect more tools → + Add new MCP → Paste the standard config above.
|
||||||
|
|
||||||
|
Click <code>Save</code>.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>VS Code</summary>
|
||||||
|
|
||||||
|
#### Click the button to install:
|
||||||
|
|
||||||
|
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
|
||||||
|
|
||||||
|
#### Or install manually:
|
||||||
|
|
||||||
|
Follow the MCP install [guide](https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_add-an-mcp-server), use the standard config above. You can also install the Playwright MCP server using the VS Code CLI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# For VS Code
|
# For VS Code
|
||||||
@@ -50,60 +166,10 @@ After installation, the Playwright MCP server will be available for use with you
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Install in Cursor</b></summary>
|
<summary>Windsurf</summary>
|
||||||
|
|
||||||
Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, use `command` type with the command `npx @playwright/mcp`. You can also verify config or add command like arguments via clicking `Edit`.
|
Follow Windsurf MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp). Use the standard config above.
|
||||||
|
|
||||||
```js
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"playwright": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"@playwright/mcp@latest"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Install in Windsurf</b></summary>
|
|
||||||
|
|
||||||
Follow Windsuff MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp). Use following configuration:
|
|
||||||
|
|
||||||
```js
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"playwright": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"@playwright/mcp@latest"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Install in Claude Desktop</b></summary>
|
|
||||||
|
|
||||||
Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user), use following configuration:
|
|
||||||
|
|
||||||
```js
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"playwright": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"@playwright/mcp@latest"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
@@ -114,30 +180,38 @@ Playwright MCP server supports following arguments. They can be provided in the
|
|||||||
|
|
||||||
```
|
```
|
||||||
> npx @playwright/mcp@latest --help
|
> npx @playwright/mcp@latest --help
|
||||||
--allowed-origins <origins> semicolon-separated list of origins to allow the
|
--allowed-origins <origins> semicolon-separated list of origins to allow
|
||||||
browser to request. Default is to allow all.
|
the browser to request. Default is to allow
|
||||||
--blocked-origins <origins> semicolon-separated list of origins to block the
|
all.
|
||||||
browser from requesting. Blocklist is evaluated
|
--blocked-origins <origins> semicolon-separated list of origins to block
|
||||||
before allowlist. If used without the allowlist,
|
the browser from requesting. Blocklist is
|
||||||
requests not matching the blocklist are still
|
evaluated before allowlist. If used without
|
||||||
allowed.
|
the allowlist, requests not matching the
|
||||||
|
blocklist are still allowed.
|
||||||
--block-service-workers block service workers
|
--block-service-workers block service workers
|
||||||
--browser <browser> browser or chrome channel to use, possible
|
--browser <browser> browser or chrome channel to use, possible
|
||||||
values: chrome, firefox, webkit, msedge.
|
values: chrome, firefox, webkit, msedge.
|
||||||
--caps <caps> comma-separated list of capabilities to enable,
|
--caps <caps> comma-separated list of additional
|
||||||
possible values: tabs, pdf, history, wait, files,
|
capabilities to enable, possible values:
|
||||||
install. Default is all.
|
vision, pdf.
|
||||||
--cdp-endpoint <endpoint> CDP endpoint to connect to.
|
--cdp-endpoint <endpoint> CDP endpoint to connect to.
|
||||||
|
--cdp-header <headers...> CDP headers to send with the connect request,
|
||||||
|
multiple can be specified.
|
||||||
--config <path> path to the configuration file.
|
--config <path> path to the configuration file.
|
||||||
--device <device> device to emulate, for example: "iPhone 15"
|
--device <device> device to emulate, for example: "iPhone 15"
|
||||||
--executable-path <path> path to the browser executable.
|
--executable-path <path> path to the browser executable.
|
||||||
--headless run browser in headless mode, headed by default
|
--extension Connect to a running browser instance
|
||||||
--host <host> host to bind server to. Default is localhost. Use
|
(Edge/Chrome only). Requires the "Playwright
|
||||||
0.0.0.0 to bind to all interfaces.
|
MCP Bridge" browser extension to be installed.
|
||||||
|
--headless run browser in headless mode, headed by
|
||||||
|
default
|
||||||
|
--host <host> host to bind server to. Default is localhost.
|
||||||
|
Use 0.0.0.0 to bind to all interfaces.
|
||||||
--ignore-https-errors ignore https errors
|
--ignore-https-errors ignore https errors
|
||||||
--isolated keep the browser profile in memory, do not save
|
--isolated keep the browser profile in memory, do not
|
||||||
it to disk.
|
save it to disk.
|
||||||
--no-image-responses do not send image responses to the client.
|
--image-responses <mode> whether to send image responses to the client.
|
||||||
|
Can be "allow" or "omit", Defaults to "allow".
|
||||||
--no-sandbox disable the sandbox for all process types that
|
--no-sandbox disable the sandbox for all process types that
|
||||||
are normally sandboxed.
|
are normally sandboxed.
|
||||||
--output-dir <path> path to the directory for output files.
|
--output-dir <path> path to the directory for output files.
|
||||||
@@ -145,23 +219,33 @@ Playwright MCP server supports following arguments. They can be provided in the
|
|||||||
--proxy-bypass <bypass> comma-separated domains to bypass proxy, for
|
--proxy-bypass <bypass> comma-separated domains to bypass proxy, for
|
||||||
example ".com,chromium.org,.domain.com"
|
example ".com,chromium.org,.domain.com"
|
||||||
--proxy-server <proxy> specify proxy server, for example
|
--proxy-server <proxy> specify proxy server, for example
|
||||||
"http://myproxy:3128" or "socks5://myproxy:8080"
|
"http://myproxy:3128" or
|
||||||
|
"socks5://myproxy:8080"
|
||||||
|
--save-session Whether to save the Playwright MCP session
|
||||||
|
into the output directory.
|
||||||
|
--save-trace Whether to save the Playwright Trace of the
|
||||||
|
session into the output directory.
|
||||||
|
--secrets <path> path to a file containing secrets in the
|
||||||
|
dotenv format
|
||||||
--storage-state <path> path to the storage state file for isolated
|
--storage-state <path> path to the storage state file for isolated
|
||||||
sessions.
|
sessions.
|
||||||
|
--timeout-action <timeout> specify action timeout in milliseconds,
|
||||||
|
defaults to 5000ms
|
||||||
|
--timeout-navigation <timeout> specify navigation timeout in milliseconds,
|
||||||
|
defaults to 60000ms
|
||||||
--user-agent <ua string> specify user agent string
|
--user-agent <ua string> specify user agent string
|
||||||
--user-data-dir <path> path to the user data directory. If not
|
--user-data-dir <path> path to the user data directory. If not
|
||||||
specified, a temporary directory will be created.
|
specified, a temporary directory will be
|
||||||
|
created.
|
||||||
--viewport-size <size> specify browser viewport size in pixels, for
|
--viewport-size <size> specify browser viewport size in pixels, for
|
||||||
example "1280, 720"
|
example "1280, 720"
|
||||||
--vision Run server that uses screenshots (Aria snapshots
|
|
||||||
are used by default)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
<!--- End of options generated section -->
|
<!--- End of options generated section -->
|
||||||
|
|
||||||
### User profile
|
### User profile
|
||||||
|
|
||||||
You can run Playwright MCP with persistent profile like a regular browser (default), or in the isolated contexts for the testing sessions.
|
You can run Playwright MCP with persistent profile like a regular browser (default), in isolated contexts for testing sessions, or connect to your existing browser using the browser extension.
|
||||||
|
|
||||||
**Persistent profile**
|
**Persistent profile**
|
||||||
|
|
||||||
@@ -194,13 +278,17 @@ state [here](https://playwright.dev/docs/auth).
|
|||||||
"args": [
|
"args": [
|
||||||
"@playwright/mcp@latest",
|
"@playwright/mcp@latest",
|
||||||
"--isolated",
|
"--isolated",
|
||||||
"--storage-state={path/to/storage.json}
|
"--storage-state={path/to/storage.json}"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Browser Extension**
|
||||||
|
|
||||||
|
The Playwright MCP Chrome Extension allows you to connect to existing browser tabs and leverage your logged-in sessions and browser state. See [extension/README.md](extension/README.md) for installation and setup instructions.
|
||||||
|
|
||||||
### Configuration file
|
### Configuration file
|
||||||
|
|
||||||
The Playwright MCP server can be configured using a JSON configuration file. You can specify the configuration file
|
The Playwright MCP server can be configured using a JSON configuration file. You can specify the configuration file
|
||||||
@@ -255,21 +343,14 @@ npx @playwright/mcp@latest --config path/to/config.json
|
|||||||
host?: string; // Host to bind to (default: localhost)
|
host?: string; // Host to bind to (default: localhost)
|
||||||
},
|
},
|
||||||
|
|
||||||
// List of enabled capabilities
|
// List of additional capabilities
|
||||||
capabilities?: Array<
|
capabilities?: Array<
|
||||||
'core' | // Core browser automation
|
|
||||||
'tabs' | // Tab management
|
'tabs' | // Tab management
|
||||||
'pdf' | // PDF generation
|
|
||||||
'history' | // Browser history
|
|
||||||
'wait' | // Wait utilities
|
|
||||||
'files' | // File handling
|
|
||||||
'install' | // Browser installation
|
'install' | // Browser installation
|
||||||
'testing' // Testing
|
'pdf' | // PDF generation
|
||||||
|
'vision' | // Coordinate-based interactions
|
||||||
>;
|
>;
|
||||||
|
|
||||||
// Enable vision mode (screenshots instead of accessibility snapshots)
|
|
||||||
vision?: boolean;
|
|
||||||
|
|
||||||
// Directory for output files
|
// Directory for output files
|
||||||
outputDir?: string;
|
outputDir?: string;
|
||||||
|
|
||||||
@@ -283,9 +364,10 @@ npx @playwright/mcp@latest --config path/to/config.json
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Do not send image responses to the client.
|
* Whether to send image responses to the client. Can be "allow" or "omit".
|
||||||
|
* Defaults to "allow".
|
||||||
*/
|
*/
|
||||||
noImageResponses?: boolean;
|
imageResponses?: 'allow' | 'omit';
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
</details>
|
</details>
|
||||||
@@ -293,19 +375,19 @@ npx @playwright/mcp@latest --config path/to/config.json
|
|||||||
### Standalone MCP server
|
### Standalone MCP server
|
||||||
|
|
||||||
When running headed browser on system w/o display or from worker processes of the IDEs,
|
When running headed browser on system w/o display or from worker processes of the IDEs,
|
||||||
run the MCP server from environment with the DISPLAY and pass the `--port` flag to enable SSE transport.
|
run the MCP server from environment with the DISPLAY and pass the `--port` flag to enable HTTP transport.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx @playwright/mcp@latest --port 8931
|
npx @playwright/mcp@latest --port 8931
|
||||||
```
|
```
|
||||||
|
|
||||||
And then in MCP client config, set the `url` to the SSE endpoint:
|
And then in MCP client config, set the `url` to the HTTP endpoint:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"playwright": {
|
"playwright": {
|
||||||
"url": "http://localhost:8931/sse"
|
"url": "http://localhost:8931/mcp"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,7 +431,7 @@ http.createServer(async (req, res) => {
|
|||||||
// Creates a headless Playwright MCP server with SSE transport
|
// Creates a headless Playwright MCP server with SSE transport
|
||||||
const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
|
const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
|
||||||
const transport = new SSEServerTransport('/messages', res);
|
const transport = new SSEServerTransport('/messages', res);
|
||||||
await connection.connect(transport);
|
await connection.sever.connect(transport);
|
||||||
|
|
||||||
// ...
|
// ...
|
||||||
});
|
});
|
||||||
@@ -358,42 +440,10 @@ http.createServer(async (req, res) => {
|
|||||||
|
|
||||||
### Tools
|
### Tools
|
||||||
|
|
||||||
The tools are available in two modes:
|
|
||||||
|
|
||||||
1. **Snapshot Mode** (default): Uses accessibility snapshots for better performance and reliability
|
|
||||||
2. **Vision Mode**: Uses screenshots for visual-based interactions
|
|
||||||
|
|
||||||
To use Vision Mode, add the `--vision` flag when starting the server:
|
|
||||||
|
|
||||||
```js
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"playwright": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"@playwright/mcp@latest",
|
|
||||||
"--vision"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Vision Mode works best with the computer use models that are able to interact with elements using
|
|
||||||
X Y coordinate space, based on the provided screenshot.
|
|
||||||
|
|
||||||
<!--- Tools generated by update-readme.js -->
|
<!--- Tools generated by update-readme.js -->
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Interactions</b></summary>
|
<summary><b>Core automation</b></summary>
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_snapshot**
|
|
||||||
- Title: Page snapshot
|
|
||||||
- Description: Capture accessibility snapshot of the current page, this is better than screenshot
|
|
||||||
- Parameters: None
|
|
||||||
- Read-only: **true**
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
@@ -403,10 +453,29 @@ X Y coordinate space, based on the provided screenshot.
|
|||||||
- Parameters:
|
- Parameters:
|
||||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||||
- `ref` (string): Exact target element reference from the page snapshot
|
- `ref` (string): Exact target element reference from the page snapshot
|
||||||
|
- `doubleClick` (boolean, optional): Whether to perform a double click instead of a single click
|
||||||
|
- `button` (string, optional): Button to click, defaults to left
|
||||||
|
- `modifiers` (array, optional): Modifier keys to press
|
||||||
- Read-only: **false**
|
- Read-only: **false**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_close**
|
||||||
|
- Title: Close browser
|
||||||
|
- Description: Close the page
|
||||||
|
- Parameters: None
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_console_messages**
|
||||||
|
- Title: Get console messages
|
||||||
|
- Description: Returns all console messages
|
||||||
|
- Parameters: None
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_drag**
|
- **browser_drag**
|
||||||
- Title: Drag mouse
|
- Title: Drag mouse
|
||||||
- Description: Perform drag and drop between two elements
|
- Description: Perform drag and drop between two elements
|
||||||
@@ -419,60 +488,17 @@ X Y coordinate space, based on the provided screenshot.
|
|||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_hover**
|
- **browser_evaluate**
|
||||||
- Title: Hover mouse
|
- Title: Evaluate JavaScript
|
||||||
- Description: Hover over element on page
|
- Description: Evaluate JavaScript expression on page or element
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
- `function` (string): () => { /* code */ } or (element) => { /* code */ } when element is provided
|
||||||
- `ref` (string): Exact target element reference from the page snapshot
|
- `element` (string, optional): Human-readable element description used to obtain permission to interact with the element
|
||||||
- Read-only: **true**
|
- `ref` (string, optional): Exact target element reference from the page snapshot
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_type**
|
|
||||||
- Title: Type text
|
|
||||||
- Description: Type text into editable element
|
|
||||||
- Parameters:
|
|
||||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
|
||||||
- `ref` (string): Exact target element reference from the page snapshot
|
|
||||||
- `text` (string): Text to type into the element
|
|
||||||
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
|
|
||||||
- `slowly` (boolean, optional): Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.
|
|
||||||
- Read-only: **false**
|
- Read-only: **false**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_select_option**
|
|
||||||
- Title: Select option
|
|
||||||
- Description: Select an option in a dropdown
|
|
||||||
- Parameters:
|
|
||||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
|
||||||
- `ref` (string): Exact target element reference from the page snapshot
|
|
||||||
- `values` (array): Array of values to select in the dropdown. This can be a single value or multiple values.
|
|
||||||
- Read-only: **false**
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_press_key**
|
|
||||||
- Title: Press a key
|
|
||||||
- Description: Press a key on the keyboard
|
|
||||||
- Parameters:
|
|
||||||
- `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`
|
|
||||||
- Read-only: **false**
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_wait_for**
|
|
||||||
- Title: Wait for
|
|
||||||
- Description: Wait for text to appear or disappear or a specified time to pass
|
|
||||||
- Parameters:
|
|
||||||
- `time` (number, optional): The time to wait in seconds
|
|
||||||
- `text` (string, optional): The text to wait for
|
|
||||||
- `textGone` (string, optional): The text to wait for to disappear
|
|
||||||
- Read-only: **true**
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_file_upload**
|
- **browser_file_upload**
|
||||||
- Title: Upload files
|
- Title: Upload files
|
||||||
- Description: Upload one or multiple files
|
- Description: Upload one or multiple files
|
||||||
@@ -482,6 +508,15 @@ X Y coordinate space, based on the provided screenshot.
|
|||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_fill_form**
|
||||||
|
- Title: Fill form
|
||||||
|
- Description: Fill multiple form fields
|
||||||
|
- Parameters:
|
||||||
|
- `fields` (array): Fields to fill in
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_handle_dialog**
|
- **browser_handle_dialog**
|
||||||
- Title: Handle a dialog
|
- Title: Handle a dialog
|
||||||
- Description: Handle a dialog
|
- Description: Handle a dialog
|
||||||
@@ -490,10 +525,15 @@ X Y coordinate space, based on the provided screenshot.
|
|||||||
- `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
|
- `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
|
||||||
- Read-only: **false**
|
- Read-only: **false**
|
||||||
|
|
||||||
</details>
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
<details>
|
- **browser_hover**
|
||||||
<summary><b>Navigation</b></summary>
|
- Title: Hover mouse
|
||||||
|
- Description: Hover over element on page
|
||||||
|
- Parameters:
|
||||||
|
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||||
|
- `ref` (string): Exact target element reference from the page snapshot
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
@@ -514,40 +554,6 @@ X Y coordinate space, based on the provided screenshot.
|
|||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_navigate_forward**
|
|
||||||
- Title: Go forward
|
|
||||||
- Description: Go forward to the next page
|
|
||||||
- Parameters: None
|
|
||||||
- Read-only: **true**
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Resources</b></summary>
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_take_screenshot**
|
|
||||||
- Title: Take a screenshot
|
|
||||||
- Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.
|
|
||||||
- Parameters:
|
|
||||||
- `raw` (boolean, optional): Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.
|
|
||||||
- `filename` (string, optional): File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.
|
|
||||||
- `element` (string, optional): Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.
|
|
||||||
- `ref` (string, optional): Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.
|
|
||||||
- Read-only: **true**
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_pdf_save**
|
|
||||||
- Title: Save as PDF
|
|
||||||
- Description: Save page as PDF
|
|
||||||
- Parameters:
|
|
||||||
- `filename` (string, optional): File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.
|
|
||||||
- Read-only: **true**
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_network_requests**
|
- **browser_network_requests**
|
||||||
- Title: List network requests
|
- Title: List network requests
|
||||||
- Description: Returns all network requests since loading the page
|
- Description: Returns all network requests since loading the page
|
||||||
@@ -556,35 +562,15 @@ X Y coordinate space, based on the provided screenshot.
|
|||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_console_messages**
|
- **browser_press_key**
|
||||||
- Title: Get console messages
|
- Title: Press a key
|
||||||
- Description: Returns all console messages
|
- Description: Press a key on the keyboard
|
||||||
- Parameters: None
|
- Parameters:
|
||||||
- Read-only: **true**
|
- `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Utilities</b></summary>
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_install**
|
|
||||||
- Title: Install the browser specified in the config
|
|
||||||
- Description: Install the browser specified in the config. Call this if you get an error about the browser not being installed.
|
|
||||||
- Parameters: None
|
|
||||||
- Read-only: **false**
|
- Read-only: **false**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_close**
|
|
||||||
- Title: Close browser
|
|
||||||
- Description: Close the page
|
|
||||||
- Parameters: None
|
|
||||||
- Read-only: **true**
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_resize**
|
- **browser_resize**
|
||||||
- Title: Resize browser window
|
- Title: Resize browser window
|
||||||
- Description: Resize the browser window
|
- Description: Resize the browser window
|
||||||
@@ -593,127 +579,49 @@ X Y coordinate space, based on the provided screenshot.
|
|||||||
- `height` (number): Height of the browser window
|
- `height` (number): Height of the browser window
|
||||||
- Read-only: **true**
|
- Read-only: **true**
|
||||||
|
|
||||||
</details>
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
<details>
|
- **browser_select_option**
|
||||||
<summary><b>Tabs</b></summary>
|
- Title: Select option
|
||||||
|
- Description: Select an option in a dropdown
|
||||||
|
- Parameters:
|
||||||
|
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||||
|
- `ref` (string): Exact target element reference from the page snapshot
|
||||||
|
- `values` (array): Array of values to select in the dropdown. This can be a single value or multiple values.
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_tab_list**
|
- **browser_snapshot**
|
||||||
- Title: List tabs
|
- Title: Page snapshot
|
||||||
- Description: List browser tabs
|
- Description: Capture accessibility snapshot of the current page, this is better than screenshot
|
||||||
- Parameters: None
|
- Parameters: None
|
||||||
- Read-only: **true**
|
- Read-only: **true**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_tab_new**
|
- **browser_take_screenshot**
|
||||||
- Title: Open a new tab
|
|
||||||
- Description: Open a new tab
|
|
||||||
- Parameters:
|
|
||||||
- `url` (string, optional): The URL to navigate to in the new tab. If not provided, the new tab will be blank.
|
|
||||||
- Read-only: **true**
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_tab_select**
|
|
||||||
- Title: Select a tab
|
|
||||||
- Description: Select a tab by index
|
|
||||||
- Parameters:
|
|
||||||
- `index` (number): The index of the tab to select
|
|
||||||
- Read-only: **true**
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_tab_close**
|
|
||||||
- Title: Close a tab
|
|
||||||
- Description: Close a tab
|
|
||||||
- Parameters:
|
|
||||||
- `index` (number, optional): The index of the tab to close. Closes current tab if not provided.
|
|
||||||
- Read-only: **false**
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Testing</b></summary>
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_generate_playwright_test**
|
|
||||||
- Title: Generate a Playwright test
|
|
||||||
- Description: Generate a Playwright test for given scenario
|
|
||||||
- Parameters:
|
|
||||||
- `name` (string): The name of the test
|
|
||||||
- `description` (string): The description of the test
|
|
||||||
- `steps` (array): The steps of the test
|
|
||||||
- Read-only: **true**
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Vision mode</b></summary>
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_screen_capture**
|
|
||||||
- Title: Take a screenshot
|
- Title: Take a screenshot
|
||||||
- Description: Take a screenshot of the current page
|
- Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.
|
||||||
- Parameters: None
|
- Parameters:
|
||||||
|
- `type` (string, optional): Image format for the screenshot. Default is png.
|
||||||
|
- `filename` (string, optional): File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.
|
||||||
|
- `element` (string, optional): Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.
|
||||||
|
- `ref` (string, optional): Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.
|
||||||
|
- `fullPage` (boolean, optional): When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Cannot be used with element screenshots.
|
||||||
- Read-only: **true**
|
- Read-only: **true**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_screen_move_mouse**
|
- **browser_type**
|
||||||
- Title: Move mouse
|
|
||||||
- Description: Move mouse to a given position
|
|
||||||
- Parameters:
|
|
||||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
|
||||||
- `x` (number): X coordinate
|
|
||||||
- `y` (number): Y coordinate
|
|
||||||
- Read-only: **true**
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_screen_click**
|
|
||||||
- Title: Click
|
|
||||||
- Description: Click left mouse button
|
|
||||||
- Parameters:
|
|
||||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
|
||||||
- `x` (number): X coordinate
|
|
||||||
- `y` (number): Y coordinate
|
|
||||||
- Read-only: **false**
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_screen_drag**
|
|
||||||
- Title: Drag mouse
|
|
||||||
- Description: Drag left mouse button
|
|
||||||
- Parameters:
|
|
||||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
|
||||||
- `startX` (number): Start X coordinate
|
|
||||||
- `startY` (number): Start Y coordinate
|
|
||||||
- `endX` (number): End X coordinate
|
|
||||||
- `endY` (number): End Y coordinate
|
|
||||||
- Read-only: **false**
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_screen_type**
|
|
||||||
- Title: Type text
|
- Title: Type text
|
||||||
- Description: Type text
|
- Description: Type text into editable element
|
||||||
- Parameters:
|
- Parameters:
|
||||||
|
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||||
|
- `ref` (string): Exact target element reference from the page snapshot
|
||||||
- `text` (string): Text to type into the element
|
- `text` (string): Text to type into the element
|
||||||
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
|
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
|
||||||
- Read-only: **false**
|
- `slowly` (boolean, optional): Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_press_key**
|
|
||||||
- Title: Press a key
|
|
||||||
- Description: Press a key on the keyboard
|
|
||||||
- Parameters:
|
|
||||||
- `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`
|
|
||||||
- Read-only: **false**
|
- Read-only: **false**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
@@ -727,25 +635,156 @@ X Y coordinate space, based on the provided screenshot.
|
|||||||
- `textGone` (string, optional): The text to wait for to disappear
|
- `textGone` (string, optional): The text to wait for to disappear
|
||||||
- Read-only: **true**
|
- Read-only: **true**
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Tab management</b></summary>
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_file_upload**
|
- **browser_tabs**
|
||||||
- Title: Upload files
|
- Title: Manage tabs
|
||||||
- Description: Upload one or multiple files
|
- Description: List, create, close, or select a browser tab.
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files.
|
- `action` (string): Operation to perform
|
||||||
|
- `index` (number, optional): Tab index, used for close/select. If omitted for close, current tab is closed.
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Browser installation</b></summary>
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_install**
|
||||||
|
- Title: Install the browser specified in the config
|
||||||
|
- Description: Install the browser specified in the config. Call this if you get an error about the browser not being installed.
|
||||||
|
- Parameters: None
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Coordinate-based (opt-in via --caps=vision)</b></summary>
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_mouse_click_xy**
|
||||||
|
- Title: Click
|
||||||
|
- Description: Click left mouse button at a given position
|
||||||
|
- Parameters:
|
||||||
|
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||||
|
- `x` (number): X coordinate
|
||||||
|
- `y` (number): Y coordinate
|
||||||
- Read-only: **false**
|
- Read-only: **false**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_handle_dialog**
|
- **browser_mouse_drag_xy**
|
||||||
- Title: Handle a dialog
|
- Title: Drag mouse
|
||||||
- Description: Handle a dialog
|
- Description: Drag left mouse button to a given position
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `accept` (boolean): Whether to accept the dialog.
|
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||||
- `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
|
- `startX` (number): Start X coordinate
|
||||||
|
- `startY` (number): Start Y coordinate
|
||||||
|
- `endX` (number): End X coordinate
|
||||||
|
- `endY` (number): End Y coordinate
|
||||||
- Read-only: **false**
|
- Read-only: **false**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_mouse_move_xy**
|
||||||
|
- Title: Move mouse
|
||||||
|
- Description: Move mouse to a given position
|
||||||
|
- Parameters:
|
||||||
|
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||||
|
- `x` (number): X coordinate
|
||||||
|
- `y` (number): Y coordinate
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>PDF generation (opt-in via --caps=pdf)</b></summary>
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_pdf_save**
|
||||||
|
- Title: Save as PDF
|
||||||
|
- Description: Save page as PDF
|
||||||
|
- Parameters:
|
||||||
|
- `filename` (string, optional): File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Verify (opt-in via --caps=verify)</b></summary>
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_verify_element_visible**
|
||||||
|
- Title: Verify element visible
|
||||||
|
- Description: Verify element is visible on the page
|
||||||
|
- Parameters:
|
||||||
|
- `role` (string): ROLE of the element. Can be found in the snapshot like this: `- {ROLE} "Accessible Name":`
|
||||||
|
- `accessibleName` (string): ACCESSIBLE_NAME of the element. Can be found in the snapshot like this: `- role "{ACCESSIBLE_NAME}"`
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_verify_list_visible**
|
||||||
|
- Title: Verify list visible
|
||||||
|
- Description: Verify list is visible on the page
|
||||||
|
- Parameters:
|
||||||
|
- `element` (string): Human-readable list description
|
||||||
|
- `ref` (string): Exact target element reference that points to the list
|
||||||
|
- `items` (array): Items to verify
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_verify_text_visible**
|
||||||
|
- Title: Verify text visible
|
||||||
|
- Description: Verify text is visible on the page. Prefer browser_verify_element_visible if possible.
|
||||||
|
- Parameters:
|
||||||
|
- `text` (string): TEXT to verify. Can be found in the snapshot like this: `- role "Accessible Name": {TEXT}` or like this: `- text: {TEXT}`
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_verify_value**
|
||||||
|
- Title: Verify value
|
||||||
|
- Description: Verify element value
|
||||||
|
- Parameters:
|
||||||
|
- `type` (string): Type of the element
|
||||||
|
- `element` (string): Human-readable element description
|
||||||
|
- `ref` (string): Exact target element reference that points to the element
|
||||||
|
- `value` (string): Value to verify. For checkbox, use "true" or "false".
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Tracing (opt-in via --caps=tracing)</b></summary>
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_start_tracing**
|
||||||
|
- Title: Start tracing
|
||||||
|
- Description: Start trace recording
|
||||||
|
- Parameters: None
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_stop_tracing**
|
||||||
|
- Title: Stop tracing
|
||||||
|
- Description: Stop trace recording
|
||||||
|
- Parameters: None
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
8
cli.js
8
cli.js
@@ -15,4 +15,10 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import './lib/program.js';
|
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);
|
||||||
|
|||||||
47
config.d.ts
vendored
47
config.d.ts
vendored
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
import type * as playwright from 'playwright';
|
import type * as playwright from 'playwright';
|
||||||
|
|
||||||
export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install' | 'testing';
|
export type ToolCapability = 'core' | 'core-tabs' | 'core-install' | 'vision' | 'pdf' | 'verify';
|
||||||
|
|
||||||
export type Config = {
|
export type Config = {
|
||||||
/**
|
/**
|
||||||
@@ -59,6 +59,11 @@ export type Config = {
|
|||||||
*/
|
*/
|
||||||
cdpEndpoint?: string;
|
cdpEndpoint?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CDP headers to send with the connect request.
|
||||||
|
*/
|
||||||
|
cdpHeaders?: Record<string, string>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remote endpoint to connect to an existing Playwright server.
|
* Remote endpoint to connect to an existing Playwright server.
|
||||||
*/
|
*/
|
||||||
@@ -80,19 +85,27 @@ export type Config = {
|
|||||||
/**
|
/**
|
||||||
* List of enabled tool capabilities. Possible values:
|
* List of enabled tool capabilities. Possible values:
|
||||||
* - 'core': Core browser automation features.
|
* - 'core': Core browser automation features.
|
||||||
* - 'tabs': Tab management features.
|
|
||||||
* - 'pdf': PDF generation and manipulation.
|
* - 'pdf': PDF generation and manipulation.
|
||||||
* - 'history': Browser history access.
|
* - 'vision': Coordinate-based interactions.
|
||||||
* - 'wait': Wait and timing utilities.
|
|
||||||
* - 'files': File upload/download support.
|
|
||||||
* - 'install': Browser installation utilities.
|
|
||||||
*/
|
*/
|
||||||
capabilities?: ToolCapability[];
|
capabilities?: ToolCapability[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run server that uses screenshots (Aria snapshots are used by default).
|
* Whether to save the Playwright session into the output directory.
|
||||||
*/
|
*/
|
||||||
vision?: boolean;
|
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.
|
* The directory to save output files.
|
||||||
@@ -111,8 +124,20 @@ export type Config = {
|
|||||||
blockedOrigins?: string[];
|
blockedOrigins?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
timeouts?: {
|
||||||
* Do not send image responses to the client.
|
/*
|
||||||
|
* Configures default action timeout: https://playwright.dev/docs/api/class-page#page-set-default-timeout. Defaults to 5000ms.
|
||||||
*/
|
*/
|
||||||
noImageResponses?: boolean;
|
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,205 +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 = {
|
|
||||||
"import/extensions": ["error", "ignorePackages", {ts: "always"}],
|
|
||||||
"@typescript-eslint/no-floating-promises": "error",
|
|
||||||
"@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,
|
|
||||||
"no-console": 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const languageOptions = {
|
|
||||||
parser: tsParser,
|
|
||||||
ecmaVersion: 9,
|
|
||||||
sourceType: "module",
|
|
||||||
parserOptions: {
|
|
||||||
project: path.join(fileURLToPath(import.meta.url), "..", "tsconfig.all.json"),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default [
|
|
||||||
{
|
|
||||||
ignores: ["**/*.js"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ["**/*.ts", "**/*.tsx"],
|
|
||||||
plugins,
|
|
||||||
languageOptions,
|
|
||||||
rules: baseRules,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
Generate test for scenario:
|
|
||||||
|
|
||||||
## GitHub PR Checks Navigation Checklist
|
|
||||||
|
|
||||||
1. Open the [Microsoft Playwright GitHub repository](https://github.com/microsoft/playwright).
|
|
||||||
2. Click on the **Pull requests** tab.
|
|
||||||
3. Find and open the pull request titled **"chore: make noWaitAfter a default"**.
|
|
||||||
4. Switch to the **Checks** tab for that pull request.
|
|
||||||
5. Expand the **infra** check suite to view its jobs.
|
|
||||||
6. Click on the **docs & lint** job to view its details.
|
|
||||||
48
extension/README.md
Normal file
48
extension/README.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Playwright MCP Chrome Extension
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
The Playwright MCP Chrome Extension allows you to connect to pages in your existing browser and leverage the state of your default user profile. This means the AI assistant can interact with websites where you're already logged in, using your existing cookies, sessions, and browser state, providing a seamless experience without requiring separate authentication or setup.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Chrome/Edge/Chromium browser
|
||||||
|
|
||||||
|
## Installation Steps
|
||||||
|
|
||||||
|
### Download the Extension
|
||||||
|
|
||||||
|
Download the latest Chrome extension from GitHub:
|
||||||
|
- **Download link**: https://github.com/microsoft/playwright-mcp/releases
|
||||||
|
|
||||||
|
### Load Chrome Extension
|
||||||
|
|
||||||
|
1. Open Chrome and navigate to `chrome://extensions/`
|
||||||
|
2. Enable "Developer mode" (toggle in the top right corner)
|
||||||
|
3. Click "Load unpacked" and select the extension directory
|
||||||
|
|
||||||
|
### Configure Playwright MCP server
|
||||||
|
|
||||||
|
Configure Playwright MCP server to connect to the browser using the extension by passing the `--extension` option when running the MCP server:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"playwright-extension": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"@playwright/mcp@latest",
|
||||||
|
"--extension"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Browser Tab Selection
|
||||||
|
|
||||||
|
When the LLM interacts with the browser for the first time, it will load a page where you can select which browser tab the LLM will connect to. This allows you to control which specific page the AI assistant will interact with during the session.
|
||||||
|
|
||||||
|
|
||||||
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,10 +14,18 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Connection, createConnection as createConnectionImpl } from './connection.js';
|
import { defineConfig } from '@playwright/test';
|
||||||
|
|
||||||
import type { Config } from '../config.js';
|
import type { TestOptions } from '../tests/fixtures';
|
||||||
|
|
||||||
export async function createConnection(config: Config = {}): Promise<Connection> {
|
export default defineConfig<TestOptions>({
|
||||||
return createConnectionImpl(config);
|
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]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -14,11 +14,18 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from './fixtures.js';
|
import { resolve } from 'path';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
test('browser_install', async ({ client, mcpBrowser }) => {
|
export default defineConfig({
|
||||||
test.skip(mcpBrowser !== 'chromium', 'Test only chromium');
|
build: {
|
||||||
expect(await client.callTool({
|
lib: {
|
||||||
name: 'browser_install',
|
entry: resolve(__dirname, 'src/background.ts'),
|
||||||
})).toContainTextContent(`No open pages available.`);
|
fileName: 'lib/background',
|
||||||
|
formats: ['es']
|
||||||
|
},
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: false,
|
||||||
|
minify: false
|
||||||
|
}
|
||||||
});
|
});
|
||||||
10
index.d.ts
vendored
10
index.d.ts
vendored
@@ -17,13 +17,7 @@
|
|||||||
|
|
||||||
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
import type { Config } from './config';
|
import type { Config } from './config';
|
||||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
import type { BrowserContext } from 'playwright';
|
||||||
|
|
||||||
export type Connection = {
|
export declare function createConnection(config?: Config, contextGetter?: () => Promise<BrowserContext>): Promise<Server>;
|
||||||
server: Server;
|
|
||||||
connect(transport: Transport): Promise<void>;
|
|
||||||
close(): Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export declare function createConnection(config?: Config): Promise<Connection>;
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
4
index.js
4
index.js
@@ -15,5 +15,5 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createConnection } from './lib/index';
|
const { createConnection } = require('playwright/lib/mcp/index');
|
||||||
export { createConnection };
|
module.exports = { createConnection };
|
||||||
|
|||||||
3584
package-lock.json
generated
3584
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
40
package.json
40
package.json
@@ -1,8 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.25",
|
"version": "0.0.37",
|
||||||
"description": "Playwright Tools for MCP",
|
"description": "Playwright Tools for MCP",
|
||||||
"type": "module",
|
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/microsoft/playwright-mcp.git"
|
"url": "git+https://github.com/microsoft/playwright-mcp.git"
|
||||||
@@ -16,16 +15,15 @@
|
|||||||
},
|
},
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"lint": "npm run update-readme",
|
||||||
"lint": "npm run update-readme && eslint . && tsc --noEmit",
|
"update-readme": "node update-readme.js",
|
||||||
"update-readme": "node utils/update-readme.js",
|
"docker-build": "docker build --no-cache -t playwright-mcp-dev:latest .",
|
||||||
"watch": "tsc --watch",
|
|
||||||
"test": "playwright test",
|
"test": "playwright test",
|
||||||
"ctest": "playwright test --project=chrome",
|
"ctest": "playwright test --project=chrome",
|
||||||
"ftest": "playwright test --project=firefox",
|
"ftest": "playwright test --project=firefox",
|
||||||
"wtest": "playwright test --project=webkit",
|
"wtest": "playwright test --project=webkit",
|
||||||
"clean": "rm -rf lib",
|
"dtest": "MCP_IN_DOCKER=1 playwright test --project=chromium-docker",
|
||||||
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
|
"npm-publish": "npm run clean && npm run test && npm publish"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
"./package.json": "./package.json",
|
"./package.json": "./package.json",
|
||||||
@@ -35,26 +33,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
"playwright": "1.56.0-alpha-2025-09-06",
|
||||||
"commander": "^13.1.0",
|
"playwright-core": "1.56.0-alpha-2025-09-06"
|
||||||
"playwright": "1.53.0-alpha-1746832516000",
|
|
||||||
"zod-to-json-schema": "^3.24.4"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
|
||||||
"@eslint/js": "^9.19.0",
|
|
||||||
"@playwright/test": "1.53.0-alpha-1746832516000",
|
|
||||||
"@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"
|
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"mcp-server-playwright": "cli.js"
|
"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,21 +16,23 @@
|
|||||||
|
|
||||||
import { defineConfig } from '@playwright/test';
|
import { defineConfig } from '@playwright/test';
|
||||||
|
|
||||||
import type { TestOptions } from './tests/fixtures.js';
|
import type { TestOptions } from './tests/fixtures';
|
||||||
|
|
||||||
export default defineConfig<TestOptions>({
|
export default defineConfig<TestOptions>({
|
||||||
testDir: './tests',
|
testDir: './tests',
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
workers: process.env.CI ? 2 : undefined,
|
||||||
workers: process.env.CI ? 1 : undefined,
|
|
||||||
reporter: 'list',
|
reporter: 'list',
|
||||||
projects: [
|
projects: [
|
||||||
{ name: 'chrome' },
|
{ name: 'chrome' },
|
||||||
{ name: 'msedge', use: { mcpBrowser: 'msedge' } },
|
...process.env.MCP_IN_DOCKER ? [{
|
||||||
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
name: 'chromium-docker',
|
||||||
...process.env.MCP_IN_DOCKER ? [{ name: 'chromium-docker', use: { mcpBrowser: 'chromium', mcpMode: 'docker' as const } }] : [],
|
grep: /browser_navigate|browser_click/,
|
||||||
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
use: {
|
||||||
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
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.
|
||||||
245
src/config.ts
245
src/config.ts
@@ -1,245 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import fs from 'fs';
|
|
||||||
import net from 'net';
|
|
||||||
import os from 'os';
|
|
||||||
import path from 'path';
|
|
||||||
import { devices } from 'playwright';
|
|
||||||
|
|
||||||
import type { Config, ToolCapability } from '../config.js';
|
|
||||||
import type { BrowserContextOptions, LaunchOptions } from 'playwright';
|
|
||||||
import { sanitizeForFilePath } from './tools/utils.js';
|
|
||||||
|
|
||||||
export type CLIOptions = {
|
|
||||||
allowedOrigins?: string[];
|
|
||||||
blockedOrigins?: string[];
|
|
||||||
blockServiceWorkers?: boolean;
|
|
||||||
browser?: string;
|
|
||||||
caps?: string;
|
|
||||||
cdpEndpoint?: string;
|
|
||||||
config?: string;
|
|
||||||
device?: string;
|
|
||||||
executablePath?: string;
|
|
||||||
headless?: boolean;
|
|
||||||
host?: string;
|
|
||||||
ignoreHttpsErrors?: boolean;
|
|
||||||
isolated?: boolean;
|
|
||||||
imageResponses: boolean;
|
|
||||||
sandbox: boolean;
|
|
||||||
outputDir?: string;
|
|
||||||
port?: number;
|
|
||||||
proxyBypass?: string;
|
|
||||||
proxyServer?: string;
|
|
||||||
storageState?: string;
|
|
||||||
userAgent?: string;
|
|
||||||
userDataDir?: string;
|
|
||||||
viewportSize?: string;
|
|
||||||
vision?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultConfig: Config = {
|
|
||||||
browser: {
|
|
||||||
browserName: 'chromium',
|
|
||||||
launchOptions: {
|
|
||||||
channel: 'chrome',
|
|
||||||
headless: os.platform() === 'linux' && !process.env.DISPLAY,
|
|
||||||
chromiumSandbox: true,
|
|
||||||
},
|
|
||||||
contextOptions: {
|
|
||||||
viewport: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
network: {
|
|
||||||
allowedOrigins: undefined,
|
|
||||||
blockedOrigins: undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function resolveConfig(cliOptions: CLIOptions): Promise<Config> {
|
|
||||||
const config = await loadConfig(cliOptions.config);
|
|
||||||
const cliOverrides = await configFromCLIOptions(cliOptions);
|
|
||||||
return mergeConfig(defaultConfig, mergeConfig(config, cliOverrides));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> {
|
|
||||||
let browserName: 'chromium' | 'firefox' | 'webkit';
|
|
||||||
let channel: string | undefined;
|
|
||||||
switch (cliOptions.browser) {
|
|
||||||
case 'chrome':
|
|
||||||
case 'chrome-beta':
|
|
||||||
case 'chrome-canary':
|
|
||||||
case 'chrome-dev':
|
|
||||||
case 'chromium':
|
|
||||||
case 'msedge':
|
|
||||||
case 'msedge-beta':
|
|
||||||
case 'msedge-canary':
|
|
||||||
case 'msedge-dev':
|
|
||||||
browserName = 'chromium';
|
|
||||||
channel = cliOptions.browser;
|
|
||||||
break;
|
|
||||||
case 'firefox':
|
|
||||||
browserName = 'firefox';
|
|
||||||
break;
|
|
||||||
case 'webkit':
|
|
||||||
browserName = 'webkit';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
browserName = 'chromium';
|
|
||||||
channel = 'chrome';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Launch options
|
|
||||||
const launchOptions: LaunchOptions = {
|
|
||||||
channel,
|
|
||||||
executablePath: cliOptions.executablePath,
|
|
||||||
headless: cliOptions.headless,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (browserName === 'chromium') {
|
|
||||||
(launchOptions as any).cdpPort = await findFreePort();
|
|
||||||
if (!cliOptions.sandbox) {
|
|
||||||
// --no-sandbox was passed, disable the sandbox
|
|
||||||
launchOptions.chromiumSandbox = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cliOptions.proxyServer) {
|
|
||||||
launchOptions.proxy = {
|
|
||||||
server: cliOptions.proxyServer
|
|
||||||
};
|
|
||||||
if (cliOptions.proxyBypass)
|
|
||||||
launchOptions.proxy.bypass = cliOptions.proxyBypass;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Context options
|
|
||||||
const contextOptions: BrowserContextOptions = cliOptions.device ? devices[cliOptions.device] : {};
|
|
||||||
if (cliOptions.storageState)
|
|
||||||
contextOptions.storageState = cliOptions.storageState;
|
|
||||||
|
|
||||||
if (cliOptions.userAgent)
|
|
||||||
contextOptions.userAgent = cliOptions.userAgent;
|
|
||||||
|
|
||||||
if (cliOptions.viewportSize) {
|
|
||||||
try {
|
|
||||||
const [width, height] = cliOptions.viewportSize.split(',').map(n => +n);
|
|
||||||
if (isNaN(width) || isNaN(height))
|
|
||||||
throw new Error('bad values');
|
|
||||||
contextOptions.viewport = { width, height };
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error('Invalid viewport size format: use "width,height", for example --viewport-size="800,600"');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cliOptions.ignoreHttpsErrors)
|
|
||||||
contextOptions.ignoreHTTPSErrors = true;
|
|
||||||
|
|
||||||
if (cliOptions.blockServiceWorkers)
|
|
||||||
contextOptions.serviceWorkers = 'block';
|
|
||||||
|
|
||||||
const result: Config = {
|
|
||||||
browser: {
|
|
||||||
browserName,
|
|
||||||
isolated: cliOptions.isolated,
|
|
||||||
userDataDir: cliOptions.userDataDir,
|
|
||||||
launchOptions,
|
|
||||||
contextOptions,
|
|
||||||
cdpEndpoint: cliOptions.cdpEndpoint,
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
port: cliOptions.port,
|
|
||||||
host: cliOptions.host,
|
|
||||||
},
|
|
||||||
capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
|
|
||||||
vision: !!cliOptions.vision,
|
|
||||||
network: {
|
|
||||||
allowedOrigins: cliOptions.allowedOrigins,
|
|
||||||
blockedOrigins: cliOptions.blockedOrigins,
|
|
||||||
},
|
|
||||||
outputDir: cliOptions.outputDir,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!cliOptions.imageResponses) {
|
|
||||||
// --no-image-responses was passed, disable image responses
|
|
||||||
result.noImageResponses = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findFreePort() {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const server = net.createServer();
|
|
||||||
server.listen(0, () => {
|
|
||||||
const { port } = server.address() as net.AddressInfo;
|
|
||||||
server.close(() => resolve(port));
|
|
||||||
});
|
|
||||||
server.on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadConfig(configFile: string | undefined): Promise<Config> {
|
|
||||||
if (!configFile)
|
|
||||||
return {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(await fs.promises.readFile(configFile, 'utf8'));
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to load config file: ${configFile}, ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function outputFile(config: Config, name: string): Promise<string> {
|
|
||||||
const result = config.outputDir ?? os.tmpdir();
|
|
||||||
await fs.promises.mkdir(result, { recursive: true });
|
|
||||||
const fileName = sanitizeForFilePath(name);
|
|
||||||
return path.join(result, fileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(obj ?? {}).filter(([_, v]) => v !== undefined)
|
|
||||||
) as Partial<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeConfig(base: Config, overrides: Config): Config {
|
|
||||||
const browser: Config['browser'] = {
|
|
||||||
...pickDefined(base.browser),
|
|
||||||
...pickDefined(overrides.browser),
|
|
||||||
launchOptions: {
|
|
||||||
...pickDefined(base.browser?.launchOptions),
|
|
||||||
...pickDefined(overrides.browser?.launchOptions),
|
|
||||||
...{ assistantMode: true },
|
|
||||||
},
|
|
||||||
contextOptions: {
|
|
||||||
...pickDefined(base.browser?.contextOptions),
|
|
||||||
...pickDefined(overrides.browser?.contextOptions),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (browser.browserName !== 'chromium' && browser.launchOptions)
|
|
||||||
delete browser.launchOptions.channel;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...pickDefined(base),
|
|
||||||
...pickDefined(overrides),
|
|
||||||
browser,
|
|
||||||
network: {
|
|
||||||
...pickDefined(base.network),
|
|
||||||
...pickDefined(overrides.network),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
||||||
import { CallToolRequestSchema, ListToolsRequestSchema, Tool as McpTool } from '@modelcontextprotocol/sdk/types.js';
|
|
||||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
||||||
|
|
||||||
import { Context, packageJSON } from './context.js';
|
|
||||||
import { snapshotTools, visionTools } from './tools.js';
|
|
||||||
|
|
||||||
import type { Config } from '../config.js';
|
|
||||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
||||||
|
|
||||||
export async function createConnection(config: Config): Promise<Connection> {
|
|
||||||
const allTools = config.vision ? visionTools : snapshotTools;
|
|
||||||
const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));
|
|
||||||
|
|
||||||
const context = new Context(tools, config);
|
|
||||||
const server = new Server({ name: 'Playwright', version: packageJSON.version }, {
|
|
||||||
capabilities: {
|
|
||||||
tools: {},
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
||||||
return {
|
|
||||||
tools: tools.map(tool => ({
|
|
||||||
name: tool.schema.name,
|
|
||||||
description: tool.schema.description,
|
|
||||||
inputSchema: zodToJsonSchema(tool.schema.inputSchema),
|
|
||||||
annotations: {
|
|
||||||
title: tool.schema.title,
|
|
||||||
readOnlyHint: tool.schema.type === 'readOnly',
|
|
||||||
destructiveHint: tool.schema.type === 'destructive',
|
|
||||||
openWorldHint: true,
|
|
||||||
},
|
|
||||||
})) as McpTool[],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
server.setRequestHandler(CallToolRequestSchema, async request => {
|
|
||||||
const errorResult = (...messages: string[]) => ({
|
|
||||||
content: [{ type: 'text', text: messages.join('\n') }],
|
|
||||||
isError: true,
|
|
||||||
});
|
|
||||||
const tool = tools.find(tool => tool.schema.name === request.params.name);
|
|
||||||
if (!tool)
|
|
||||||
return errorResult(`Tool "${request.params.name}" not found`);
|
|
||||||
|
|
||||||
|
|
||||||
const modalStates = context.modalStates().map(state => state.type);
|
|
||||||
if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState))
|
|
||||||
return errorResult(`The tool "${request.params.name}" can only be used when there is related modal state present.`, ...context.modalStatesMarkdown());
|
|
||||||
if (!tool.clearsModalState && modalStates.length)
|
|
||||||
return errorResult(`Tool "${request.params.name}" does not handle the modal state.`, ...context.modalStatesMarkdown());
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await context.run(tool, request.params.arguments);
|
|
||||||
} catch (error) {
|
|
||||||
return errorResult(String(error));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const connection = new Connection(server, context);
|
|
||||||
return connection;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Connection {
|
|
||||||
readonly server: Server;
|
|
||||||
readonly context: Context;
|
|
||||||
|
|
||||||
constructor(server: Server, context: Context) {
|
|
||||||
this.server = server;
|
|
||||||
this.context = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
async connect(transport: Transport) {
|
|
||||||
await this.server.connect(transport);
|
|
||||||
await new Promise<void>(resolve => {
|
|
||||||
this.server.oninitialized = () => resolve();
|
|
||||||
});
|
|
||||||
if (this.server.getClientVersion()?.name.includes('cursor'))
|
|
||||||
this.context.config.noImageResponses = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async close() {
|
|
||||||
await this.server.close();
|
|
||||||
await this.context.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
402
src/context.ts
402
src/context.ts
@@ -1,402 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import url from 'node:url';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
import * as playwright from 'playwright';
|
|
||||||
|
|
||||||
import { waitForCompletion } from './tools/utils.js';
|
|
||||||
import { ManualPromise } from './manualPromise.js';
|
|
||||||
import { Tab } from './tab.js';
|
|
||||||
|
|
||||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
|
||||||
import type { ModalState, Tool, ToolActionResult } from './tools/tool.js';
|
|
||||||
import type { Config } from '../config.js';
|
|
||||||
import { outputFile } from './config.js';
|
|
||||||
|
|
||||||
type PendingAction = {
|
|
||||||
dialogShown: ManualPromise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type BrowserContextAndBrowser = {
|
|
||||||
browser?: playwright.Browser;
|
|
||||||
browserContext: playwright.BrowserContext;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class Context {
|
|
||||||
readonly tools: Tool[];
|
|
||||||
readonly config: Config;
|
|
||||||
private _browserContextPromise: Promise<BrowserContextAndBrowser> | undefined;
|
|
||||||
private _tabs: Tab[] = [];
|
|
||||||
private _currentTab: Tab | undefined;
|
|
||||||
private _modalStates: (ModalState & { tab: Tab })[] = [];
|
|
||||||
private _pendingAction: PendingAction | undefined;
|
|
||||||
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
|
|
||||||
|
|
||||||
constructor(tools: Tool[], config: Config) {
|
|
||||||
this.tools = tools;
|
|
||||||
this.config = config;
|
|
||||||
}
|
|
||||||
|
|
||||||
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'];
|
|
||||||
if (this._modalStates.length === 0)
|
|
||||||
result.push('- There is no modal state present');
|
|
||||||
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 { browserContext } = await this._ensureBrowserContext();
|
|
||||||
if (!this._currentTab)
|
|
||||||
await browserContext.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, tool.schema.inputSchema.parse(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._downloads.length) {
|
|
||||||
result.push('', '### Downloads');
|
|
||||||
for (const entry of this._downloads) {
|
|
||||||
if (entry.finished)
|
|
||||||
result.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
|
|
||||||
else
|
|
||||||
result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
|
|
||||||
}
|
|
||||||
result.push('');
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
async downloadStarted(tab: Tab, download: playwright.Download) {
|
|
||||||
const entry = {
|
|
||||||
download,
|
|
||||||
finished: false,
|
|
||||||
outputFile: await outputFile(this.config, download.suggestedFilename())
|
|
||||||
};
|
|
||||||
this._downloads.push(entry);
|
|
||||||
await download.saveAs(entry.outputFile);
|
|
||||||
entry.finished = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
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._tabs.length)
|
|
||||||
void this.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
async close() {
|
|
||||||
if (!this._browserContextPromise)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const promise = this._browserContextPromise;
|
|
||||||
this._browserContextPromise = undefined;
|
|
||||||
|
|
||||||
await promise.then(async ({ browserContext, browser }) => {
|
|
||||||
await browserContext.close().then(async () => {
|
|
||||||
await browser?.close();
|
|
||||||
}).catch(() => {});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _setupRequestInterception(context: playwright.BrowserContext) {
|
|
||||||
if (this.config.network?.allowedOrigins?.length) {
|
|
||||||
await context.route('**', route => route.abort('blockedbyclient'));
|
|
||||||
|
|
||||||
for (const origin of this.config.network.allowedOrigins)
|
|
||||||
await context.route(`*://${origin}/**`, route => route.continue());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.config.network?.blockedOrigins?.length) {
|
|
||||||
for (const origin of this.config.network.blockedOrigins)
|
|
||||||
await context.route(`*://${origin}/**`, route => route.abort('blockedbyclient'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _ensureBrowserContext() {
|
|
||||||
if (!this._browserContextPromise) {
|
|
||||||
this._browserContextPromise = this._setupBrowserContext();
|
|
||||||
this._browserContextPromise.catch(() => {
|
|
||||||
this._browserContextPromise = undefined;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return this._browserContextPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _setupBrowserContext(): Promise<BrowserContextAndBrowser> {
|
|
||||||
const { browser, browserContext } = await this._createBrowserContext();
|
|
||||||
await this._setupRequestInterception(browserContext);
|
|
||||||
for (const page of browserContext.pages())
|
|
||||||
this._onPageCreated(page);
|
|
||||||
browserContext.on('page', page => this._onPageCreated(page));
|
|
||||||
return { browser, browserContext };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _createBrowserContext(): Promise<BrowserContextAndBrowser> {
|
|
||||||
if (this.config.browser?.remoteEndpoint) {
|
|
||||||
const url = new URL(this.config.browser?.remoteEndpoint);
|
|
||||||
if (this.config.browser.browserName)
|
|
||||||
url.searchParams.set('browser', this.config.browser.browserName);
|
|
||||||
if (this.config.browser.launchOptions)
|
|
||||||
url.searchParams.set('launch-options', JSON.stringify(this.config.browser.launchOptions));
|
|
||||||
const browser = await playwright[this.config.browser?.browserName ?? 'chromium'].connect(String(url));
|
|
||||||
const browserContext = await browser.newContext();
|
|
||||||
return { browser, browserContext };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.config.browser?.cdpEndpoint) {
|
|
||||||
const browser = await playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint);
|
|
||||||
const browserContext = this.config.browser.isolated ? await browser.newContext() : browser.contexts()[0];
|
|
||||||
return { browser, browserContext };
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.config.browser?.isolated ?
|
|
||||||
await createIsolatedContext(this.config.browser) :
|
|
||||||
await launchPersistentContext(this.config.browser);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createIsolatedContext(browserConfig: Config['browser']): Promise<BrowserContextAndBrowser> {
|
|
||||||
try {
|
|
||||||
const browserName = browserConfig?.browserName ?? 'chromium';
|
|
||||||
const browserType = playwright[browserName];
|
|
||||||
const browser = await browserType.launch(browserConfig?.launchOptions);
|
|
||||||
const browserContext = await browser.newContext(browserConfig?.contextOptions);
|
|
||||||
return { browser, browserContext };
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.message.includes('Executable doesn\'t exist'))
|
|
||||||
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function launchPersistentContext(browserConfig: Config['browser']): Promise<BrowserContextAndBrowser> {
|
|
||||||
try {
|
|
||||||
const browserName = browserConfig?.browserName ?? 'chromium';
|
|
||||||
const userDataDir = browserConfig?.userDataDir ?? await createUserDataDir({ ...browserConfig, browserName });
|
|
||||||
const browserType = playwright[browserName];
|
|
||||||
const browserContext = await browserType.launchPersistentContext(userDataDir, { ...browserConfig?.launchOptions, ...browserConfig?.contextOptions });
|
|
||||||
return { browserContext };
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.message.includes('Executable doesn\'t exist'))
|
|
||||||
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createUserDataDir(browserConfig: Config['browser']) {
|
|
||||||
let cacheDirectory: string;
|
|
||||||
if (process.platform === 'linux')
|
|
||||||
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
|
||||||
else if (process.platform === 'darwin')
|
|
||||||
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
|
|
||||||
else if (process.platform === 'win32')
|
|
||||||
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
||||||
else
|
|
||||||
throw new Error('Unsupported platform: ' + process.platform);
|
|
||||||
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserConfig?.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
|
|
||||||
await fs.promises.mkdir(result, { recursive: true });
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
|
||||||
return (locator as any)._generateLocatorString();
|
|
||||||
}
|
|
||||||
|
|
||||||
const __filename = url.fileURLToPath(import.meta.url);
|
|
||||||
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8'));
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -1,50 +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';
|
|
||||||
|
|
||||||
export class PageSnapshot {
|
|
||||||
private _page: playwright.Page;
|
|
||||||
private _text!: string;
|
|
||||||
|
|
||||||
constructor(page: playwright.Page) {
|
|
||||||
this._page = page;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async create(page: playwright.Page): Promise<PageSnapshot> {
|
|
||||||
const snapshot = new PageSnapshot(page);
|
|
||||||
await snapshot._build();
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
text(): string {
|
|
||||||
return this._text;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _build() {
|
|
||||||
const yamlDocument = await (this._page as any)._snapshotForAI();
|
|
||||||
this._text = [
|
|
||||||
`- Page Snapshot`,
|
|
||||||
'```yaml',
|
|
||||||
yamlDocument.toString({ indentSeq: false }).trim(),
|
|
||||||
'```',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
refLocator(ref: string): playwright.Locator {
|
|
||||||
return this._page.locator(`aria-ref=${ref}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +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 { program } from 'commander';
|
|
||||||
|
|
||||||
import { startHttpTransport, startStdioTransport } from './transport.js';
|
|
||||||
import { resolveConfig } from './config.js';
|
|
||||||
|
|
||||||
import type { Connection } from './connection.js';
|
|
||||||
import { packageJSON } from './context.js';
|
|
||||||
|
|
||||||
program
|
|
||||||
.version('Version ' + packageJSON.version)
|
|
||||||
.name(packageJSON.name)
|
|
||||||
.option('--allowed-origins <origins>', 'semicolon-separated list of origins to allow the browser to request. Default is to allow all.', semicolonSeparatedList)
|
|
||||||
.option('--blocked-origins <origins>', 'semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.', semicolonSeparatedList)
|
|
||||||
.option('--block-service-workers', 'block service workers')
|
|
||||||
.option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
|
|
||||||
.option('--caps <caps>', 'comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
|
|
||||||
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
|
|
||||||
.option('--config <path>', 'path to the configuration file.')
|
|
||||||
.option('--device <device>', 'device to emulate, for example: "iPhone 15"')
|
|
||||||
.option('--executable-path <path>', 'path to the browser executable.')
|
|
||||||
.option('--headless', 'run browser in headless mode, headed by default')
|
|
||||||
.option('--host <host>', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
|
|
||||||
.option('--ignore-https-errors', 'ignore https errors')
|
|
||||||
.option('--isolated', 'keep the browser profile in memory, do not save it to disk.')
|
|
||||||
.option('--no-image-responses', 'do not send image responses to the client.')
|
|
||||||
.option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.')
|
|
||||||
.option('--output-dir <path>', 'path to the directory for output files.')
|
|
||||||
.option('--port <port>', 'port to listen on for SSE transport.')
|
|
||||||
.option('--proxy-bypass <bypass>', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"')
|
|
||||||
.option('--proxy-server <proxy>', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"')
|
|
||||||
.option('--storage-state <path>', 'path to the storage state file for isolated sessions.')
|
|
||||||
.option('--user-agent <ua string>', 'specify user agent string')
|
|
||||||
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
|
|
||||||
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"')
|
|
||||||
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
|
|
||||||
.action(async options => {
|
|
||||||
const config = await resolveConfig(options);
|
|
||||||
const connectionList: Connection[] = [];
|
|
||||||
setupExitWatchdog(connectionList);
|
|
||||||
|
|
||||||
if (options.port)
|
|
||||||
startHttpTransport(config, +options.port, options.host, connectionList);
|
|
||||||
else
|
|
||||||
await startStdioTransport(config, connectionList);
|
|
||||||
});
|
|
||||||
|
|
||||||
function setupExitWatchdog(connectionList: Connection[]) {
|
|
||||||
const handleExit = async () => {
|
|
||||||
setTimeout(() => process.exit(0), 15000);
|
|
||||||
for (const connection of connectionList)
|
|
||||||
await connection.close();
|
|
||||||
process.exit(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
process.stdin.on('close', handleExit);
|
|
||||||
process.on('SIGINT', handleExit);
|
|
||||||
process.on('SIGTERM', handleExit);
|
|
||||||
}
|
|
||||||
|
|
||||||
function semicolonSeparatedList(value: string): string[] {
|
|
||||||
return value.split(';').map(v => v.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
program.parse(process.argv);
|
|
||||||
112
src/tab.ts
112
src/tab.ts
@@ -1,112 +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 { PageSnapshot } from './pageSnapshot.js';
|
|
||||||
|
|
||||||
import type { Context } from './context.js';
|
|
||||||
|
|
||||||
export class Tab {
|
|
||||||
readonly context: Context;
|
|
||||||
readonly page: playwright.Page;
|
|
||||||
private _consoleMessages: playwright.ConsoleMessage[] = [];
|
|
||||||
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
|
|
||||||
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._consoleMessages.push(event));
|
|
||||||
page.on('request', request => this._requests.set(request, null));
|
|
||||||
page.on('response', response => this._requests.set(response.request(), response));
|
|
||||||
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.on('download', download => {
|
|
||||||
void this.context.downloadStarted(this, download);
|
|
||||||
});
|
|
||||||
page.setDefaultNavigationTimeout(60000);
|
|
||||||
page.setDefaultTimeout(5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _clearCollectedArtifacts() {
|
|
||||||
this._consoleMessages.length = 0;
|
|
||||||
this._requests.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
private _onClose() {
|
|
||||||
this._clearCollectedArtifacts();
|
|
||||||
this._onPageClose(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
async navigate(url: string) {
|
|
||||||
this._clearCollectedArtifacts();
|
|
||||||
|
|
||||||
const downloadEvent = this.page.waitForEvent('download').catch(() => {});
|
|
||||||
try {
|
|
||||||
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
|
|
||||||
} catch (_e: unknown) {
|
|
||||||
const e = _e as Error;
|
|
||||||
const mightBeDownload =
|
|
||||||
e.message.includes('net::ERR_ABORTED') // chromium
|
|
||||||
|| e.message.includes('Download is starting'); // firefox + webkit
|
|
||||||
if (!mightBeDownload)
|
|
||||||
throw e;
|
|
||||||
|
|
||||||
// on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit
|
|
||||||
const download = await Promise.race([
|
|
||||||
downloadEvent,
|
|
||||||
new Promise(resolve => setTimeout(resolve, 500)),
|
|
||||||
]);
|
|
||||||
if (!download)
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cap load event to 5 seconds, the page is operational at this point.
|
|
||||||
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
hasSnapshot(): boolean {
|
|
||||||
return !!this._snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshotOrDie(): PageSnapshot {
|
|
||||||
if (!this._snapshot)
|
|
||||||
throw new Error('No snapshot available');
|
|
||||||
return this._snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
consoleMessages(): playwright.ConsoleMessage[] {
|
|
||||||
return this._consoleMessages;
|
|
||||||
}
|
|
||||||
|
|
||||||
requests(): Map<playwright.Request, playwright.Response | null> {
|
|
||||||
return this._requests;
|
|
||||||
}
|
|
||||||
|
|
||||||
async captureSnapshot() {
|
|
||||||
this._snapshot = await PageSnapshot.create(this.page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
66
src/tools.ts
66
src/tools.ts
@@ -1,66 +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 common from './tools/common.js';
|
|
||||||
import console from './tools/console.js';
|
|
||||||
import dialogs from './tools/dialogs.js';
|
|
||||||
import files from './tools/files.js';
|
|
||||||
import install from './tools/install.js';
|
|
||||||
import keyboard from './tools/keyboard.js';
|
|
||||||
import navigate from './tools/navigate.js';
|
|
||||||
import network from './tools/network.js';
|
|
||||||
import pdf from './tools/pdf.js';
|
|
||||||
import snapshot from './tools/snapshot.js';
|
|
||||||
import tabs from './tools/tabs.js';
|
|
||||||
import screenshot from './tools/screenshot.js';
|
|
||||||
import testing from './tools/testing.js';
|
|
||||||
import vision from './tools/vision.js';
|
|
||||||
import wait from './tools/wait.js';
|
|
||||||
|
|
||||||
import type { Tool } from './tools/tool.js';
|
|
||||||
|
|
||||||
export const snapshotTools: Tool<any>[] = [
|
|
||||||
...common(true),
|
|
||||||
...console,
|
|
||||||
...dialogs(true),
|
|
||||||
...files(true),
|
|
||||||
...install,
|
|
||||||
...keyboard(true),
|
|
||||||
...navigate(true),
|
|
||||||
...network,
|
|
||||||
...pdf,
|
|
||||||
...screenshot,
|
|
||||||
...snapshot,
|
|
||||||
...tabs(true),
|
|
||||||
...testing,
|
|
||||||
...wait(true),
|
|
||||||
];
|
|
||||||
|
|
||||||
export const visionTools: Tool<any>[] = [
|
|
||||||
...common(false),
|
|
||||||
...console,
|
|
||||||
...dialogs(false),
|
|
||||||
...files(false),
|
|
||||||
...install,
|
|
||||||
...keyboard(false),
|
|
||||||
...navigate(false),
|
|
||||||
...network,
|
|
||||||
...pdf,
|
|
||||||
...tabs(false),
|
|
||||||
...testing,
|
|
||||||
...vision,
|
|
||||||
...wait(false),
|
|
||||||
];
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { defineTool, type ToolFactory } from './tool.js';
|
|
||||||
|
|
||||||
const close = defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
|
|
||||||
schema: {
|
|
||||||
name: 'browser_close',
|
|
||||||
title: 'Close browser',
|
|
||||||
description: 'Close the page',
|
|
||||||
inputSchema: z.object({}),
|
|
||||||
type: 'readOnly',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async context => {
|
|
||||||
await context.close();
|
|
||||||
return {
|
|
||||||
code: [`await page.close()`],
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const resize: ToolFactory = captureSnapshot => defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_resize',
|
|
||||||
title: 'Resize browser window',
|
|
||||||
description: 'Resize the browser window',
|
|
||||||
inputSchema: z.object({
|
|
||||||
width: z.number().describe('Width of the browser window'),
|
|
||||||
height: z.number().describe('Height of the browser window'),
|
|
||||||
}),
|
|
||||||
type: 'readOnly',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
|
|
||||||
const code = [
|
|
||||||
`// Resize browser window to ${params.width}x${params.height}`,
|
|
||||||
`await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`
|
|
||||||
];
|
|
||||||
|
|
||||||
const action = async () => {
|
|
||||||
await tab.page.setViewportSize({ width: params.width, height: params.height });
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action,
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: true
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default (captureSnapshot: boolean) => [
|
|
||||||
close,
|
|
||||||
resize(captureSnapshot)
|
|
||||||
];
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { defineTool } from './tool.js';
|
|
||||||
|
|
||||||
const console = defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_console_messages',
|
|
||||||
title: 'Get console messages',
|
|
||||||
description: 'Returns all console messages',
|
|
||||||
inputSchema: z.object({}),
|
|
||||||
type: 'readOnly',
|
|
||||||
},
|
|
||||||
handle: async context => {
|
|
||||||
const messages = context.currentTabOrDie().consoleMessages();
|
|
||||||
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,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 { defineTool, type ToolFactory } from './tool.js';
|
|
||||||
|
|
||||||
const handleDialog: ToolFactory = captureSnapshot => defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
|
|
||||||
schema: {
|
|
||||||
name: 'browser_handle_dialog',
|
|
||||||
title: 'Handle a dialog',
|
|
||||||
description: 'Handle a dialog',
|
|
||||||
inputSchema: 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.'),
|
|
||||||
}),
|
|
||||||
type: 'destructive',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const dialogState = context.modalStates().find(state => state.type === 'dialog');
|
|
||||||
if (!dialogState)
|
|
||||||
throw new Error('No dialog visible');
|
|
||||||
|
|
||||||
if (params.accept)
|
|
||||||
await dialogState.dialog.accept(params.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,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 { z } from 'zod';
|
|
||||||
import { defineTool, type ToolFactory } from './tool.js';
|
|
||||||
|
|
||||||
const uploadFile: ToolFactory = captureSnapshot => defineTool({
|
|
||||||
capability: 'files',
|
|
||||||
|
|
||||||
schema: {
|
|
||||||
name: 'browser_file_upload',
|
|
||||||
title: 'Upload files',
|
|
||||||
description: 'Upload one or multiple files',
|
|
||||||
inputSchema: z.object({
|
|
||||||
paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'),
|
|
||||||
}),
|
|
||||||
type: 'destructive',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, 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 ${params.paths.join(', ')}`,
|
|
||||||
];
|
|
||||||
|
|
||||||
const action = async () => {
|
|
||||||
await modalState.fileChooser.setFiles(params.paths);
|
|
||||||
context.clearModalState(modalState);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action,
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
clearsModalState: 'fileChooser',
|
|
||||||
});
|
|
||||||
|
|
||||||
export default (captureSnapshot: boolean) => [
|
|
||||||
uploadFile(captureSnapshot),
|
|
||||||
];
|
|
||||||
@@ -1,63 +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 { defineTool } from './tool.js';
|
|
||||||
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const install = defineTool({
|
|
||||||
capability: 'install',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_install',
|
|
||||||
title: 'Install the browser specified in the config',
|
|
||||||
description: 'Install the browser specified in the config. Call this if you get an error about the browser not being installed.',
|
|
||||||
inputSchema: z.object({}),
|
|
||||||
type: 'destructive',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async context => {
|
|
||||||
const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.browserName ?? 'chrome';
|
|
||||||
const cliUrl = import.meta.resolve('playwright/package.json');
|
|
||||||
const cliPath = path.join(fileURLToPath(cliUrl), '..', 'cli.js');
|
|
||||||
const child = fork(cliPath, ['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,54 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { defineTool, type ToolFactory } from './tool.js';
|
|
||||||
|
|
||||||
const pressKey: ToolFactory = captureSnapshot => defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
|
|
||||||
schema: {
|
|
||||||
name: 'browser_press_key',
|
|
||||||
title: 'Press a key',
|
|
||||||
description: 'Press a key on the keyboard',
|
|
||||||
inputSchema: z.object({
|
|
||||||
key: z.string().describe('Name of the key to press or a character to generate, such as `ArrowLeft` or `a`'),
|
|
||||||
}),
|
|
||||||
type: 'destructive',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
|
|
||||||
const code = [
|
|
||||||
`// Press ${params.key}`,
|
|
||||||
`await page.keyboard.press('${params.key}');`,
|
|
||||||
];
|
|
||||||
|
|
||||||
const action = () => tab.page.keyboard.press(params.key);
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action,
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: true
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default (captureSnapshot: boolean) => [
|
|
||||||
pressKey(captureSnapshot),
|
|
||||||
];
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { defineTool, type ToolFactory } from './tool.js';
|
|
||||||
|
|
||||||
const navigate: ToolFactory = captureSnapshot => defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
|
|
||||||
schema: {
|
|
||||||
name: 'browser_navigate',
|
|
||||||
title: 'Navigate to a URL',
|
|
||||||
description: 'Navigate to a URL',
|
|
||||||
inputSchema: z.object({
|
|
||||||
url: z.string().describe('The URL to navigate to'),
|
|
||||||
}),
|
|
||||||
type: 'destructive',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const tab = await context.ensureTab();
|
|
||||||
await tab.navigate(params.url);
|
|
||||||
|
|
||||||
const code = [
|
|
||||||
`// Navigate to ${params.url}`,
|
|
||||||
`await page.goto('${params.url}');`,
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const goBack: ToolFactory = captureSnapshot => defineTool({
|
|
||||||
capability: 'history',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_navigate_back',
|
|
||||||
title: 'Go back',
|
|
||||||
description: 'Go back to the previous page',
|
|
||||||
inputSchema: z.object({}),
|
|
||||||
type: 'readOnly',
|
|
||||||
},
|
|
||||||
|
|
||||||
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 goForward: ToolFactory = captureSnapshot => defineTool({
|
|
||||||
capability: 'history',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_navigate_forward',
|
|
||||||
title: 'Go forward',
|
|
||||||
description: 'Go forward to the next page',
|
|
||||||
inputSchema: z.object({}),
|
|
||||||
type: 'readOnly',
|
|
||||||
},
|
|
||||||
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 { z } from 'zod';
|
|
||||||
import { defineTool } from './tool.js';
|
|
||||||
|
|
||||||
import type * as playwright from 'playwright';
|
|
||||||
|
|
||||||
const requests = defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
|
|
||||||
schema: {
|
|
||||||
name: 'browser_network_requests',
|
|
||||||
title: 'List network requests',
|
|
||||||
description: 'Returns all network requests since loading the page',
|
|
||||||
inputSchema: z.object({}),
|
|
||||||
type: 'readOnly',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async context => {
|
|
||||||
const requests = context.currentTabOrDie().requests();
|
|
||||||
const log = [...requests.entries()].map(([request, response]) => renderRequest(request, response)).join('\n');
|
|
||||||
return {
|
|
||||||
code: [`// <internal code to list network requests>`],
|
|
||||||
action: async () => {
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text', text: log }]
|
|
||||||
};
|
|
||||||
},
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function renderRequest(request: playwright.Request, response: playwright.Response | null) {
|
|
||||||
const result: string[] = [];
|
|
||||||
result.push(`[${request.method().toUpperCase()}] ${request.url()}`);
|
|
||||||
if (response)
|
|
||||||
result.push(`=> [${response.status()}] ${response.statusText()}`);
|
|
||||||
return result.join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
export default [
|
|
||||||
requests,
|
|
||||||
];
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { defineTool } from './tool.js';
|
|
||||||
|
|
||||||
import * as javascript from '../javascript.js';
|
|
||||||
import { outputFile } from '../config.js';
|
|
||||||
|
|
||||||
const pdfSchema = z.object({
|
|
||||||
filename: z.string().optional().describe('File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const pdf = defineTool({
|
|
||||||
capability: 'pdf',
|
|
||||||
|
|
||||||
schema: {
|
|
||||||
name: 'browser_pdf_save',
|
|
||||||
title: 'Save as PDF',
|
|
||||||
description: 'Save page as PDF',
|
|
||||||
inputSchema: pdfSchema,
|
|
||||||
type: 'readOnly',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
const fileName = await outputFile(context.config, params.filename ?? `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,90 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { defineTool } from './tool.js';
|
|
||||||
import * as javascript from '../javascript.js';
|
|
||||||
import { outputFile } from '../config.js';
|
|
||||||
import { generateLocator } from './utils.js';
|
|
||||||
|
|
||||||
import type * as playwright from 'playwright';
|
|
||||||
|
|
||||||
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.'),
|
|
||||||
filename: z.string().optional().describe('File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.'),
|
|
||||||
element: z.string().optional().describe('Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.'),
|
|
||||||
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 = defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_take_screenshot',
|
|
||||||
title: 'Take a screenshot',
|
|
||||||
description: `Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.`,
|
|
||||||
inputSchema: screenshotSchema,
|
|
||||||
type: 'readOnly',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
const snapshot = tab.snapshotOrDie();
|
|
||||||
const fileType = params.raw ? 'png' : 'jpeg';
|
|
||||||
const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
|
|
||||||
const options: playwright.PageScreenshotOptions = { type: fileType, quality: fileType === 'png' ? undefined : 50, scale: 'css', path: fileName };
|
|
||||||
const isElementScreenshot = params.element && params.ref;
|
|
||||||
|
|
||||||
const code = [
|
|
||||||
`// Screenshot ${isElementScreenshot ? params.element : 'viewport'} and save it as ${fileName}`,
|
|
||||||
];
|
|
||||||
|
|
||||||
const locator = params.ref ? snapshot.refLocator(params.ref) : null;
|
|
||||||
|
|
||||||
if (locator)
|
|
||||||
code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
|
|
||||||
else
|
|
||||||
code.push(`await page.screenshot(${javascript.formatObject(options)});`);
|
|
||||||
|
|
||||||
const includeBase64 = !context.config.noImageResponses;
|
|
||||||
const action = async () => {
|
|
||||||
const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
|
|
||||||
return {
|
|
||||||
content: includeBase64 ? [{
|
|
||||||
type: 'image' as 'image',
|
|
||||||
data: screenshot.toString('base64'),
|
|
||||||
mimeType: fileType === 'png' ? 'image/png' : 'image/jpeg',
|
|
||||||
}] : []
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action,
|
|
||||||
captureSnapshot: true,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default [
|
|
||||||
screenshot,
|
|
||||||
];
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { defineTool } from './tool.js';
|
|
||||||
import * as javascript from '../javascript.js';
|
|
||||||
import { generateLocator } from './utils.js';
|
|
||||||
|
|
||||||
const snapshot = defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_snapshot',
|
|
||||||
title: 'Page snapshot',
|
|
||||||
description: 'Capture accessibility snapshot of the current page, this is better than screenshot',
|
|
||||||
inputSchema: z.object({}),
|
|
||||||
type: 'readOnly',
|
|
||||||
},
|
|
||||||
|
|
||||||
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 = defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_click',
|
|
||||||
title: 'Click',
|
|
||||||
description: 'Perform click on a web page',
|
|
||||||
inputSchema: elementSchema,
|
|
||||||
type: 'destructive',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
const locator = tab.snapshotOrDie().refLocator(params.ref);
|
|
||||||
|
|
||||||
const code = [
|
|
||||||
`// Click ${params.element}`,
|
|
||||||
`await page.${await generateLocator(locator)}.click();`
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action: () => locator.click(),
|
|
||||||
captureSnapshot: true,
|
|
||||||
waitForNetwork: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const drag = defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_drag',
|
|
||||||
title: 'Drag mouse',
|
|
||||||
description: 'Perform drag and drop between two elements',
|
|
||||||
inputSchema: 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'),
|
|
||||||
}),
|
|
||||||
type: 'destructive',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
|
||||||
const startLocator = snapshot.refLocator(params.startRef);
|
|
||||||
const endLocator = snapshot.refLocator(params.endRef);
|
|
||||||
|
|
||||||
const code = [
|
|
||||||
`// Drag ${params.startElement} to ${params.endElement}`,
|
|
||||||
`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action: () => startLocator.dragTo(endLocator),
|
|
||||||
captureSnapshot: true,
|
|
||||||
waitForNetwork: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const hover = defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_hover',
|
|
||||||
title: 'Hover mouse',
|
|
||||||
description: 'Hover over element on page',
|
|
||||||
inputSchema: elementSchema,
|
|
||||||
type: 'readOnly',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
|
||||||
const locator = snapshot.refLocator(params.ref);
|
|
||||||
|
|
||||||
const code = [
|
|
||||||
`// Hover over ${params.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 = defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_type',
|
|
||||||
title: 'Type text',
|
|
||||||
description: 'Type text into editable element',
|
|
||||||
inputSchema: typeSchema,
|
|
||||||
type: 'destructive',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
|
||||||
const locator = snapshot.refLocator(params.ref);
|
|
||||||
|
|
||||||
const code: string[] = [];
|
|
||||||
const steps: (() => Promise<void>)[] = [];
|
|
||||||
|
|
||||||
if (params.slowly) {
|
|
||||||
code.push(`// Press "${params.text}" sequentially into "${params.element}"`);
|
|
||||||
code.push(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`);
|
|
||||||
steps.push(() => locator.pressSequentially(params.text));
|
|
||||||
} else {
|
|
||||||
code.push(`// Fill "${params.text}" into "${params.element}"`);
|
|
||||||
code.push(`await page.${await generateLocator(locator)}.fill(${javascript.quote(params.text)});`);
|
|
||||||
steps.push(() => locator.fill(params.text));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.submit) {
|
|
||||||
code.push(`// Submit text`);
|
|
||||||
code.push(`await page.${await generateLocator(locator)}.press('Enter');`);
|
|
||||||
steps.push(() => locator.press('Enter'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action: () => steps.reduce((acc, step) => acc.then(step), Promise.resolve()),
|
|
||||||
captureSnapshot: true,
|
|
||||||
waitForNetwork: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectOptionSchema = elementSchema.extend({
|
|
||||||
values: z.array(z.string()).describe('Array of values to select in the dropdown. This can be a single value or multiple values.'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectOption = defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_select_option',
|
|
||||||
title: 'Select option',
|
|
||||||
description: 'Select an option in a dropdown',
|
|
||||||
inputSchema: selectOptionSchema,
|
|
||||||
type: 'destructive',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
|
||||||
const locator = snapshot.refLocator(params.ref);
|
|
||||||
|
|
||||||
const code = [
|
|
||||||
`// Select options [${params.values.join(', ')}] in ${params.element}`,
|
|
||||||
`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action: () => locator.selectOption(params.values).then(() => {}),
|
|
||||||
captureSnapshot: true,
|
|
||||||
waitForNetwork: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default [
|
|
||||||
snapshot,
|
|
||||||
click,
|
|
||||||
drag,
|
|
||||||
hover,
|
|
||||||
type,
|
|
||||||
selectOption,
|
|
||||||
];
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { defineTool, type ToolFactory } from './tool.js';
|
|
||||||
|
|
||||||
const listTabs = defineTool({
|
|
||||||
capability: 'tabs',
|
|
||||||
|
|
||||||
schema: {
|
|
||||||
name: 'browser_tab_list',
|
|
||||||
title: 'List tabs',
|
|
||||||
description: 'List browser tabs',
|
|
||||||
inputSchema: z.object({}),
|
|
||||||
type: 'readOnly',
|
|
||||||
},
|
|
||||||
|
|
||||||
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 selectTab: ToolFactory = captureSnapshot => defineTool({
|
|
||||||
capability: 'tabs',
|
|
||||||
|
|
||||||
schema: {
|
|
||||||
name: 'browser_tab_select',
|
|
||||||
title: 'Select a tab',
|
|
||||||
description: 'Select a tab by index',
|
|
||||||
inputSchema: z.object({
|
|
||||||
index: z.number().describe('The index of the tab to select'),
|
|
||||||
}),
|
|
||||||
type: 'readOnly',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
await context.selectTab(params.index);
|
|
||||||
const code = [
|
|
||||||
`// <internal code to select tab ${params.index}>`,
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: false
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const newTab: ToolFactory = captureSnapshot => defineTool({
|
|
||||||
capability: 'tabs',
|
|
||||||
|
|
||||||
schema: {
|
|
||||||
name: 'browser_tab_new',
|
|
||||||
title: 'Open a new tab',
|
|
||||||
description: 'Open a new tab',
|
|
||||||
inputSchema: z.object({
|
|
||||||
url: z.string().optional().describe('The URL to navigate to in the new tab. If not provided, the new tab will be blank.'),
|
|
||||||
}),
|
|
||||||
type: 'readOnly',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
await context.newTab();
|
|
||||||
if (params.url)
|
|
||||||
await context.currentTabOrDie().navigate(params.url);
|
|
||||||
|
|
||||||
const code = [
|
|
||||||
`// <internal code to open a new tab>`,
|
|
||||||
];
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: false
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const closeTab: ToolFactory = captureSnapshot => defineTool({
|
|
||||||
capability: 'tabs',
|
|
||||||
|
|
||||||
schema: {
|
|
||||||
name: 'browser_tab_close',
|
|
||||||
title: 'Close a tab',
|
|
||||||
description: 'Close a tab',
|
|
||||||
inputSchema: z.object({
|
|
||||||
index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
|
|
||||||
}),
|
|
||||||
type: 'destructive',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
await context.closeTab(params.index);
|
|
||||||
const code = [
|
|
||||||
`// <internal code to close tab ${params.index}>`,
|
|
||||||
];
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: false
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default (captureSnapshot: boolean) => [
|
|
||||||
listTabs,
|
|
||||||
newTab(captureSnapshot),
|
|
||||||
selectTab(captureSnapshot),
|
|
||||||
closeTab(captureSnapshot),
|
|
||||||
];
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { defineTool } from './tool.js';
|
|
||||||
|
|
||||||
const generateTestSchema = z.object({
|
|
||||||
name: z.string().describe('The name of the test'),
|
|
||||||
description: z.string().describe('The description of the test'),
|
|
||||||
steps: z.array(z.string()).describe('The steps of the test'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const generateTest = defineTool({
|
|
||||||
capability: 'testing',
|
|
||||||
|
|
||||||
schema: {
|
|
||||||
name: 'browser_generate_playwright_test',
|
|
||||||
title: 'Generate a Playwright test',
|
|
||||||
description: 'Generate a Playwright test for given scenario',
|
|
||||||
inputSchema: generateTestSchema,
|
|
||||||
type: 'readOnly',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
return {
|
|
||||||
resultOverride: {
|
|
||||||
content: [{
|
|
||||||
type: 'text',
|
|
||||||
text: instructions(params),
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
code: [],
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const instructions = (params: { name: string, description: string, steps: string[] }) => [
|
|
||||||
`## Instructions`,
|
|
||||||
`- You are a playwright test generator.`,
|
|
||||||
`- You are given a scenario and you need to generate a playwright test for it.`,
|
|
||||||
'- DO NOT generate test code based on the scenario alone. DO run steps one by one using the tools provided instead.',
|
|
||||||
'- Only after all steps are completed, emit a Playwright TypeScript test that uses @playwright/test based on message history',
|
|
||||||
'- Save generated test file in the tests directory',
|
|
||||||
`Test name: ${params.name}`,
|
|
||||||
`Description: ${params.description}`,
|
|
||||||
`Steps:`,
|
|
||||||
...params.steps.map((step, index) => `- ${index + 1}. ${step}`),
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
export default [
|
|
||||||
generateTest,
|
|
||||||
];
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
|
||||||
import type { z } from 'zod';
|
|
||||||
import type { Context } from '../context.js';
|
|
||||||
import type * as playwright from 'playwright';
|
|
||||||
import type { ToolCapability } from '../../config.js';
|
|
||||||
|
|
||||||
export type ToolSchema<Input extends InputType> = {
|
|
||||||
name: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
inputSchema: Input;
|
|
||||||
type: 'readOnly' | 'destructive';
|
|
||||||
};
|
|
||||||
|
|
||||||
type InputType = z.Schema;
|
|
||||||
|
|
||||||
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<Input extends InputType = InputType> = {
|
|
||||||
capability: ToolCapability;
|
|
||||||
schema: ToolSchema<Input>;
|
|
||||||
clearsModalState?: ModalState['type'];
|
|
||||||
handle: (context: Context, params: z.output<Input>) => Promise<ToolResult>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ToolFactory = (snapshot: boolean) => Tool<any>;
|
|
||||||
|
|
||||||
export function defineTool<Input extends InputType>(tool: Tool<Input>): Tool<Input> {
|
|
||||||
return tool;
|
|
||||||
}
|
|
||||||
@@ -1,83 +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.js';
|
|
||||||
|
|
||||||
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) {
|
|
||||||
const sanitize = (s: string) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
|
|
||||||
const separator = s.lastIndexOf('.');
|
|
||||||
if (separator === -1)
|
|
||||||
return sanitize(s);
|
|
||||||
return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
|
||||||
return (locator as any)._generateLocatorString();
|
|
||||||
}
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { defineTool } from './tool.js';
|
|
||||||
|
|
||||||
import * as javascript from '../javascript.js';
|
|
||||||
|
|
||||||
const elementSchema = z.object({
|
|
||||||
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const screenshot = defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_screen_capture',
|
|
||||||
title: 'Take a screenshot',
|
|
||||||
description: 'Take a screenshot of the current page',
|
|
||||||
inputSchema: z.object({}),
|
|
||||||
type: 'readOnly',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async context => {
|
|
||||||
const tab = await context.ensureTab();
|
|
||||||
const options = { type: 'jpeg' as 'jpeg', quality: 50, scale: 'css' as 'css' };
|
|
||||||
|
|
||||||
const code = [
|
|
||||||
`// Take a screenshot of the current page`,
|
|
||||||
`await page.screenshot(${javascript.formatObject(options)});`,
|
|
||||||
];
|
|
||||||
|
|
||||||
const action = () => tab.page.screenshot(options).then(buffer => {
|
|
||||||
return {
|
|
||||||
content: [{ type: 'image' as 'image', data: buffer.toString('base64'), mimeType: 'image/jpeg' }],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action,
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: false
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const moveMouse = defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_screen_move_mouse',
|
|
||||||
title: 'Move mouse',
|
|
||||||
description: 'Move mouse to a given position',
|
|
||||||
inputSchema: elementSchema.extend({
|
|
||||||
x: z.number().describe('X coordinate'),
|
|
||||||
y: z.number().describe('Y coordinate'),
|
|
||||||
}),
|
|
||||||
type: 'readOnly',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
const code = [
|
|
||||||
`// Move mouse to (${params.x}, ${params.y})`,
|
|
||||||
`await page.mouse.move(${params.x}, ${params.y});`,
|
|
||||||
];
|
|
||||||
const action = () => tab.page.mouse.move(params.x, params.y);
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action,
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: false
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const click = defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_screen_click',
|
|
||||||
title: 'Click',
|
|
||||||
description: 'Click left mouse button',
|
|
||||||
inputSchema: elementSchema.extend({
|
|
||||||
x: z.number().describe('X coordinate'),
|
|
||||||
y: z.number().describe('Y coordinate'),
|
|
||||||
}),
|
|
||||||
type: 'destructive',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
const code = [
|
|
||||||
`// Click mouse at coordinates (${params.x}, ${params.y})`,
|
|
||||||
`await page.mouse.move(${params.x}, ${params.y});`,
|
|
||||||
`await page.mouse.down();`,
|
|
||||||
`await page.mouse.up();`,
|
|
||||||
];
|
|
||||||
const action = async () => {
|
|
||||||
await tab.page.mouse.move(params.x, params.y);
|
|
||||||
await tab.page.mouse.down();
|
|
||||||
await tab.page.mouse.up();
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action,
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const drag = defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_screen_drag',
|
|
||||||
title: 'Drag mouse',
|
|
||||||
description: 'Drag left mouse button',
|
|
||||||
inputSchema: 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'),
|
|
||||||
}),
|
|
||||||
type: 'destructive',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
|
|
||||||
const code = [
|
|
||||||
`// Drag mouse from (${params.startX}, ${params.startY}) to (${params.endX}, ${params.endY})`,
|
|
||||||
`await page.mouse.move(${params.startX}, ${params.startY});`,
|
|
||||||
`await page.mouse.down();`,
|
|
||||||
`await page.mouse.move(${params.endX}, ${params.endY});`,
|
|
||||||
`await page.mouse.up();`,
|
|
||||||
];
|
|
||||||
|
|
||||||
const action = async () => {
|
|
||||||
await tab.page.mouse.move(params.startX, params.startY);
|
|
||||||
await tab.page.mouse.down();
|
|
||||||
await tab.page.mouse.move(params.endX, params.endY);
|
|
||||||
await tab.page.mouse.up();
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action,
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const type = defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_screen_type',
|
|
||||||
title: 'Type text',
|
|
||||||
description: 'Type text',
|
|
||||||
inputSchema: z.object({
|
|
||||||
text: z.string().describe('Text to type into the element'),
|
|
||||||
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
|
|
||||||
}),
|
|
||||||
type: 'destructive',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
|
|
||||||
const code = [
|
|
||||||
`// Type ${params.text}`,
|
|
||||||
`await page.keyboard.type('${params.text}');`,
|
|
||||||
];
|
|
||||||
|
|
||||||
const action = async () => {
|
|
||||||
await tab.page.keyboard.type(params.text);
|
|
||||||
if (params.submit)
|
|
||||||
await tab.page.keyboard.press('Enter');
|
|
||||||
};
|
|
||||||
|
|
||||||
if (params.submit) {
|
|
||||||
code.push(`// Submit text`);
|
|
||||||
code.push(`await page.keyboard.press('Enter');`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action,
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default [
|
|
||||||
screenshot,
|
|
||||||
moveMouse,
|
|
||||||
click,
|
|
||||||
drag,
|
|
||||||
type,
|
|
||||||
];
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { defineTool, type ToolFactory } from './tool.js';
|
|
||||||
|
|
||||||
const wait: ToolFactory = captureSnapshot => defineTool({
|
|
||||||
capability: 'wait',
|
|
||||||
|
|
||||||
schema: {
|
|
||||||
name: 'browser_wait_for',
|
|
||||||
title: 'Wait for',
|
|
||||||
description: 'Wait for text to appear or disappear or a specified time to pass',
|
|
||||||
inputSchema: z.object({
|
|
||||||
time: z.number().optional().describe('The time to wait in seconds'),
|
|
||||||
text: z.string().optional().describe('The text to wait for'),
|
|
||||||
textGone: z.string().optional().describe('The text to wait for to disappear'),
|
|
||||||
}),
|
|
||||||
type: 'readOnly',
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
if (!params.text && !params.textGone && !params.time)
|
|
||||||
throw new Error('Either time, text or textGone must be provided');
|
|
||||||
|
|
||||||
const code: string[] = [];
|
|
||||||
|
|
||||||
if (params.time) {
|
|
||||||
code.push(`await new Promise(f => setTimeout(f, ${params.time!} * 1000));`);
|
|
||||||
await new Promise(f => setTimeout(f, Math.min(10000, params.time! * 1000)));
|
|
||||||
}
|
|
||||||
|
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
const locator = params.text ? tab.page.getByText(params.text).first() : undefined;
|
|
||||||
const goneLocator = params.textGone ? tab.page.getByText(params.textGone).first() : undefined;
|
|
||||||
|
|
||||||
if (goneLocator) {
|
|
||||||
code.push(`await page.getByText(${JSON.stringify(params.textGone)}).first().waitFor({ state: 'hidden' });`);
|
|
||||||
await goneLocator.waitFor({ state: 'hidden' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (locator) {
|
|
||||||
code.push(`await page.getByText(${JSON.stringify(params.text)}).first().waitFor({ state: 'visible' });`);
|
|
||||||
await locator.waitFor({ state: 'visible' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default (captureSnapshot: boolean) => [
|
|
||||||
wait(captureSnapshot),
|
|
||||||
];
|
|
||||||
145
src/transport.ts
145
src/transport.ts
@@ -1,145 +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 'node:http';
|
|
||||||
import assert from 'node:assert';
|
|
||||||
import crypto from 'node:crypto';
|
|
||||||
|
|
||||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
||||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
||||||
|
|
||||||
import { createConnection } from './connection.js';
|
|
||||||
|
|
||||||
import type { Config } from '../config.js';
|
|
||||||
import type { Connection } from './connection.js';
|
|
||||||
|
|
||||||
export async function startStdioTransport(config: Config, connectionList: Connection[]) {
|
|
||||||
const connection = await createConnection(config);
|
|
||||||
await connection.connect(new StdioServerTransport());
|
|
||||||
connectionList.push(connection);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSSE(config: Config, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>, connectionList: Connection[]) {
|
|
||||||
if (req.method === 'POST') {
|
|
||||||
const sessionId = url.searchParams.get('sessionId');
|
|
||||||
if (!sessionId) {
|
|
||||||
res.statusCode = 400;
|
|
||||||
return res.end('Missing sessionId');
|
|
||||||
}
|
|
||||||
|
|
||||||
const transport = sessions.get(sessionId);
|
|
||||||
if (!transport) {
|
|
||||||
res.statusCode = 404;
|
|
||||||
return res.end('Session not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return await transport.handlePostMessage(req, res);
|
|
||||||
} else if (req.method === 'GET') {
|
|
||||||
const transport = new SSEServerTransport('/sse', res);
|
|
||||||
sessions.set(transport.sessionId, transport);
|
|
||||||
const connection = await createConnection(config);
|
|
||||||
await connection.connect(transport);
|
|
||||||
connectionList.push(connection);
|
|
||||||
res.on('close', () => {
|
|
||||||
sessions.delete(transport.sessionId);
|
|
||||||
connection.close().catch(e => {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error(e);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.statusCode = 405;
|
|
||||||
res.end('Method not allowed');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStreamable(config: Config, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, StreamableHTTPServerTransport>, connectionList: Connection[]) {
|
|
||||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
|
||||||
if (sessionId) {
|
|
||||||
const transport = sessions.get(sessionId);
|
|
||||||
if (!transport) {
|
|
||||||
res.statusCode = 404;
|
|
||||||
res.end('Session not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return await transport.handleRequest(req, res);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'POST') {
|
|
||||||
const transport = new StreamableHTTPServerTransport({
|
|
||||||
sessionIdGenerator: () => crypto.randomUUID(),
|
|
||||||
onsessioninitialized: sessionId => {
|
|
||||||
sessions.set(sessionId, transport);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
transport.onclose = () => {
|
|
||||||
if (transport.sessionId)
|
|
||||||
sessions.delete(transport.sessionId);
|
|
||||||
};
|
|
||||||
const connection = await createConnection(config);
|
|
||||||
connectionList.push(connection);
|
|
||||||
await Promise.all([
|
|
||||||
connection.connect(transport),
|
|
||||||
transport.handleRequest(req, res),
|
|
||||||
]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.statusCode = 400;
|
|
||||||
res.end('Invalid request');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startHttpTransport(config: Config, port: number, hostname: string | undefined, connectionList: Connection[]) {
|
|
||||||
const sseSessions = new Map<string, SSEServerTransport>();
|
|
||||||
const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
|
|
||||||
const httpServer = http.createServer(async (req, res) => {
|
|
||||||
const url = new URL(`http://localhost${req.url}`);
|
|
||||||
if (url.pathname.startsWith('/mcp'))
|
|
||||||
await handleStreamable(config, req, res, streamableSessions, connectionList);
|
|
||||||
else
|
|
||||||
await handleSSE(config, req, res, url, sseSessions, connectionList);
|
|
||||||
});
|
|
||||||
httpServer.listen(port, hostname, () => {
|
|
||||||
const address = httpServer.address();
|
|
||||||
assert(address, 'Could not bind server socket');
|
|
||||||
let url: string;
|
|
||||||
if (typeof address === 'string') {
|
|
||||||
url = address;
|
|
||||||
} else {
|
|
||||||
const resolvedPort = address.port;
|
|
||||||
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
|
|
||||||
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
|
|
||||||
resolvedHost = 'localhost';
|
|
||||||
url = `http://${resolvedHost}:${resolvedPort}`;
|
|
||||||
}
|
|
||||||
const message = [
|
|
||||||
`Listening on ${url}`,
|
|
||||||
'Put this in your client config:',
|
|
||||||
JSON.stringify({
|
|
||||||
'mcpServers': {
|
|
||||||
'playwright': {
|
|
||||||
'url': `${url}/sse`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, undefined, 2),
|
|
||||||
'If your client supports streamable HTTP, you can use the /mcp endpoint instead.',
|
|
||||||
].join('\n');
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log(message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures';
|
||||||
|
|
||||||
test('test snapshot tool list', async ({ client }) => {
|
test('test snapshot tool list', async ({ client }) => {
|
||||||
const { tools } = await client.listTools();
|
const { tools } = await client.listTools();
|
||||||
@@ -22,8 +22,9 @@ test('test snapshot tool list', async ({ client }) => {
|
|||||||
'browser_click',
|
'browser_click',
|
||||||
'browser_console_messages',
|
'browser_console_messages',
|
||||||
'browser_drag',
|
'browser_drag',
|
||||||
|
'browser_evaluate',
|
||||||
'browser_file_upload',
|
'browser_file_upload',
|
||||||
'browser_generate_playwright_test',
|
'browser_fill_form',
|
||||||
'browser_handle_dialog',
|
'browser_handle_dialog',
|
||||||
'browser_hover',
|
'browser_hover',
|
||||||
'browser_select_option',
|
'browser_select_option',
|
||||||
@@ -31,62 +32,75 @@ test('test snapshot tool list', async ({ client }) => {
|
|||||||
'browser_close',
|
'browser_close',
|
||||||
'browser_install',
|
'browser_install',
|
||||||
'browser_navigate_back',
|
'browser_navigate_back',
|
||||||
'browser_navigate_forward',
|
|
||||||
'browser_navigate',
|
'browser_navigate',
|
||||||
'browser_network_requests',
|
'browser_network_requests',
|
||||||
'browser_pdf_save',
|
|
||||||
'browser_press_key',
|
'browser_press_key',
|
||||||
'browser_resize',
|
'browser_resize',
|
||||||
'browser_snapshot',
|
'browser_snapshot',
|
||||||
'browser_tab_close',
|
'browser_tabs',
|
||||||
'browser_tab_list',
|
|
||||||
'browser_tab_new',
|
|
||||||
'browser_tab_select',
|
|
||||||
'browser_take_screenshot',
|
'browser_take_screenshot',
|
||||||
'browser_wait_for',
|
'browser_wait_for',
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test vision tool list', async ({ visionClient }) => {
|
test('test tool list proxy mode', async ({ startClient }) => {
|
||||||
const { tools: visionTools } = await visionClient.listTools();
|
const { client } = await startClient({
|
||||||
expect(new Set(visionTools.map(t => t.name))).toEqual(new Set([
|
args: ['--connect-tool'],
|
||||||
'browser_close',
|
});
|
||||||
|
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_console_messages',
|
||||||
|
'browser_drag',
|
||||||
|
'browser_evaluate',
|
||||||
'browser_file_upload',
|
'browser_file_upload',
|
||||||
'browser_generate_playwright_test',
|
'browser_fill_form',
|
||||||
'browser_handle_dialog',
|
'browser_handle_dialog',
|
||||||
|
'browser_hover',
|
||||||
|
'browser_select_option',
|
||||||
|
'browser_type',
|
||||||
|
'browser_close',
|
||||||
'browser_install',
|
'browser_install',
|
||||||
'browser_navigate_back',
|
'browser_navigate_back',
|
||||||
'browser_navigate_forward',
|
|
||||||
'browser_navigate',
|
'browser_navigate',
|
||||||
'browser_network_requests',
|
'browser_network_requests',
|
||||||
'browser_pdf_save',
|
|
||||||
'browser_press_key',
|
'browser_press_key',
|
||||||
'browser_resize',
|
'browser_resize',
|
||||||
'browser_screen_capture',
|
'browser_snapshot',
|
||||||
'browser_screen_click',
|
'browser_tabs',
|
||||||
'browser_screen_drag',
|
'browser_take_screenshot',
|
||||||
'browser_screen_move_mouse',
|
|
||||||
'browser_screen_type',
|
|
||||||
'browser_tab_close',
|
|
||||||
'browser_tab_list',
|
|
||||||
'browser_tab_new',
|
|
||||||
'browser_tab_select',
|
|
||||||
'browser_wait_for',
|
'browser_wait_for',
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test capabilities', async ({ startClient }) => {
|
test('test capabilities (pdf)', async ({ startClient }) => {
|
||||||
const client = await startClient({
|
const { client } = await startClient({
|
||||||
args: ['--caps="core"'],
|
args: ['--caps=pdf'],
|
||||||
});
|
});
|
||||||
const { tools } = await client.listTools();
|
const { tools } = await client.listTools();
|
||||||
const toolNames = tools.map(t => t.name);
|
const toolNames = tools.map(t => t.name);
|
||||||
expect(toolNames).not.toContain('browser_file_upload');
|
expect(toolNames).toContain('browser_pdf_save');
|
||||||
expect(toolNames).not.toContain('browser_pdf_save');
|
});
|
||||||
expect(toolNames).not.toContain('browser_screen_capture');
|
|
||||||
expect(toolNames).not.toContain('browser_screen_click');
|
test('test capabilities (vision)', async ({ startClient }) => {
|
||||||
expect(toolNames).not.toContain('browser_screen_drag');
|
const { client } = await startClient({
|
||||||
expect(toolNames).not.toContain('browser_screen_move_mouse');
|
args: ['--caps=vision'],
|
||||||
expect(toolNames).not.toContain('browser_screen_type');
|
});
|
||||||
|
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,73 +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.js';
|
|
||||||
|
|
||||||
test('cdp server', async ({ cdpEndpoint, startClient, server }) => {
|
|
||||||
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.HELLO_WORLD },
|
|
||||||
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
|
|
||||||
const client = await startClient({ args: [`--cdp-endpoint=${await 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',
|
|
||||||
})).toHaveTextContent(`
|
|
||||||
- Ran Playwright code:
|
|
||||||
\`\`\`js
|
|
||||||
// <internal code to capture accessibility snapshot>
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
- Page URL: data:text/html,hello world
|
|
||||||
- Page Title:
|
|
||||||
- Page Snapshot
|
|
||||||
\`\`\`yaml
|
|
||||||
- generic [ref=e1]: hello world
|
|
||||||
\`\`\`
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should throw connection error and allow re-connecting', async ({ cdpEndpoint, startClient, server }) => {
|
|
||||||
const port = 3200 + test.info().parallelIndex;
|
|
||||||
const client = await startClient({ args: [`--cdp-endpoint=http://localhost:${port}`] });
|
|
||||||
|
|
||||||
server.setContent('/', `
|
|
||||||
<title>Title</title>
|
|
||||||
<body>Hello, world!</body>
|
|
||||||
`, 'text/html');
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.PREFIX },
|
|
||||||
})).toContainTextContent(`Error: browserType.connectOverCDP: connect ECONNREFUSED`);
|
|
||||||
await cdpEndpoint(port);
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.PREFIX },
|
|
||||||
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
|
||||||
});
|
|
||||||
@@ -14,31 +14,30 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'node:fs';
|
import { test, expect } from './fixtures';
|
||||||
|
|
||||||
import { Config } from '../config.js';
|
test('browser_click', async ({ client, server, mcpBrowser }) => {
|
||||||
import { test, expect } from './fixtures.js';
|
|
||||||
|
|
||||||
test('config user data dir', async ({ startClient, localOutputPath, server }) => {
|
|
||||||
server.setContent('/', `
|
server.setContent('/', `
|
||||||
<title>Title</title>
|
<title>Title</title>
|
||||||
<body>Hello, world!</body>
|
<button>Submit</button>
|
||||||
`, 'text/html');
|
`, 'text/html');
|
||||||
|
|
||||||
const config: Config = {
|
|
||||||
browser: {
|
|
||||||
userDataDir: localOutputPath('user-data-dir'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const configPath = localOutputPath('config.json');
|
|
||||||
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
|
|
||||||
|
|
||||||
const client = await startClient({ args: ['--config', configPath] });
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
})).toContainTextContent(`Hello, world!`);
|
})).toHaveResponse({
|
||||||
|
code: `await page.goto('${server.PREFIX}');`,
|
||||||
|
pageState: expect.stringContaining(`- button \"Submit\" [ref=e2]`),
|
||||||
|
});
|
||||||
|
|
||||||
const files = await fs.promises.readdir(config.browser!.userDataDir!);
|
expect(await client.callTool({
|
||||||
expect(files.length).toBeGreaterThan(0);
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Submit button',
|
||||||
|
ref: 'e2',
|
||||||
|
},
|
||||||
|
})).toHaveResponse({
|
||||||
|
code: `await page.getByRole('button', { name: 'Submit' }).click();`,
|
||||||
|
pageState: expect.stringContaining(`- button "Submit" ${mcpBrowser !== 'webkit' || process.platform === 'linux' ? '[active] ' : ''}[ref=e2]`),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test, expect } from './fixtures.js';
|
|
||||||
|
|
||||||
test('browser_console_messages', async ({ client, server }) => {
|
|
||||||
server.setContent('/', `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<script>
|
|
||||||
console.log("Hello, world!");
|
|
||||||
console.error("Error");
|
|
||||||
</script>
|
|
||||||
</html>
|
|
||||||
`, 'text/html');
|
|
||||||
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: {
|
|
||||||
url: server.PREFIX,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const resource = await client.callTool({
|
|
||||||
name: 'browser_console_messages',
|
|
||||||
});
|
|
||||||
expect(resource).toHaveTextContent([
|
|
||||||
'[LOG] Hello, world!',
|
|
||||||
'[ERROR] Error',
|
|
||||||
].join('\n'));
|
|
||||||
});
|
|
||||||
@@ -14,226 +14,19 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures';
|
||||||
|
|
||||||
test('browser_navigate', async ({ client, server }) => {
|
test('browser_navigate', async ({ client, server }) => {
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
})).toHaveTextContent(`
|
})).toHaveResponse({
|
||||||
- Ran Playwright code:
|
code: `await page.goto('${server.HELLO_WORLD}');`,
|
||||||
\`\`\`js
|
pageState: `- Page URL: ${server.HELLO_WORLD}
|
||||||
// Navigate to ${server.HELLO_WORLD}
|
|
||||||
await page.goto('${server.HELLO_WORLD}');
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
- Page URL: ${server.HELLO_WORLD}
|
|
||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
- Page Snapshot
|
- Page Snapshot:
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [ref=e1]: Hello, world!
|
- generic [active] [ref=e1]: Hello, world!
|
||||||
\`\`\`
|
\`\`\``,
|
||||||
`
|
});
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('browser_click', async ({ client, server }) => {
|
|
||||||
server.setContent('/', `
|
|
||||||
<title>Title</title>
|
|
||||||
<button>Submit</button>
|
|
||||||
`, 'text/html');
|
|
||||||
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.PREFIX },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_click',
|
|
||||||
arguments: {
|
|
||||||
element: 'Submit button',
|
|
||||||
ref: 'e2',
|
|
||||||
},
|
|
||||||
})).toHaveTextContent(`
|
|
||||||
- Ran Playwright code:
|
|
||||||
\`\`\`js
|
|
||||||
// Click Submit button
|
|
||||||
await page.getByRole('button', { name: 'Submit' }).click();
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
- Page URL: ${server.PREFIX}
|
|
||||||
- Page Title: Title
|
|
||||||
- Page Snapshot
|
|
||||||
\`\`\`yaml
|
|
||||||
- button "Submit" [ref=e2]
|
|
||||||
\`\`\`
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('browser_select_option', async ({ client, server }) => {
|
|
||||||
server.setContent('/', `
|
|
||||||
<title>Title</title>
|
|
||||||
<select>
|
|
||||||
<option value="foo">Foo</option>
|
|
||||||
<option value="bar">Bar</option>
|
|
||||||
</select>
|
|
||||||
`, 'text/html');
|
|
||||||
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.PREFIX },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_select_option',
|
|
||||||
arguments: {
|
|
||||||
element: 'Select',
|
|
||||||
ref: 'e2',
|
|
||||||
values: ['bar'],
|
|
||||||
},
|
|
||||||
})).toHaveTextContent(`
|
|
||||||
- Ran Playwright code:
|
|
||||||
\`\`\`js
|
|
||||||
// Select options [bar] in Select
|
|
||||||
await page.getByRole('combobox').selectOption(['bar']);
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
- Page URL: ${server.PREFIX}
|
|
||||||
- Page Title: Title
|
|
||||||
- Page Snapshot
|
|
||||||
\`\`\`yaml
|
|
||||||
- combobox [ref=e2]:
|
|
||||||
- option "Foo"
|
|
||||||
- option "Bar" [selected]
|
|
||||||
\`\`\`
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('browser_select_option (multiple)', async ({ client, server }) => {
|
|
||||||
server.setContent('/', `
|
|
||||||
<title>Title</title>
|
|
||||||
<select multiple>
|
|
||||||
<option value="foo">Foo</option>
|
|
||||||
<option value="bar">Bar</option>
|
|
||||||
<option value="baz">Baz</option>
|
|
||||||
</select>
|
|
||||||
`, 'text/html');
|
|
||||||
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.PREFIX },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_select_option',
|
|
||||||
arguments: {
|
|
||||||
element: 'Select',
|
|
||||||
ref: 'e2',
|
|
||||||
values: ['bar', 'baz'],
|
|
||||||
},
|
|
||||||
})).toHaveTextContent(`
|
|
||||||
- Ran Playwright code:
|
|
||||||
\`\`\`js
|
|
||||||
// Select options [bar, baz] in Select
|
|
||||||
await page.getByRole('listbox').selectOption(['bar', 'baz']);
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
- Page URL: ${server.PREFIX}
|
|
||||||
- Page Title: Title
|
|
||||||
- Page Snapshot
|
|
||||||
\`\`\`yaml
|
|
||||||
- listbox [ref=e2]:
|
|
||||||
- option "Foo" [ref=e3]
|
|
||||||
- option "Bar" [selected] [ref=e4]
|
|
||||||
- option "Baz" [selected] [ref=e5]
|
|
||||||
\`\`\`
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('browser_type', async ({ client, server }) => {
|
|
||||||
server.setContent('/', `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<input type='keypress' onkeypress="console.log('Key pressed:', event.key, ', Text:', event.target.value)"></input>
|
|
||||||
</html>
|
|
||||||
`, 'text/html');
|
|
||||||
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: {
|
|
||||||
url: server.PREFIX,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_type',
|
|
||||||
arguments: {
|
|
||||||
element: 'textbox',
|
|
||||||
ref: 'e2',
|
|
||||||
text: 'Hi!',
|
|
||||||
submit: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_console_messages',
|
|
||||||
})).toHaveTextContent('[LOG] Key pressed: Enter , Text: Hi!');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('browser_type (slowly)', async ({ client, server }) => {
|
|
||||||
server.setContent('/', `
|
|
||||||
<input type='text' onkeydown="console.log('Key pressed:', event.key, 'Text:', event.target.value)"></input>
|
|
||||||
`, 'text/html');
|
|
||||||
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: {
|
|
||||||
url: server.PREFIX,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_type',
|
|
||||||
arguments: {
|
|
||||||
element: 'textbox',
|
|
||||||
ref: 'e2',
|
|
||||||
text: 'Hi!',
|
|
||||||
submit: true,
|
|
||||||
slowly: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_console_messages',
|
|
||||||
})).toHaveTextContent([
|
|
||||||
'[LOG] Key pressed: H Text: ',
|
|
||||||
'[LOG] Key pressed: i Text: H',
|
|
||||||
'[LOG] Key pressed: ! Text: Hi',
|
|
||||||
'[LOG] Key pressed: Enter Text: Hi!',
|
|
||||||
].join('\n'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('browser_resize', async ({ client, server }) => {
|
|
||||||
server.setContent('/', `
|
|
||||||
<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>
|
|
||||||
`, 'text/html');
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.PREFIX },
|
|
||||||
});
|
|
||||||
|
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,43 +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.js';
|
|
||||||
|
|
||||||
test('--device should work', async ({ startClient, server }) => {
|
|
||||||
const client = await startClient({
|
|
||||||
args: ['--device', 'iPhone 15'],
|
|
||||||
});
|
|
||||||
|
|
||||||
server.route('/', (req, res) => {
|
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
||||||
res.end(`
|
|
||||||
<head>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
</head>
|
|
||||||
<body></body>
|
|
||||||
<script>
|
|
||||||
document.body.textContent = window.innerWidth + "x" + window.innerHeight;
|
|
||||||
</script>
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: {
|
|
||||||
url: server.PREFIX,
|
|
||||||
},
|
|
||||||
})).toContainTextContent(`393x659`);
|
|
||||||
});
|
|
||||||
@@ -1,212 +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.js';
|
|
||||||
|
|
||||||
// https://github.com/microsoft/playwright/issues/35663
|
|
||||||
test.skip(({ mcpBrowser, mcpHeadless }) => mcpBrowser === 'webkit' && mcpHeadless);
|
|
||||||
|
|
||||||
test('alert dialog', async ({ client, server }) => {
|
|
||||||
server.setContent('/', `<button onclick="alert('Alert')">Button</button>`, 'text/html');
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.PREFIX },
|
|
||||||
})).toContainTextContent('- button "Button" [ref=e2]');
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_click',
|
|
||||||
arguments: {
|
|
||||||
element: 'Button',
|
|
||||||
ref: 'e2',
|
|
||||||
},
|
|
||||||
})).toHaveTextContent(`- Ran Playwright code:
|
|
||||||
\`\`\`js
|
|
||||||
// Click Button
|
|
||||||
await page.getByRole('button', { name: 'Button' }).click();
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Modal state
|
|
||||||
- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`);
|
|
||||||
|
|
||||||
const result = await client.callTool({
|
|
||||||
name: 'browser_handle_dialog',
|
|
||||||
arguments: {
|
|
||||||
accept: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).not.toContainTextContent('### Modal state');
|
|
||||||
expect(result).toHaveTextContent(`- Ran Playwright code:
|
|
||||||
\`\`\`js
|
|
||||||
// <internal code to handle "alert" dialog>
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
- Page URL: ${server.PREFIX}
|
|
||||||
- Page Title:
|
|
||||||
- Page Snapshot
|
|
||||||
\`\`\`yaml
|
|
||||||
- button "Button" [ref=e2]
|
|
||||||
\`\`\`
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('two alert dialogs', async ({ client, server }) => {
|
|
||||||
test.fixme(true, 'Race between the dialog and ariaSnapshot');
|
|
||||||
|
|
||||||
server.setContent('/', `
|
|
||||||
<title>Title</title>
|
|
||||||
<body>
|
|
||||||
<button onclick="alert('Alert 1');alert('Alert 2');">Button</button>
|
|
||||||
</body>
|
|
||||||
`, 'text/html');
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.PREFIX },
|
|
||||||
})).toContainTextContent('- button "Button" [ref=e2]');
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_click',
|
|
||||||
arguments: {
|
|
||||||
element: 'Button',
|
|
||||||
ref: 'e2',
|
|
||||||
},
|
|
||||||
})).toHaveTextContent(`- Ran Playwright code:
|
|
||||||
\`\`\`js
|
|
||||||
// Click Button
|
|
||||||
await page.getByRole('button', { name: 'Button' }).click();
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Modal state
|
|
||||||
- ["alert" dialog with message "Alert 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, server }) => {
|
|
||||||
server.setContent('/', `
|
|
||||||
<title>Title</title>
|
|
||||||
<body>
|
|
||||||
<button onclick="document.body.textContent = confirm('Confirm')">Button</button>
|
|
||||||
</body>
|
|
||||||
`, 'text/html');
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.PREFIX },
|
|
||||||
})).toContainTextContent('- button "Button" [ref=e2]');
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_click',
|
|
||||||
arguments: {
|
|
||||||
element: 'Button',
|
|
||||||
ref: 'e2',
|
|
||||||
},
|
|
||||||
})).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
|
|
||||||
- generic [ref=e1]: "true"
|
|
||||||
\`\`\``);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('confirm dialog (false)', async ({ client, server }) => {
|
|
||||||
server.setContent('/', `
|
|
||||||
<title>Title</title>
|
|
||||||
<body>
|
|
||||||
<button onclick="document.body.textContent = confirm('Confirm')">Button</button>
|
|
||||||
</body>
|
|
||||||
`, 'text/html');
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.PREFIX },
|
|
||||||
})).toContainTextContent('- button "Button" [ref=e2]');
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_click',
|
|
||||||
arguments: {
|
|
||||||
element: 'Button',
|
|
||||||
ref: 'e2',
|
|
||||||
},
|
|
||||||
})).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
|
|
||||||
- generic [ref=e1]: "false"
|
|
||||||
\`\`\``);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('prompt dialog', async ({ client, server }) => {
|
|
||||||
server.setContent('/', `
|
|
||||||
<title>Title</title>
|
|
||||||
<body>
|
|
||||||
<button onclick="document.body.textContent = prompt('Prompt')">Button</button>
|
|
||||||
</body>
|
|
||||||
`, 'text/html');
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.PREFIX },
|
|
||||||
})).toContainTextContent('- button "Button" [ref=e2]');
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_click',
|
|
||||||
arguments: {
|
|
||||||
element: 'Button',
|
|
||||||
ref: 'e2',
|
|
||||||
},
|
|
||||||
})).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
|
|
||||||
- generic [ref=e1]: Answer
|
|
||||||
\`\`\``);
|
|
||||||
});
|
|
||||||
@@ -1,145 +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.js';
|
|
||||||
import fs from 'fs/promises';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
test('browser_file_upload', async ({ client, localOutputPath, server }) => {
|
|
||||||
server.setContent('/', `
|
|
||||||
<input type="file" />
|
|
||||||
<button>Button</button>
|
|
||||||
`, 'text/html');
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.PREFIX },
|
|
||||||
})).toContainTextContent(`
|
|
||||||
\`\`\`yaml
|
|
||||||
- generic [ref=e1]:
|
|
||||||
- button "Choose File" [ref=e2]
|
|
||||||
- button "Button" [ref=e3]
|
|
||||||
\`\`\``);
|
|
||||||
|
|
||||||
{
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_file_upload',
|
|
||||||
arguments: { paths: [] },
|
|
||||||
})).toHaveTextContent(`
|
|
||||||
The tool "browser_file_upload" can only be used when there is related modal state present.
|
|
||||||
### Modal state
|
|
||||||
- There is no modal state present
|
|
||||||
`.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_click',
|
|
||||||
arguments: {
|
|
||||||
element: 'Textbox',
|
|
||||||
ref: 'e2',
|
|
||||||
},
|
|
||||||
})).toContainTextContent(`### Modal state
|
|
||||||
- [File chooser]: can be handled by the "browser_file_upload" tool`);
|
|
||||||
|
|
||||||
const filePath = localOutputPath('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(`
|
|
||||||
\`\`\`yaml
|
|
||||||
- generic [ref=e1]:
|
|
||||||
- button "Choose File" [ref=e2]
|
|
||||||
- button "Button" [ref=e3]
|
|
||||||
\`\`\``);
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
const response = await client.callTool({
|
|
||||||
name: 'browser_click',
|
|
||||||
arguments: {
|
|
||||||
element: 'Textbox',
|
|
||||||
ref: 'e2',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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: 'e3',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clicking on download link emits download', async ({ startClient, localOutputPath, server }) => {
|
|
||||||
const outputDir = localOutputPath('output');
|
|
||||||
const client = await startClient({
|
|
||||||
config: { outputDir },
|
|
||||||
});
|
|
||||||
|
|
||||||
server.setContent('/', `<a href="/download" download="test.txt">Download</a>`, 'text/html');
|
|
||||||
server.setContent('/download', 'Data', 'text/plain');
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.PREFIX },
|
|
||||||
})).toContainTextContent('- link "Download" [ref=e2]');
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_click',
|
|
||||||
arguments: {
|
|
||||||
element: 'Download link',
|
|
||||||
ref: 'e2',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(`
|
|
||||||
### Downloads
|
|
||||||
- Downloaded file test.txt to ${path.join(outputDir, 'test.txt')}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('navigating to download link emits download', async ({ client, server, mcpBrowser }) => {
|
|
||||||
test.skip(mcpBrowser === 'webkit' && process.platform === 'linux', 'https://github.com/microsoft/playwright/blob/8e08fdb52c27bb75de9bf87627bf740fadab2122/tests/library/download.spec.ts#L436');
|
|
||||||
server.route('/download', (req, res) => {
|
|
||||||
res.writeHead(200, {
|
|
||||||
'Content-Type': 'text/plain',
|
|
||||||
'Content-Disposition': 'attachment; filename=test.txt',
|
|
||||||
});
|
|
||||||
res.end('Hello world!');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: {
|
|
||||||
url: server.PREFIX + 'download',
|
|
||||||
},
|
|
||||||
})).toContainTextContent('### Downloads');
|
|
||||||
});
|
|
||||||
@@ -15,33 +15,48 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import url from 'url';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
import { test as baseTest, expect as baseExpect } from '@playwright/test';
|
import { test as baseTest, expect as baseExpect } from '@playwright/test';
|
||||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
import { ChildProcessWithoutNullStreams, spawn } from 'child_process';
|
import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { TestServer } from './testserver/index.ts';
|
import { TestServer } from './testserver/index';
|
||||||
|
|
||||||
import type { Config } from '../config';
|
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 = {
|
export type TestOptions = {
|
||||||
|
mcpArgs: string[] | undefined;
|
||||||
mcpBrowser: string | undefined;
|
mcpBrowser: string | undefined;
|
||||||
mcpMode: 'docker' | 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 = {
|
type TestFixtures = {
|
||||||
client: Client;
|
client: Client;
|
||||||
visionClient: Client;
|
startClient: StartClient;
|
||||||
startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<Client>;
|
|
||||||
wsEndpoint: string;
|
wsEndpoint: string;
|
||||||
cdpEndpoint: (port?: number) => Promise<string>;
|
cdpServer: CDPServer;
|
||||||
server: TestServer;
|
server: TestServer;
|
||||||
httpsServer: TestServer;
|
httpsServer: TestServer;
|
||||||
mcpHeadless: boolean;
|
mcpHeadless: boolean;
|
||||||
localOutputPath: (filePath: string) => string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type WorkerFixtures = {
|
type WorkerFixtures = {
|
||||||
@@ -50,21 +65,19 @@ type WorkerFixtures = {
|
|||||||
|
|
||||||
export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>({
|
export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>({
|
||||||
|
|
||||||
|
mcpArgs: [undefined, { option: true }],
|
||||||
|
|
||||||
client: async ({ startClient }, use) => {
|
client: async ({ startClient }, use) => {
|
||||||
await use(await startClient());
|
const { client } = await startClient();
|
||||||
|
await use(client);
|
||||||
},
|
},
|
||||||
|
|
||||||
visionClient: async ({ startClient }, use) => {
|
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode, mcpArgs }, use, testInfo) => {
|
||||||
await use(await startClient({ args: ['--vision'] }));
|
|
||||||
},
|
|
||||||
|
|
||||||
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
|
|
||||||
const userDataDir = testInfo.outputPath('user-data-dir');
|
|
||||||
const configDir = path.dirname(test.info().config.configFile!);
|
const configDir = path.dirname(test.info().config.configFile!);
|
||||||
let client: Client | undefined;
|
const clients: Client[] = [];
|
||||||
|
|
||||||
await use(async options => {
|
await use(async options => {
|
||||||
const args = ['--user-data-dir', path.relative(configDir, userDataDir)];
|
const args: string[] = mcpArgs ?? [];
|
||||||
if (process.env.CI && process.platform === 'linux')
|
if (process.env.CI && process.platform === 'linux')
|
||||||
args.push('--no-sandbox');
|
args.push('--no-sandbox');
|
||||||
if (mcpHeadless)
|
if (mcpHeadless)
|
||||||
@@ -79,14 +92,30 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
args.push(`--config=${path.relative(configDir, configFile)}`);
|
args.push(`--config=${path.relative(configDir, configFile)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
|
const client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }, options?.roots ? { capabilities: { roots: {} } } : undefined);
|
||||||
const transport = createTransport(args, mcpMode);
|
if (options?.roots) {
|
||||||
|
client.setRequestHandler(ListRootsRequestSchema, async request => {
|
||||||
|
if (options.rootsResponseDelay)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, options.rootsResponseDelay));
|
||||||
|
return {
|
||||||
|
roots: options.roots,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const { transport, stderr } = await createTransport(args, mcpMode, testInfo.outputPath('ms-playwright'));
|
||||||
|
let stderrBuffer = '';
|
||||||
|
stderr?.on('data', data => {
|
||||||
|
if (process.env.PWMCP_DEBUG)
|
||||||
|
process.stderr.write(data);
|
||||||
|
stderrBuffer += data.toString();
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
await client.connect(transport);
|
await client.connect(transport);
|
||||||
await client.ping();
|
await client.ping();
|
||||||
return client;
|
return { client, stderr: () => stderrBuffer };
|
||||||
});
|
});
|
||||||
|
|
||||||
await client?.close();
|
await Promise.all(clients.map(client => client.close()));
|
||||||
},
|
},
|
||||||
|
|
||||||
wsEndpoint: async ({ }, use) => {
|
wsEndpoint: async ({ }, use) => {
|
||||||
@@ -95,39 +124,27 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
await browserServer.close();
|
await browserServer.close();
|
||||||
},
|
},
|
||||||
|
|
||||||
cdpEndpoint: async ({ }, use, testInfo) => {
|
cdpServer: async ({ mcpBrowser }, use, testInfo) => {
|
||||||
let browserProcess: ChildProcessWithoutNullStreams | undefined;
|
test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser!), 'CDP is not supported for non-Chromium browsers');
|
||||||
|
|
||||||
await use(async port => {
|
let browserContext: BrowserContext | undefined;
|
||||||
if (!port)
|
const port = 3200 + test.info().parallelIndex;
|
||||||
port = 3200 + test.info().parallelIndex;
|
await use({
|
||||||
if (browserProcess)
|
endpoint: `http://localhost:${port}`,
|
||||||
return `http://localhost:${port}`;
|
start: async () => {
|
||||||
browserProcess = spawn(chromium.executablePath(), [
|
if (browserContext)
|
||||||
`--user-data-dir=${testInfo.outputPath('user-data-dir')}`,
|
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}`,
|
`--remote-debugging-port=${port}`,
|
||||||
`--no-first-run`,
|
],
|
||||||
`--no-sandbox`,
|
|
||||||
`--headless`,
|
|
||||||
'--use-mock-keychain',
|
|
||||||
`data:text/html,hello world`,
|
|
||||||
], {
|
|
||||||
stdio: 'pipe',
|
|
||||||
});
|
});
|
||||||
await new Promise<void>(resolve => {
|
return browserContext;
|
||||||
browserProcess!.stderr.on('data', data => {
|
}
|
||||||
if (data.toString().includes('DevTools listening on '))
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return `http://localhost:${port}`;
|
|
||||||
});
|
|
||||||
await new Promise<void>(resolve => {
|
|
||||||
if (!browserProcess)
|
|
||||||
return resolve();
|
|
||||||
browserProcess.on('exit', () => resolve());
|
|
||||||
browserProcess.kill();
|
|
||||||
});
|
});
|
||||||
|
await browserContext?.close();
|
||||||
},
|
},
|
||||||
|
|
||||||
mcpHeadless: async ({ headless }, use) => {
|
mcpHeadless: async ({ headless }, use) => {
|
||||||
@@ -138,14 +155,7 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
|
|
||||||
mcpMode: [undefined, { option: true }],
|
mcpMode: [undefined, { option: true }],
|
||||||
|
|
||||||
localOutputPath: async ({ mcpMode }, use, testInfo) => {
|
_workerServers: [async ({ }, use, workerInfo) => {
|
||||||
await use(filePath => {
|
|
||||||
test.skip(mcpMode === 'docker', 'Mounting files is not supported in docker mode');
|
|
||||||
return testInfo.outputPath(filePath);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
_workerServers: [async ({}, use, workerInfo) => {
|
|
||||||
const port = 8907 + workerInfo.workerIndex * 4;
|
const port = 8907 + workerInfo.workerIndex * 4;
|
||||||
const server = await TestServer.create(port);
|
const server = await TestServer.create(port);
|
||||||
|
|
||||||
@@ -171,65 +181,52 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function createTransport(args: string[], mcpMode: TestOptions['mcpMode']) {
|
async function createTransport(args: string[], mcpMode: TestOptions['mcpMode'], profilesDir: string): Promise<{
|
||||||
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
transport: Transport,
|
||||||
const __filename = url.fileURLToPath(import.meta.url);
|
stderr: Stream | null,
|
||||||
|
}> {
|
||||||
if (mcpMode === 'docker') {
|
if (mcpMode === 'docker') {
|
||||||
const dockerArgs = ['run', '--rm', '-i', '--network=host', '-v', `${test.info().project.outputDir}:/app/test-results`];
|
const dockerArgs = ['run', '--rm', '-i', '--network=host', '-v', `${test.info().project.outputDir}:/app/test-results`];
|
||||||
return new StdioClientTransport({
|
const transport = new StdioClientTransport({
|
||||||
command: 'docker',
|
command: 'docker',
|
||||||
args: [...dockerArgs, 'playwright-mcp-dev:latest', ...args],
|
args: [...dockerArgs, 'playwright-mcp-dev:latest', ...args],
|
||||||
});
|
});
|
||||||
|
return {
|
||||||
|
transport,
|
||||||
|
stderr: transport.stderr,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return new StdioClientTransport({
|
|
||||||
|
const transport = new StdioClientTransport({
|
||||||
command: 'node',
|
command: 'node',
|
||||||
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
|
args: [path.join(__dirname, '../cli.js'), ...args],
|
||||||
cwd: path.join(path.dirname(__filename), '..'),
|
cwd: path.dirname(test.info().config.configFile!),
|
||||||
env: process.env as Record<string, string>,
|
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']>>;
|
type Response = Awaited<ReturnType<Client['callTool']>>;
|
||||||
|
|
||||||
export const expect = baseExpect.extend({
|
export const expect = baseExpect.extend({
|
||||||
toHaveTextContent(response: Response, content: string | RegExp) {
|
toHaveResponse(response: Response, object: any) {
|
||||||
|
const parsed = parseResponse(response);
|
||||||
const isNot = this.isNot;
|
const isNot = this.isNot;
|
||||||
try {
|
try {
|
||||||
const text = (response.content as any)[0].text;
|
|
||||||
if (typeof content === 'string') {
|
|
||||||
if (isNot)
|
if (isNot)
|
||||||
baseExpect(text.trim()).not.toBe(content.trim());
|
expect(parsed).not.toEqual(expect.objectContaining(object));
|
||||||
else
|
else
|
||||||
baseExpect(text.trim()).toBe(content.trim());
|
expect(parsed).toEqual(expect.objectContaining(object));
|
||||||
} 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]);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {
|
return {
|
||||||
pass: isNot,
|
pass: isNot,
|
||||||
@@ -242,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,50 +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.js';
|
|
||||||
|
|
||||||
for (const mcpHeadless of [false, true]) {
|
|
||||||
test.describe(`mcpHeadless: ${mcpHeadless}`, () => {
|
|
||||||
test.use({ mcpHeadless });
|
|
||||||
test.skip(process.platform === 'linux', 'Auto-detection wont let this test run on linux');
|
|
||||||
test.skip(({ mcpMode, mcpHeadless }) => mcpMode === 'docker' && !mcpHeadless, 'Headed mode is not supported in docker');
|
|
||||||
test('browser', async ({ client, server, mcpBrowser }) => {
|
|
||||||
test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser ?? ''), 'Only chrome is supported for this test');
|
|
||||||
server.route('/', (req, res) => {
|
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
||||||
res.end(`
|
|
||||||
<body></body>
|
|
||||||
<script>
|
|
||||||
document.body.textContent = navigator.userAgent;
|
|
||||||
</script>
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: {
|
|
||||||
url: server.PREFIX,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response).toContainTextContent(`Mozilla/5.0`);
|
|
||||||
if (mcpHeadless)
|
|
||||||
expect(response).toContainTextContent(`HeadlessChrome`);
|
|
||||||
else
|
|
||||||
expect(response).not.toContainTextContent(`HeadlessChrome`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test, expect } from './fixtures.js';
|
|
||||||
|
|
||||||
test('stitched aria frames', async ({ client }) => {
|
|
||||||
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
|
|
||||||
- generic [ref=e1]:
|
|
||||||
- heading "Hello" [level=1] [ref=e2]
|
|
||||||
- iframe [ref=e3]:
|
|
||||||
- generic [ref=f1e1]:
|
|
||||||
- button "World" [ref=f1e2]
|
|
||||||
- main [ref=f1e3]:
|
|
||||||
- iframe [ref=f1e4]:
|
|
||||||
- paragraph [ref=f2e2]: Nested
|
|
||||||
\`\`\``);
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_click',
|
|
||||||
arguments: {
|
|
||||||
element: 'World',
|
|
||||||
ref: 'f1e2',
|
|
||||||
},
|
|
||||||
})).toContainTextContent(`// Click World`);
|
|
||||||
});
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import fs from 'fs';
|
|
||||||
|
|
||||||
import { test, expect } from './fixtures.js';
|
|
||||||
|
|
||||||
test('test reopen browser', async ({ client, server }) => {
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.HELLO_WORLD },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_close',
|
|
||||||
})).toContainTextContent('No open pages available');
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.HELLO_WORLD },
|
|
||||||
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('executable path', async ({ startClient, server }) => {
|
|
||||||
const client = await startClient({ args: [`--executable-path=bogus`] });
|
|
||||||
const response = await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.HELLO_WORLD },
|
|
||||||
});
|
|
||||||
expect(response).toContainTextContent(`executable doesn't exist`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('persistent context', async ({ startClient, server }) => {
|
|
||||||
server.setContent('/', `
|
|
||||||
<body>
|
|
||||||
</body>
|
|
||||||
<script>
|
|
||||||
document.body.textContent = localStorage.getItem('test') ? 'Storage: YES' : 'Storage: NO';
|
|
||||||
localStorage.setItem('test', 'test');
|
|
||||||
</script>
|
|
||||||
`, 'text/html');
|
|
||||||
|
|
||||||
const client = await startClient();
|
|
||||||
const response = await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.PREFIX },
|
|
||||||
});
|
|
||||||
expect(response).toContainTextContent(`Storage: NO`);
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
||||||
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_close',
|
|
||||||
});
|
|
||||||
|
|
||||||
const client2 = await startClient();
|
|
||||||
const response2 = await client2.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.PREFIX },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response2).toContainTextContent(`Storage: YES`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('isolated context', async ({ startClient, server }) => {
|
|
||||||
server.setContent('/', `
|
|
||||||
<body>
|
|
||||||
</body>
|
|
||||||
<script>
|
|
||||||
document.body.textContent = localStorage.getItem('test') ? 'Storage: YES' : 'Storage: NO';
|
|
||||||
localStorage.setItem('test', 'test');
|
|
||||||
</script>
|
|
||||||
`, 'text/html');
|
|
||||||
|
|
||||||
const client = await startClient({ args: [`--isolated`] });
|
|
||||||
const response = await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.PREFIX },
|
|
||||||
});
|
|
||||||
expect(response).toContainTextContent(`Storage: NO`);
|
|
||||||
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_close',
|
|
||||||
});
|
|
||||||
|
|
||||||
const client2 = await startClient({ args: [`--isolated`] });
|
|
||||||
const response2 = await client2.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.PREFIX },
|
|
||||||
});
|
|
||||||
expect(response2).toContainTextContent(`Storage: NO`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('isolated context with storage state', async ({ startClient, server, localOutputPath }) => {
|
|
||||||
const storageStatePath = localOutputPath('storage-state.json');
|
|
||||||
await fs.promises.writeFile(storageStatePath, JSON.stringify({
|
|
||||||
origins: [
|
|
||||||
{
|
|
||||||
origin: server.PREFIX,
|
|
||||||
localStorage: [{ name: 'test', value: 'session-value' }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
|
|
||||||
server.setContent('/', `
|
|
||||||
<body>
|
|
||||||
</body>
|
|
||||||
<script>
|
|
||||||
document.body.textContent = 'Storage: ' + localStorage.getItem('test');
|
|
||||||
</script>
|
|
||||||
`, 'text/html');
|
|
||||||
|
|
||||||
const client = await startClient({ args: [
|
|
||||||
`--isolated`,
|
|
||||||
`--storage-state=${storageStatePath}`,
|
|
||||||
] });
|
|
||||||
const response = await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.PREFIX },
|
|
||||||
});
|
|
||||||
expect(response).toContainTextContent(`Storage: session-value`);
|
|
||||||
});
|
|
||||||
@@ -13,24 +13,16 @@
|
|||||||
* See the License for the specific language governing permissions and
|
* See the License for the specific language governing permissions and
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
import child_process from 'child_process';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import { test, expect } from './fixtures';
|
||||||
|
|
||||||
import type { Context } from '../context.js';
|
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');
|
||||||
export type ResourceSchema = {
|
await fs.writeFile(file, `
|
||||||
uri: string;
|
import('@playwright/mcp')
|
||||||
name: string;
|
.then(playwrightMCP => playwrightMCP.createConnection())
|
||||||
description?: string;
|
.then(() => console.log('OK'));
|
||||||
mimeType?: string;
|
`);
|
||||||
};
|
expect(child_process.execSync(`node ${file}`, { encoding: 'utf-8' })).toContain('OK');
|
||||||
|
});
|
||||||
export type ResourceResult = {
|
|
||||||
uri: string;
|
|
||||||
mimeType?: string;
|
|
||||||
text?: string;
|
|
||||||
blob?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Resource = {
|
|
||||||
schema: ResourceSchema;
|
|
||||||
read: (context: Context, uri: string) => Promise<ResourceResult[]>;
|
|
||||||
};
|
|
||||||
@@ -1,45 +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.js';
|
|
||||||
|
|
||||||
test('browser_network_requests', async ({ client, server }) => {
|
|
||||||
server.setContent('/', `
|
|
||||||
<button onclick="fetch('/json')">Click me</button>
|
|
||||||
`, 'text/html');
|
|
||||||
|
|
||||||
server.setContent('/json', JSON.stringify({ name: 'John Doe' }), 'application/json');
|
|
||||||
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: {
|
|
||||||
url: server.PREFIX,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_click',
|
|
||||||
arguments: {
|
|
||||||
element: 'Click me button',
|
|
||||||
ref: 'e2',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect.poll(() => client.callTool({
|
|
||||||
name: 'browser_network_requests',
|
|
||||||
})).toHaveTextContent(`[GET] ${`${server.PREFIX}`} => [200] OK
|
|
||||||
[GET] ${`${server.PREFIX}json`} => [200] OK`);
|
|
||||||
});
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import fs from 'fs';
|
|
||||||
|
|
||||||
import { test, expect } from './fixtures.js';
|
|
||||||
|
|
||||||
test('save as pdf unavailable', async ({ startClient, server }) => {
|
|
||||||
const client = await startClient({ args: ['--caps="no-pdf"'] });
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.HELLO_WORLD },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_pdf_save',
|
|
||||||
})).toHaveTextContent(/Tool \"browser_pdf_save\" not found/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('save as pdf', async ({ client, mcpBrowser, server }) => {
|
|
||||||
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.HELLO_WORLD },
|
|
||||||
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
|
||||||
|
|
||||||
const response = await client.callTool({
|
|
||||||
name: 'browser_pdf_save',
|
|
||||||
});
|
|
||||||
expect(response).toHaveTextContent(/Save page as.*page-[^:]+.pdf/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, server, localOutputPath }) => {
|
|
||||||
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
|
|
||||||
const outputDir = localOutputPath('output');
|
|
||||||
const client = await startClient({
|
|
||||||
config: { outputDir },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.HELLO_WORLD },
|
|
||||||
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_pdf_save',
|
|
||||||
arguments: {
|
|
||||||
filename: 'output.pdf',
|
|
||||||
},
|
|
||||||
})).toEqual({
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: expect.stringContaining(`output.pdf`),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const files = [...fs.readdirSync(outputDir)];
|
|
||||||
|
|
||||||
expect(fs.existsSync(outputDir)).toBeTruthy();
|
|
||||||
expect(files).toHaveLength(1);
|
|
||||||
expect(files[0]).toMatch(/^output.pdf$/);
|
|
||||||
});
|
|
||||||
@@ -1,82 +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 { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
||||||
import { test, expect } from './fixtures.ts';
|
|
||||||
|
|
||||||
const BLOCK_MESSAGE = /Blocked by Web Inspector|NS_ERROR_FAILURE|net::ERR_BLOCKED_BY_CLIENT/g;
|
|
||||||
|
|
||||||
const fetchPage = async (client: Client, url: string) => {
|
|
||||||
const result = await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: {
|
|
||||||
url,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return JSON.stringify(result, null, 2);
|
|
||||||
};
|
|
||||||
|
|
||||||
test('default to allow all', async ({ server, client }) => {
|
|
||||||
server.setContent('/ppp', 'content:PPP', 'text/html');
|
|
||||||
const result = await fetchPage(client, server.PREFIX + 'ppp');
|
|
||||||
expect(result).toContain('content:PPP');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('blocked works', async ({ startClient }) => {
|
|
||||||
const client = await startClient({
|
|
||||||
args: ['--blocked-origins', 'microsoft.com;example.com;playwright.dev']
|
|
||||||
});
|
|
||||||
const result = await fetchPage(client, 'https://example.com/');
|
|
||||||
expect(result).toMatch(BLOCK_MESSAGE);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('allowed works', async ({ server, startClient }) => {
|
|
||||||
server.setContent('/ppp', 'content:PPP', 'text/html');
|
|
||||||
const client = await startClient({
|
|
||||||
args: ['--allowed-origins', `microsoft.com;${new URL(server.PREFIX).host};playwright.dev`]
|
|
||||||
});
|
|
||||||
const result = await fetchPage(client, server.PREFIX + 'ppp');
|
|
||||||
expect(result).toContain('content:PPP');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('blocked takes precedence', async ({ startClient }) => {
|
|
||||||
const client = await startClient({
|
|
||||||
args: [
|
|
||||||
'--blocked-origins', 'example.com',
|
|
||||||
'--allowed-origins', 'example.com',
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const result = await fetchPage(client, 'https://example.com/');
|
|
||||||
expect(result).toMatch(BLOCK_MESSAGE);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('allowed without blocked blocks all non-explicitly specified origins', async ({ startClient }) => {
|
|
||||||
const client = await startClient({
|
|
||||||
args: ['--allowed-origins', 'playwright.dev'],
|
|
||||||
});
|
|
||||||
const result = await fetchPage(client, 'https://example.com/');
|
|
||||||
expect(result).toMatch(BLOCK_MESSAGE);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('blocked without allowed allows non-explicitly specified origins', async ({ server, startClient }) => {
|
|
||||||
server.setContent('/ppp', 'content:PPP', 'text/html');
|
|
||||||
const client = await startClient({
|
|
||||||
args: ['--blocked-origins', 'example.com'],
|
|
||||||
});
|
|
||||||
const result = await fetchPage(client, server.PREFIX + 'ppp');
|
|
||||||
expect(result).toContain('content:PPP');
|
|
||||||
});
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import fs from 'fs';
|
|
||||||
|
|
||||||
import { test, expect } from './fixtures.js';
|
|
||||||
|
|
||||||
test('browser_take_screenshot (viewport)', async ({ client, server }) => {
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.HELLO_WORLD },
|
|
||||||
})).toContainTextContent(`Navigate to http://localhost`);
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_take_screenshot',
|
|
||||||
})).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, server }) => {
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.HELLO_WORLD },
|
|
||||||
})).toContainTextContent(`[ref=e1]`);
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_take_screenshot',
|
|
||||||
arguments: {
|
|
||||||
element: 'hello button',
|
|
||||||
ref: 'e1',
|
|
||||||
},
|
|
||||||
})).toEqual({
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
data: expect.any(String),
|
|
||||||
mimeType: 'image/jpeg',
|
|
||||||
type: 'image',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: expect.stringContaining(`page.getByText('Hello, world!').screenshot`),
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('--output-dir should work', async ({ startClient, localOutputPath, server }) => {
|
|
||||||
const outputDir = localOutputPath('output');
|
|
||||||
const client = await startClient({
|
|
||||||
args: ['--output-dir', outputDir],
|
|
||||||
});
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.HELLO_WORLD },
|
|
||||||
})).toContainTextContent(`Navigate to http://localhost`);
|
|
||||||
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_take_screenshot',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fs.existsSync(outputDir)).toBeTruthy();
|
|
||||||
expect([...fs.readdirSync(outputDir)]).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const raw of [undefined, true]) {
|
|
||||||
test(`browser_take_screenshot (raw: ${raw})`, async ({ startClient, localOutputPath, server }) => {
|
|
||||||
const ext = raw ? 'png' : 'jpeg';
|
|
||||||
const outputDir = localOutputPath('output');
|
|
||||||
const client = await startClient({
|
|
||||||
config: { outputDir },
|
|
||||||
});
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.PREFIX },
|
|
||||||
})).toContainTextContent(`Navigate to http://localhost`);
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_take_screenshot',
|
|
||||||
arguments: { raw },
|
|
||||||
})).toEqual({
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
data: expect.any(String),
|
|
||||||
mimeType: `image/${ext}`,
|
|
||||||
type: 'image',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: expect.stringMatching(
|
|
||||||
new RegExp(`page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}\\-\\d{3}Z\\.${ext}`)
|
|
||||||
),
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const files = [...fs.readdirSync(outputDir)];
|
|
||||||
|
|
||||||
expect(fs.existsSync(outputDir)).toBeTruthy();
|
|
||||||
expect(files).toHaveLength(1);
|
|
||||||
expect(files[0]).toMatch(
|
|
||||||
new RegExp(`^page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z\\.${ext}$`)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient, localOutputPath, server }) => {
|
|
||||||
const outputDir = localOutputPath('output');
|
|
||||||
const client = await startClient({
|
|
||||||
config: { outputDir },
|
|
||||||
});
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.HELLO_WORLD },
|
|
||||||
})).toContainTextContent(`Navigate to http://localhost`);
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_take_screenshot',
|
|
||||||
arguments: {
|
|
||||||
filename: 'output.jpeg',
|
|
||||||
},
|
|
||||||
})).toEqual({
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
data: expect.any(String),
|
|
||||||
mimeType: 'image/jpeg',
|
|
||||||
type: 'image',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: expect.stringContaining(`output.jpeg`),
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const files = [...fs.readdirSync(outputDir)];
|
|
||||||
|
|
||||||
expect(fs.existsSync(outputDir)).toBeTruthy();
|
|
||||||
expect(files).toHaveLength(1);
|
|
||||||
expect(files[0]).toMatch(/^output.jpeg$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('browser_take_screenshot (noImageResponses)', async ({ startClient, server }) => {
|
|
||||||
const client = await startClient({
|
|
||||||
config: {
|
|
||||||
noImageResponses: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.HELLO_WORLD },
|
|
||||||
})).toContainTextContent(`Navigate to http://localhost`);
|
|
||||||
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_take_screenshot',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_take_screenshot',
|
|
||||||
})).toEqual({
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
text: expect.stringContaining(`Screenshot viewport and save it as`),
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('browser_take_screenshot (cursor)', async ({ startClient, server }) => {
|
|
||||||
const client = await startClient({ clientName: 'cursor:vscode' });
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.HELLO_WORLD },
|
|
||||||
})).toContainTextContent(`Navigate to http://localhost`);
|
|
||||||
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_take_screenshot',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_take_screenshot',
|
|
||||||
})).toEqual({
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
text: expect.stringContaining(`Screenshot viewport and save it as`),
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import url from 'node:url';
|
|
||||||
import http from 'node:http';
|
|
||||||
import { spawn } from 'node:child_process';
|
|
||||||
import path from 'node:path';
|
|
||||||
import type { AddressInfo } from 'node:net';
|
|
||||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
|
||||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
||||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
||||||
|
|
||||||
import { createConnection } from '@playwright/mcp';
|
|
||||||
|
|
||||||
import { test as baseTest, expect } from './fixtures.js';
|
|
||||||
|
|
||||||
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
|
||||||
const __filename = url.fileURLToPath(import.meta.url);
|
|
||||||
|
|
||||||
const test = baseTest.extend<{ serverEndpoint: string }>({
|
|
||||||
serverEndpoint: async ({}, use) => {
|
|
||||||
const cp = spawn('node', [path.join(path.dirname(__filename), '../cli.js'), '--port', '0'], { stdio: 'pipe' });
|
|
||||||
try {
|
|
||||||
let stdout = '';
|
|
||||||
const url = await new Promise<string>(resolve => cp.stdout?.on('data', data => {
|
|
||||||
stdout += data.toString();
|
|
||||||
const match = stdout.match(/Listening on (http:\/\/.*)/);
|
|
||||||
if (match)
|
|
||||||
resolve(match[1]);
|
|
||||||
}));
|
|
||||||
|
|
||||||
await use(url);
|
|
||||||
} finally {
|
|
||||||
cp.kill();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sse transport', async ({ serverEndpoint }) => {
|
|
||||||
const transport = new SSEClientTransport(new URL(serverEndpoint));
|
|
||||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
|
||||||
await client.connect(transport);
|
|
||||||
await client.ping();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('streamable http transport', async ({ serverEndpoint }) => {
|
|
||||||
const transport = new StreamableHTTPClientTransport(new URL('/mcp', serverEndpoint));
|
|
||||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
|
||||||
await client.connect(transport);
|
|
||||||
await client.ping();
|
|
||||||
expect(transport.sessionId, 'has session support').toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sse transport via public API', async ({ server }) => {
|
|
||||||
const sessions = new Map<string, SSEServerTransport>();
|
|
||||||
const mcpServer = http.createServer(async (req, res) => {
|
|
||||||
if (req.method === 'GET') {
|
|
||||||
const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
|
|
||||||
const transport = new SSEServerTransport('/sse', res);
|
|
||||||
sessions.set(transport.sessionId, transport);
|
|
||||||
await connection.connect(transport);
|
|
||||||
} else if (req.method === 'POST') {
|
|
||||||
const url = new URL(`http://localhost${req.url}`);
|
|
||||||
const sessionId = url.searchParams.get('sessionId');
|
|
||||||
if (!sessionId) {
|
|
||||||
res.statusCode = 400;
|
|
||||||
return res.end('Missing sessionId');
|
|
||||||
}
|
|
||||||
const transport = sessions.get(sessionId);
|
|
||||||
if (!transport) {
|
|
||||||
res.statusCode = 404;
|
|
||||||
return res.end('Session not found');
|
|
||||||
}
|
|
||||||
void transport.handlePostMessage(req, res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await new Promise<void>(resolve => mcpServer.listen(0, () => resolve()));
|
|
||||||
const serverUrl = `http://localhost:${(mcpServer.address() as AddressInfo).port}/sse`;
|
|
||||||
const transport = new SSEClientTransport(new URL(serverUrl));
|
|
||||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
|
||||||
await client.connect(transport);
|
|
||||||
await client.ping();
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.HELLO_WORLD },
|
|
||||||
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
|
||||||
await client.close();
|
|
||||||
mcpServer.close();
|
|
||||||
});
|
|
||||||
@@ -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.js';
|
|
||||||
|
|
||||||
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
|
|
||||||
- generic [ref=e1]: 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
|
|
||||||
- generic [ref=e1]: 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
|
|
||||||
- generic [ref=e1]: 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
|
|
||||||
- generic [ref=e1]: Body one
|
|
||||||
\`\`\``);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('reuse first tab when navigating', async ({ startClient, cdpEndpoint, server }) => {
|
|
||||||
server.setContent('/', `<title>Title</title><body>Body</body>`, 'text/html');
|
|
||||||
|
|
||||||
const browser = await chromium.connectOverCDP(await cdpEndpoint());
|
|
||||||
const [context] = browser.contexts();
|
|
||||||
const pages = context.pages();
|
|
||||||
|
|
||||||
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.PREFIX },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(pages.length).toBe(1);
|
|
||||||
expect(await pages[0].title()).toBe('Title');
|
|
||||||
});
|
|
||||||
@@ -16,7 +16,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import url from 'node:url';
|
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
import https from 'https';
|
import https from 'https';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -25,9 +24,6 @@ import debug from 'debug';
|
|||||||
const fulfillSymbol = Symbol('fulfil callback');
|
const fulfillSymbol = Symbol('fulfil callback');
|
||||||
const rejectSymbol = Symbol('reject callback');
|
const rejectSymbol = Symbol('reject callback');
|
||||||
|
|
||||||
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
|
||||||
const __filename = url.fileURLToPath(import.meta.url);
|
|
||||||
|
|
||||||
export class TestServer {
|
export class TestServer {
|
||||||
private _server: http.Server;
|
private _server: http.Server;
|
||||||
readonly debugServer: any;
|
readonly debugServer: any;
|
||||||
|
|||||||
@@ -1,85 +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.js';
|
|
||||||
|
|
||||||
test('browser_wait_for(text)', async ({ client, server }) => {
|
|
||||||
server.setContent('/', `
|
|
||||||
<script>
|
|
||||||
function update() {
|
|
||||||
setTimeout(() => {
|
|
||||||
document.querySelector('div').textContent = 'Text to appear';
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<body>
|
|
||||||
<button onclick="update()">Click me</button>
|
|
||||||
<div>Text to disappear</div>
|
|
||||||
</body>
|
|
||||||
`, 'text/html');
|
|
||||||
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.PREFIX },
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_click',
|
|
||||||
arguments: {
|
|
||||||
element: 'Click me',
|
|
||||||
ref: 'e2',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_wait_for',
|
|
||||||
arguments: { text: 'Text to appear' },
|
|
||||||
})).toContainTextContent(`- generic [ref=e3]: Text to appear`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('browser_wait_for(textGone)', async ({ client, server }) => {
|
|
||||||
server.setContent('/', `
|
|
||||||
<script>
|
|
||||||
function update() {
|
|
||||||
setTimeout(() => {
|
|
||||||
document.querySelector('div').textContent = 'Text to appear';
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<body>
|
|
||||||
<button onclick="update()">Click me</button>
|
|
||||||
<div>Text to disappear</div>
|
|
||||||
</body>
|
|
||||||
`, 'text/html');
|
|
||||||
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.PREFIX },
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.callTool({
|
|
||||||
name: 'browser_click',
|
|
||||||
arguments: {
|
|
||||||
element: 'Click me',
|
|
||||||
ref: 'e2',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_wait_for',
|
|
||||||
arguments: { textGone: 'Text to disappear' },
|
|
||||||
})).toContainTextContent(`- generic [ref=e3]: Text to appear`);
|
|
||||||
});
|
|
||||||
@@ -1,38 +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.js';
|
|
||||||
|
|
||||||
test('do not falsely advertise user agent as a test driver', async ({ client, server, mcpBrowser }) => {
|
|
||||||
test.skip(mcpBrowser === 'firefox');
|
|
||||||
test.skip(mcpBrowser === 'webkit');
|
|
||||||
server.route('/', (req, res) => {
|
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
||||||
res.end(`
|
|
||||||
<body></body>
|
|
||||||
<script>
|
|
||||||
document.body.textContent = 'webdriver: ' + navigator.webdriver;
|
|
||||||
</script>
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: {
|
|
||||||
url: server.PREFIX,
|
|
||||||
},
|
|
||||||
})).toContainTextContent('webdriver: false');
|
|
||||||
});
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./tsconfig.json",
|
|
||||||
"include": ["**/*.ts", "**/*.js"],
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ESNext",
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"moduleResolution": "nodenext",
|
|
||||||
"strict": true,
|
|
||||||
"module": "NodeNext",
|
|
||||||
"rootDir": "src",
|
|
||||||
"outDir": "./lib",
|
|
||||||
"resolveJsonModule": true
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"src",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -16,69 +16,27 @@
|
|||||||
*/
|
*/
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
import fs from 'node:fs'
|
const fs = require('fs')
|
||||||
import path from 'node:path'
|
const path = require('path')
|
||||||
import url from 'node:url'
|
const { zodToJsonSchema } = require('zod-to-json-schema')
|
||||||
import zodToJsonSchema from 'zod-to-json-schema'
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
import commonTools from '../lib/tools/common.js';
|
const { allTools } = require('playwright/lib/mcp/browser/tools');
|
||||||
import consoleTools from '../lib/tools/console.js';
|
|
||||||
import dialogsTools from '../lib/tools/dialogs.js';
|
|
||||||
import filesTools from '../lib/tools/files.js';
|
|
||||||
import installTools from '../lib/tools/install.js';
|
|
||||||
import keyboardTools from '../lib/tools/keyboard.js';
|
|
||||||
import navigateTools from '../lib/tools/navigate.js';
|
|
||||||
import networkTools from '../lib/tools/network.js';
|
|
||||||
import pdfTools from '../lib/tools/pdf.js';
|
|
||||||
import snapshotTools from '../lib/tools/snapshot.js';
|
|
||||||
import tabsTools from '../lib/tools/tabs.js';
|
|
||||||
import screenshotTools from '../lib/tools/screenshot.js';
|
|
||||||
import testTools from '../lib/tools/testing.js';
|
|
||||||
import visionTools from '../lib/tools/vision.js';
|
|
||||||
import waitTools from '../lib/tools/wait.js';
|
|
||||||
import { execSync } from 'node:child_process';
|
|
||||||
|
|
||||||
const categories = {
|
const capabilities = {
|
||||||
'Interactions': [
|
'core': 'Core automation',
|
||||||
...snapshotTools,
|
'core-tabs': 'Tab management',
|
||||||
...keyboardTools(true),
|
'core-install': 'Browser installation',
|
||||||
...waitTools(true),
|
'vision': 'Coordinate-based (opt-in via --caps=vision)',
|
||||||
...filesTools(true),
|
'pdf': 'PDF generation (opt-in via --caps=pdf)',
|
||||||
...dialogsTools(true),
|
'verify': 'Verify (opt-in via --caps=verify)',
|
||||||
],
|
'tracing': 'Tracing (opt-in via --caps=tracing)',
|
||||||
'Navigation': [
|
|
||||||
...navigateTools(true),
|
|
||||||
],
|
|
||||||
'Resources': [
|
|
||||||
...screenshotTools,
|
|
||||||
...pdfTools,
|
|
||||||
...networkTools,
|
|
||||||
...consoleTools,
|
|
||||||
],
|
|
||||||
'Utilities': [
|
|
||||||
...installTools,
|
|
||||||
...commonTools(true),
|
|
||||||
],
|
|
||||||
'Tabs': [
|
|
||||||
...tabsTools(true),
|
|
||||||
],
|
|
||||||
'Testing': [
|
|
||||||
...testTools,
|
|
||||||
],
|
|
||||||
'Vision mode': [
|
|
||||||
...visionTools,
|
|
||||||
...keyboardTools(),
|
|
||||||
...waitTools(false),
|
|
||||||
...filesTools(false),
|
|
||||||
...dialogsTools(false),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
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))]));
|
||||||
const __filename = url.fileURLToPath(import.meta.url);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import('../src/tools/tool.js').ToolSchema<any>} tool
|
* @param {any} tool
|
||||||
* @returns {string[]}
|
* @returns {string[]}
|
||||||
*/
|
*/
|
||||||
function formatToolForReadme(tool) {
|
function formatToolForReadme(tool) {
|
||||||
@@ -139,14 +97,12 @@ async function updateSection(content, startMarker, endMarker, generatedLines) {
|
|||||||
async function updateTools(content) {
|
async function updateTools(content) {
|
||||||
console.log('Loading tool information from compiled modules...');
|
console.log('Loading tool information from compiled modules...');
|
||||||
|
|
||||||
const totalTools = Object.values(categories).flat().length;
|
|
||||||
console.log(`Found ${totalTools} tools`);
|
|
||||||
|
|
||||||
const generatedLines = /** @type {string[]} */ ([]);
|
const generatedLines = /** @type {string[]} */ ([]);
|
||||||
for (const [category, categoryTools] of Object.entries(categories)) {
|
for (const [capability, tools] of Object.entries(toolsByCapability)) {
|
||||||
generatedLines.push(`<details>\n<summary><b>${category}</b></summary>`);
|
console.log('Updating tools for capability:', capability);
|
||||||
|
generatedLines.push(`<details>\n<summary><b>${capability}</b></summary>`);
|
||||||
generatedLines.push('');
|
generatedLines.push('');
|
||||||
for (const tool of categoryTools)
|
for (const tool of tools)
|
||||||
generatedLines.push(...formatToolForReadme(tool.schema));
|
generatedLines.push(...formatToolForReadme(tool.schema));
|
||||||
generatedLines.push(`</details>`);
|
generatedLines.push(`</details>`);
|
||||||
generatedLines.push('');
|
generatedLines.push('');
|
||||||
@@ -180,7 +136,7 @@ async function updateOptions(content) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function updateReadme() {
|
async function updateReadme() {
|
||||||
const readmePath = path.join(path.dirname(__filename), '..', 'README.md');
|
const readmePath = path.join(__dirname, 'README.md');
|
||||||
const readmeContent = await fs.promises.readFile(readmePath, 'utf-8');
|
const readmeContent = await fs.promises.readFile(readmePath, 'utf-8');
|
||||||
const withTools = await updateTools(readmeContent);
|
const withTools = await updateTools(readmeContent);
|
||||||
const withOptions = await updateOptions(withTools);
|
const withOptions = await updateOptions(withTools);
|
||||||
@@ -1,15 +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.
|
|
||||||
*/
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
const config = JSON.stringify({ name: 'playwright', command: 'npx', args: ["@playwright/mcp@latest"] });
|
|
||||||
const urlForWebsites = `vscode:mcp/install?${encodeURIComponent(config)}`;
|
|
||||||
// Github markdown does not allow linking to `vscode:` directly, so you can use our redirect:
|
|
||||||
const urlForGithub = `https://insiders.vscode.dev/redirect?url=${encodeURIComponent(urlForWebsites)}`;
|
|
||||||
|
|
||||||
console.log(urlForGithub);
|
|
||||||
Reference in New Issue
Block a user