mirror of
https://github.com/microsoft/playwright-mcp.git
synced 2026-01-30 06:22:03 +00:00
Compare commits
209 Commits
copilot/fi
...
v0.0.59
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f531b2c9cb | ||
|
|
4b62f68979 | ||
|
|
5b497bcca8 | ||
|
|
00b9c54515 | ||
|
|
79111366a9 | ||
|
|
956b79a1ab | ||
|
|
b58ad48e0a | ||
|
|
41fba2bd71 | ||
|
|
cd2b589338 | ||
|
|
fbd62cd838 | ||
|
|
6aab683338 | ||
|
|
85c64bbe0f | ||
|
|
b213c187b0 | ||
|
|
412f6dc6fe | ||
|
|
4b1a6842b1 | ||
|
|
9cc61b4faf | ||
|
|
33b4c00923 | ||
|
|
f5ed83a4ca | ||
|
|
2f7467ba29 | ||
|
|
d47197f41f | ||
|
|
dba2fd054d | ||
|
|
075397e57e | ||
|
|
e8b471ec60 | ||
|
|
c806df7b13 | ||
|
|
a0b4ffbe15 | ||
|
|
0a6f1c4ea4 | ||
|
|
c784f93a65 | ||
|
|
10c340c0b3 | ||
|
|
850520321b | ||
|
|
b717a2a8ed | ||
|
|
ff9544db83 | ||
|
|
c85ee1a6ec | ||
|
|
0fcb25d118 | ||
|
|
f4df37ca71 | ||
|
|
64f65ccd10 | ||
|
|
3f7e2d1b45 | ||
|
|
e0cb424dea | ||
|
|
86e0020b4a | ||
|
|
009aa9275b | ||
|
|
c016643bf9 | ||
|
|
8cc557d677 | ||
|
|
15d382b940 | ||
|
|
e72701b21c | ||
|
|
8ee8445342 | ||
|
|
ac6e678135 | ||
|
|
b945ace746 | ||
|
|
b3dce4097e | ||
|
|
67ed859c2a | ||
|
|
7da5e7273c | ||
|
|
fe59d4b35f | ||
|
|
a03ec7ad56 | ||
|
|
ad14743235 | ||
|
|
a9ffccd40f | ||
|
|
b4e016a0b8 | ||
|
|
e17bf17dff | ||
|
|
7ee5c87a4b | ||
|
|
2817952d0d | ||
|
|
fb900a8827 | ||
|
|
29e532687c | ||
|
|
d149b89889 | ||
|
|
1caecd00c7 | ||
|
|
9657b58e17 | ||
|
|
8dfea1c67f | ||
|
|
a86b580797 | ||
|
|
927e570c18 | ||
|
|
24e68fa41d | ||
|
|
e50322731b | ||
|
|
711089d261 | ||
|
|
4a7be8de75 | ||
|
|
1523338246 | ||
|
|
08bd91d119 | ||
|
|
ef83729796 | ||
|
|
a70854c02f | ||
|
|
d42e0e12fc | ||
|
|
335315f158 | ||
|
|
c49dedb83f | ||
|
|
0bfcb05422 | ||
|
|
ca0820c55e | ||
|
|
45fee738bc | ||
|
|
dc3419023f | ||
|
|
2036f8a9d5 | ||
|
|
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 |
70
.github/workflows/ci.yml
vendored
70
.github/workflows/ci.yml
vendored
@@ -11,61 +11,50 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 18
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- run: npm run build
|
||||
- name: Run ESLint
|
||||
run: npm run lint
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- name: Ensure no changes
|
||||
run: git diff --exit-code
|
||||
|
||||
test:
|
||||
test_mcp:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
os: [ubuntu-latest, macos-15, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 18
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
# https://github.com/microsoft/playwright-mcp/issues/344
|
||||
node-version: '18.19'
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Playwright install
|
||||
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
|
||||
run: npm test
|
||||
run: npm run test
|
||||
working-directory: ./packages/playwright-mcp
|
||||
|
||||
test_docker:
|
||||
test_mcp_docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 18
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Playwright install
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build and push
|
||||
@@ -81,5 +70,38 @@ jobs:
|
||||
# Used for the Docker tests to share the test-results folder with the container.
|
||||
umask 0000
|
||||
npm run test -- --project=chromium-docker
|
||||
working-directory: ./packages/playwright-mcp
|
||||
env:
|
||||
MCP_IN_DOCKER: 1
|
||||
|
||||
test_extension:
|
||||
runs-on: macos-latest
|
||||
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: Playwright install
|
||||
run: npx playwright install --with-deps
|
||||
- name: Build extension
|
||||
run: npm run build
|
||||
working-directory: ./packages/extension
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: extension
|
||||
path: ./extension/dist
|
||||
retention-days: 7
|
||||
- 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
|
||||
working-directory: ./packages/extension
|
||||
|
||||
108
.github/workflows/publish.yml
vendored
108
.github/workflows/publish.yml
vendored
@@ -1,36 +1,87 @@
|
||||
name: Publish
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 8 * * *'
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
publish-npm:
|
||||
publish-mcp-canary-npm:
|
||||
if: github.event.schedule || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # Needed for npm provenance
|
||||
id-token: write # Required for OIDC npm publishing
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
registry-url: https://registry.npmjs.org/
|
||||
# Ensure npm 11.5.1 or later is installed (for OIDC npm publishing)
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- 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
|
||||
working-directory: ./packages/playwright-mcp
|
||||
|
||||
- run: npm ci
|
||||
- run: npx playwright install --with-deps
|
||||
- run: npm run build
|
||||
- run: npm run lint
|
||||
- run: npm run ctest
|
||||
- run: npm publish --provenance
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
working-directory: ./packages/playwright-mcp
|
||||
|
||||
publish-docker:
|
||||
- name: Publish to npm with next tag
|
||||
run: npm publish --tag next
|
||||
working-directory: ./packages/playwright-mcp
|
||||
|
||||
publish-mcp-release-npm:
|
||||
if: github.event_name == 'release'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # Required for OIDC npm publishing
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: https://registry.npmjs.org/
|
||||
# Ensure npm 11.5.1 or later is installed (for OIDC npm publishing)
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
- run: npm ci
|
||||
- run: npx playwright install --with-deps
|
||||
- run: npm run lint
|
||||
- run: npm run ctest
|
||||
working-directory: ./packages/playwright-mcp
|
||||
- run: npm publish
|
||||
working-directory: ./packages/playwright-mcp
|
||||
|
||||
publish-mcp-release-docker:
|
||||
if: github.event_name == 'release'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # Needed for OIDC login to Azure
|
||||
environment: allow-publishing-docker-to-acr
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Set up QEMU # Needed for multi-platform builds (e.g., arm64 on amd64 runner)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx # Needed for multi-platform builds
|
||||
@@ -47,8 +98,7 @@ jobs:
|
||||
id: build-push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile # Adjust path if your Dockerfile is elsewhere
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
@@ -68,3 +118,35 @@ jobs:
|
||||
for tag in $(echo ${{ steps.build-push.outputs.metadata['image.name'] }} | tr ',' '\n'); do
|
||||
attach_eol_manifest $tag
|
||||
done
|
||||
|
||||
package-release-extension:
|
||||
if: github.event_name == 'release'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # Needed to upload release assets
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
- name: Install extension dependencies
|
||||
run: npm ci
|
||||
- name: Build extension
|
||||
working-directory: ./packages/extension
|
||||
run: npm run build
|
||||
- name: Get extension version
|
||||
id: get-version
|
||||
working-directory: ./packages/extension
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
- name: Package extension
|
||||
working-directory: ./packages/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}} ./packages/extension/playwright-mcp-extension-${{ steps.get-version.outputs.version }}.zip
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,8 +1,8 @@
|
||||
lib/
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
.vscode/mcp.json
|
||||
|
||||
.idea
|
||||
.DS_Store
|
||||
.env
|
||||
sessions/
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
**/*
|
||||
README.md
|
||||
LICENSE
|
||||
!lib/**/*.js
|
||||
!cli.js
|
||||
!index.*
|
||||
!config.d.ts
|
||||
137
CONTRIBUTING.md
Normal file
137
CONTRIBUTING.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Contributing
|
||||
|
||||
## Choose an issue
|
||||
|
||||
Playwright MCP **requires an issue** for every contribution, except for minor documentation updates.
|
||||
|
||||
If you are passionate about a bug/feature, but cannot find an issue describing it, **file an issue first**. This will
|
||||
facilitate the discussion, and you might get some early feedback from project maintainers before spending your time on
|
||||
creating a pull request.
|
||||
|
||||
## Make a change
|
||||
|
||||
> [!WARNING]
|
||||
> The core of the Playwright MCP was moved to the [Playwright monorepo](https://github.com/microsoft/playwright).
|
||||
|
||||
Clone the Playwright repository. If you plan to send a pull request, it might be better to [fork the repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo) first.
|
||||
|
||||
|
||||
```bash
|
||||
git clone https://github.com/microsoft/playwright
|
||||
cd playwright
|
||||
```
|
||||
|
||||
Install dependencies and run the build in watch mode.
|
||||
```bash
|
||||
# install deps and run watch
|
||||
npm ci
|
||||
npm run watch
|
||||
npx playwright install
|
||||
```
|
||||
|
||||
Source code for Playwright MCP is located at [packages/playwright/src/mcp](https://github.com/microsoft/playwright/blob/main/packages/playwright/src/mcp).
|
||||
|
||||
```bash
|
||||
# list source files
|
||||
ls -la packages/playwright/src/mcp
|
||||
```
|
||||
|
||||
Coding style is fully defined in [eslint.config.mjs](https://github.com/microsoft/playwright/blob/main/eslint.config.mjs). Before creating a pull request, or at any moment during development, run linter to check all kinds of things:
|
||||
```bash
|
||||
# lint the source base before sending PR
|
||||
npm run flint
|
||||
```
|
||||
|
||||
Comments should have an explicit purpose and should improve readability rather than hinder it. If the code would not be understood without comments, consider re-writing the code to make it self-explanatory.
|
||||
|
||||
## Add a test
|
||||
|
||||
Playwright requires a test for the new or modified functionality. An exception would be a pure refactoring, but chances are you are doing more than that.
|
||||
|
||||
There are multiple [test suites](https://github.com/microsoft/playwright/blob/main/tests) in Playwright that will be executed on the CI. Tests for Playwright MCP are located at [tests/mcp](https://github.com/microsoft/playwright/blob/main/tests/mcp).
|
||||
|
||||
```bash
|
||||
# list test files
|
||||
ls -la tests/mcp
|
||||
```
|
||||
|
||||
To run the mcp tests, use
|
||||
|
||||
```bash
|
||||
# fast path runs all MCP tests in Chromium
|
||||
npm run mcp-ctest
|
||||
```
|
||||
|
||||
```bash
|
||||
# slow path runs all tests in three browsers
|
||||
npm run mcp-test
|
||||
```
|
||||
|
||||
Since Playwright tests are using Playwright under the hood, everything from our documentation applies, for example [this guide on running and debugging tests](https://playwright.dev/docs/running-tests#running-tests).
|
||||
|
||||
Note that tests should be *hermetic*, and not depend on external services. Tests should work on all three platforms: macOS, Linux and Windows.
|
||||
|
||||
## Write a commit message
|
||||
|
||||
Commit messages should follow the [Semantic Commit Messages](https://www.conventionalcommits.org/en/v1.0.0/) format:
|
||||
|
||||
```
|
||||
label(namespace): title
|
||||
|
||||
description
|
||||
|
||||
footer
|
||||
```
|
||||
|
||||
1. *label* is one of the following:
|
||||
- `fix` - bug fixes
|
||||
- `feat` - new features
|
||||
- `docs` - documentation-only changes
|
||||
- `test` - test-only changes
|
||||
- `devops` - changes to the CI or build
|
||||
- `chore` - everything that doesn't fall under previous categories
|
||||
2. *namespace* is put in parentheses after label and is optional. Must be lowercase.
|
||||
3. *title* is a brief summary of changes.
|
||||
4. *description* is **optional**, new-line separated from title and is in present tense.
|
||||
5. *footer* is **optional**, new-line separated from *description* and contains "fixes" / "references" attribution to GitHub issues.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
feat(trace viewer): network panel filtering
|
||||
|
||||
This patch adds a filtering toolbar to the network panel.
|
||||
<link to a screenshot>
|
||||
|
||||
Fixes #123, references #234.
|
||||
```
|
||||
|
||||
## Send a pull request
|
||||
|
||||
All submissions, including submissions by project members, require review. We use GitHub pull requests for this purpose.
|
||||
Make sure to keep your PR (diff) small and readable. If necessary, split your contribution into multiple PRs.
|
||||
Consult [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more information on using pull requests.
|
||||
|
||||
After a successful code review, one of the maintainers will merge your pull request. Congratulations!
|
||||
|
||||
## More details
|
||||
|
||||
**No new dependencies**
|
||||
|
||||
There is a very high bar for new dependencies, including updating to a new version of an existing dependency. We recommend to explicitly discuss this in an issue and get a green light from a maintainer, before creating a pull request that updates dependencies.
|
||||
|
||||
## Contributor License Agreement
|
||||
|
||||
This project welcomes contributions and suggestions. Most contributions require you to agree to a
|
||||
Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us
|
||||
the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com.
|
||||
|
||||
When you submit a pull request, a CLA bot will automatically determine whether you need to provide
|
||||
a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions
|
||||
provided by the bot. You will only need to do this once across all repos using our CLA.
|
||||
|
||||
### Code of Conduct
|
||||
|
||||
This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
|
||||
For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or
|
||||
contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.
|
||||
12
Dockerfile
12
Dockerfile
@@ -16,6 +16,7 @@ WORKDIR /app
|
||||
RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \
|
||||
--mount=type=bind,source=package.json,target=package.json \
|
||||
--mount=type=bind,source=package-lock.json,target=package-lock.json \
|
||||
--mount=type=bind,source=packages/playwright-mcp/package.json,target=packages/playwright-mcp/package.json \
|
||||
npm ci --omit=dev && \
|
||||
# Install system dependencies for playwright
|
||||
npx -y playwright-core install-deps chromium
|
||||
@@ -28,14 +29,11 @@ FROM base AS builder
|
||||
RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \
|
||||
--mount=type=bind,source=package.json,target=package.json \
|
||||
--mount=type=bind,source=package-lock.json,target=package-lock.json \
|
||||
--mount=type=bind,source=packages/playwright-mcp/package.json,target=packages/playwright-mcp/package.json \
|
||||
npm ci
|
||||
|
||||
# Copy the rest of the app
|
||||
COPY *.json *.js *.ts .
|
||||
COPY src src/
|
||||
|
||||
# Build the app
|
||||
RUN npm run build
|
||||
COPY packages/playwright-mcp/*.json packages/playwright-mcp/*.js packages/playwright-mcp/*.ts .
|
||||
|
||||
# ------------------------------
|
||||
# Browser
|
||||
@@ -55,6 +53,7 @@ FROM base
|
||||
ARG PLAYWRIGHT_BROWSERS_PATH
|
||||
ARG USERNAME=node
|
||||
ENV NODE_ENV=production
|
||||
ENV PLAYWRIGHT_MCP_OUTPUT_DIR=/tmp/playwright-output
|
||||
|
||||
# Set the correct ownership for the runtime user on production `node_modules`
|
||||
RUN chown -R ${USERNAME}:${USERNAME} node_modules
|
||||
@@ -62,8 +61,7 @@ RUN chown -R ${USERNAME}:${USERNAME} node_modules
|
||||
USER ${USERNAME}
|
||||
|
||||
COPY --from=browser --chown=${USERNAME}:${USERNAME} ${PLAYWRIGHT_BROWSERS_PATH} ${PLAYWRIGHT_BROWSERS_PATH}
|
||||
COPY --chown=${USERNAME}:${USERNAME} cli.js package.json ./
|
||||
COPY --from=builder --chown=${USERNAME}:${USERNAME} /app/lib /app/lib
|
||||
COPY --chown=${USERNAME}:${USERNAME} packages/playwright-mcp/cli.js packages/playwright-mcp/package.json ./
|
||||
|
||||
# Run in headless and only with chromium (other browsers need more dependencies not included in this image)
|
||||
ENTRYPOINT ["node", "cli.js", "--headless", "--browser", "chromium", "--no-sandbox"]
|
||||
|
||||
@@ -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 @@
|
||||
Use Playwright tools to 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.
|
||||
4969
package-lock.json
generated
4969
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
68
package.json
68
package.json
@@ -1,68 +1,30 @@
|
||||
{
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.31",
|
||||
"description": "Playwright Tools for MCP",
|
||||
"type": "module",
|
||||
"name": "playwright-mcp-internal",
|
||||
"version": "0.0.59",
|
||||
"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",
|
||||
"lint": "npm run update-readme && eslint . && tsc --noEmit",
|
||||
"update-readme": "node utils/update-readme.js",
|
||||
"watch": "tsc --watch",
|
||||
"test": "playwright test",
|
||||
"ctest": "playwright test --project=chrome",
|
||||
"ftest": "playwright test --project=firefox",
|
||||
"wtest": "playwright test --project=webkit",
|
||||
"run-server": "node lib/browserServer.js",
|
||||
"clean": "rm -rf lib",
|
||||
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
|
||||
},
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"types": "./index.d.ts",
|
||||
"default": "./index.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"commander": "^13.1.0",
|
||||
"debug": "^4.4.1",
|
||||
"mime": "^4.0.7",
|
||||
"playwright": "1.55.0-alpha-1752701791000",
|
||||
"playwright-core": "1.55.0-alpha-1752701791000",
|
||||
"ws": "^8.18.1",
|
||||
"zod-to-json-schema": "^3.24.4"
|
||||
"docker-build": "docker build --no-cache -t playwright-mcp-dev:latest .",
|
||||
"docker-rm": "docker rm playwright-mcp-dev",
|
||||
"docker-run": "docker run -it -p 8080:8080 --name playwright-mcp-dev playwright-mcp-dev:latest",
|
||||
"lint": "npm run lint --workspaces",
|
||||
"test": "npm run test --workspaces",
|
||||
"build": "npm run build --workspaces"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@playwright/test": "1.55.0-alpha-1752701791000",
|
||||
"@stylistic/eslint-plugin": "^3.0.1",
|
||||
"@types/chrome": "^0.0.315",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@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": {
|
||||
"mcp-server-playwright": "cli.js"
|
||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||
"@playwright/test": "1.59.0-alpha-1769364499000",
|
||||
"@types/node": "^24.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
1
packages/extension/.gitignore
vendored
Normal file
1
packages/extension/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
dist/
|
||||
77
packages/extension/README.md
Normal file
77
packages/extension/README.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# 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.
|
||||
|
||||
### Bypassing the Connection Approval Dialog
|
||||
|
||||
By default, you'll need to approve each connection when the MCP server tries to connect to your browser. To bypass this approval dialog and allow automatic connections, you can use an authentication token.
|
||||
|
||||
#### Using Your Unique Authentication Token
|
||||
|
||||
1. After installing the extension, click on the extension icon or navigate to the extension's status page
|
||||
2. Copy the `PLAYWRIGHT_MCP_EXTENSION_TOKEN` value displayed in the extension UI
|
||||
3. Add it to your MCP server configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright-extension": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest",
|
||||
"--extension"
|
||||
],
|
||||
"env": {
|
||||
"PLAYWRIGHT_MCP_EXTENSION_TOKEN": "your-token-here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This token is unique to your browser profile and provides secure authentication between the MCP server and the extension. Once configured, you won't need to manually approve connections each time.
|
||||
|
||||
|
||||
BIN
packages/extension/icons/icon-128.png
Normal file
BIN
packages/extension/icons/icon-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
BIN
packages/extension/icons/icon-16.png
Normal file
BIN
packages/extension/icons/icon-16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 571 B |
BIN
packages/extension/icons/icon-32.png
Normal file
BIN
packages/extension/icons/icon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
packages/extension/icons/icon-48.png
Normal file
BIN
packages/extension/icons/icon-48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
35
packages/extension/manifest.json
Normal file
35
packages/extension/manifest.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Playwright MCP Bridge",
|
||||
"version": "0.0.59",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
36
packages/extension/package.json
Normal file
36
packages/extension/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@playwright/mcp-extension",
|
||||
"version": "0.0.59",
|
||||
"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",
|
||||
"lint": "tsc --project .",
|
||||
"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.4.21",
|
||||
"vite-plugin-static-copy": "^3.1.1"
|
||||
}
|
||||
}
|
||||
31
packages/extension/playwright.config.ts
Normal file
31
packages/extension/playwright.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
import type { TestOptions } from '../playwright-mcp/tests/fixtures';
|
||||
|
||||
export default defineConfig<TestOptions>({
|
||||
testDir: './tests',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'list',
|
||||
projects: [
|
||||
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
||||
],
|
||||
});
|
||||
222
packages/extension/src/background.ts
Normal file
222
packages/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
packages/extension/src/relayConnection.ts
Normal file
178
packages/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));
|
||||
}
|
||||
}
|
||||
142
packages/extension/src/ui/authToken.css
Normal file
142
packages/extension/src/ui/authToken.css
Normal file
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
.auth-token-section {
|
||||
margin: 16px 0;
|
||||
padding: 16px;
|
||||
background-color: #f6f8fa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.auth-token-description {
|
||||
font-size: 12px;
|
||||
color: #656d76;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.auth-token-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background-color: #ffffff;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.auth-token-code {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: #1f2328;
|
||||
border: none;
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.auth-token-refresh {
|
||||
flex: none;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--color-fg-muted);
|
||||
background: transparent;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.auth-token-refresh svg {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-token-refresh:not(:disabled):hover {
|
||||
background-color: var(--color-btn-selected-bg);
|
||||
}
|
||||
|
||||
.auth-token-example-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.auth-token-example-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 8px 0;
|
||||
font-size: 12px;
|
||||
color: #656d76;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.auth-token-example-toggle:hover {
|
||||
color: #1f2328;
|
||||
}
|
||||
|
||||
.auth-token-chevron {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: rotate(-90deg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.auth-token-chevron.expanded {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.auth-token-chevron svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.auth-token-chevron .octicon {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.auth-token-example-content {
|
||||
margin-top: 12px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.auth-token-example-description {
|
||||
font-size: 12px;
|
||||
color: #656d76;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.auth-token-example-config {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
background-color: #ffffff;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.auth-token-example-code {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 11px;
|
||||
color: #1f2328;
|
||||
white-space: pre;
|
||||
flex: 1;
|
||||
line-height: 1.4;
|
||||
}
|
||||
118
packages/extension/src/ui/authToken.tsx
Normal file
118
packages/extension/src/ui/authToken.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 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, { useCallback, useState } from 'react';
|
||||
import { CopyToClipboard } from './copyToClipboard';
|
||||
import * as icons from './icons';
|
||||
import './authToken.css';
|
||||
|
||||
export const AuthTokenSection: React.FC<{}> = ({}) => {
|
||||
const [authToken, setAuthToken] = useState<string>(getOrCreateAuthToken);
|
||||
const [isExampleExpanded, setIsExampleExpanded] = useState<boolean>(false);
|
||||
|
||||
const onRegenerateToken = useCallback(() => {
|
||||
const newToken = generateAuthToken();
|
||||
localStorage.setItem('auth-token', newToken);
|
||||
setAuthToken(newToken);
|
||||
}, []);
|
||||
|
||||
const toggleExample = useCallback(() => {
|
||||
setIsExampleExpanded(!isExampleExpanded);
|
||||
}, [isExampleExpanded]);
|
||||
|
||||
return (
|
||||
<div className='auth-token-section'>
|
||||
<div className='auth-token-description'>
|
||||
Set this environment variable to bypass the connection dialog:
|
||||
</div>
|
||||
<div className='auth-token-container'>
|
||||
<code className='auth-token-code'>{authTokenCode(authToken)}</code>
|
||||
<button className='auth-token-refresh' title='Generate new token' aria-label='Generate new token'onClick={onRegenerateToken}>{icons.refresh()}</button>
|
||||
<CopyToClipboard value={authTokenCode(authToken)} />
|
||||
</div>
|
||||
|
||||
<div className='auth-token-example-section'>
|
||||
<button
|
||||
className='auth-token-example-toggle'
|
||||
onClick={toggleExample}
|
||||
aria-expanded={isExampleExpanded}
|
||||
title={isExampleExpanded ? 'Hide example config' : 'Show example config'}
|
||||
>
|
||||
<span className={`auth-token-chevron ${isExampleExpanded ? 'expanded' : ''}`}>
|
||||
{icons.chevronDown()}
|
||||
</span>
|
||||
Example MCP server configuration
|
||||
</button>
|
||||
|
||||
{isExampleExpanded && (
|
||||
<div className='auth-token-example-content'>
|
||||
<div className='auth-token-example-description'>
|
||||
Add this configuration to your MCP client (e.g., VS Code) to connect to the Playwright MCP Bridge:
|
||||
</div>
|
||||
<div className='auth-token-example-config'>
|
||||
<code className='auth-token-example-code'>{exampleConfig(authToken)}</code>
|
||||
<CopyToClipboard value={exampleConfig(authToken)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function authTokenCode(authToken: string) {
|
||||
return `PLAYWRIGHT_MCP_EXTENSION_TOKEN=${authToken}`;
|
||||
}
|
||||
|
||||
function exampleConfig(authToken: string) {
|
||||
return `{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--extension"],
|
||||
"env": {
|
||||
"PLAYWRIGHT_MCP_EXTENSION_TOKEN":
|
||||
"${authToken}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
}
|
||||
|
||||
function generateAuthToken(): string {
|
||||
// Generate a cryptographically secure random token
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
// Convert to base64 and make it URL-safe
|
||||
return btoa(String.fromCharCode.apply(null, Array.from(array)))
|
||||
.replace(/[+/=]/g, match => {
|
||||
switch (match) {
|
||||
case '+': return '-';
|
||||
case '/': return '_';
|
||||
case '=': return '';
|
||||
default: return match;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const getOrCreateAuthToken = (): string => {
|
||||
let token = localStorage.getItem('auth-token');
|
||||
if (!token) {
|
||||
token = generateAuthToken();
|
||||
localStorage.setItem('auth-token', token);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
891
packages/extension/src/ui/colors.css
Normal file
891
packages/extension/src/ui/colors.css
Normal file
@@ -0,0 +1,891 @@
|
||||
/* The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2021 GitHub Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE. */
|
||||
|
||||
:root {
|
||||
--color-canvas-default-transparent: rgba(255,255,255,0);
|
||||
--color-marketing-icon-primary: #218bff;
|
||||
--color-marketing-icon-secondary: #54aeff;
|
||||
--color-diff-blob-addition-num-text: #24292f;
|
||||
--color-diff-blob-addition-fg: #24292f;
|
||||
--color-diff-blob-addition-num-bg: #CCFFD8;
|
||||
--color-diff-blob-addition-line-bg: #E6FFEC;
|
||||
--color-diff-blob-addition-word-bg: #ABF2BC;
|
||||
--color-diff-blob-deletion-num-text: #24292f;
|
||||
--color-diff-blob-deletion-fg: #24292f;
|
||||
--color-diff-blob-deletion-num-bg: #FFD7D5;
|
||||
--color-diff-blob-deletion-line-bg: #FFEBE9;
|
||||
--color-diff-blob-deletion-word-bg: rgba(255,129,130,0.4);
|
||||
--color-diff-blob-hunk-num-bg: rgba(84,174,255,0.4);
|
||||
--color-diff-blob-expander-icon: #57606a;
|
||||
--color-diff-blob-selected-line-highlight-mix-blend-mode: multiply;
|
||||
--color-diffstat-deletion-border: rgba(27,31,36,0.15);
|
||||
--color-diffstat-addition-border: rgba(27,31,36,0.15);
|
||||
--color-diffstat-addition-bg: #2da44e;
|
||||
--color-search-keyword-hl: #fff8c5;
|
||||
--color-prettylights-syntax-comment: #6e7781;
|
||||
--color-prettylights-syntax-constant: #0550ae;
|
||||
--color-prettylights-syntax-entity: #8250df;
|
||||
--color-prettylights-syntax-storage-modifier-import: #24292f;
|
||||
--color-prettylights-syntax-entity-tag: #116329;
|
||||
--color-prettylights-syntax-keyword: #cf222e;
|
||||
--color-prettylights-syntax-string: #0a3069;
|
||||
--color-prettylights-syntax-variable: #953800;
|
||||
--color-prettylights-syntax-brackethighlighter-unmatched: #82071e;
|
||||
--color-prettylights-syntax-invalid-illegal-text: #f6f8fa;
|
||||
--color-prettylights-syntax-invalid-illegal-bg: #82071e;
|
||||
--color-prettylights-syntax-carriage-return-text: #f6f8fa;
|
||||
--color-prettylights-syntax-carriage-return-bg: #cf222e;
|
||||
--color-prettylights-syntax-string-regexp: #116329;
|
||||
--color-prettylights-syntax-markup-list: #3b2300;
|
||||
--color-prettylights-syntax-markup-heading: #0550ae;
|
||||
--color-prettylights-syntax-markup-italic: #24292f;
|
||||
--color-prettylights-syntax-markup-bold: #24292f;
|
||||
--color-prettylights-syntax-markup-deleted-text: #82071e;
|
||||
--color-prettylights-syntax-markup-deleted-bg: #FFEBE9;
|
||||
--color-prettylights-syntax-markup-inserted-text: #116329;
|
||||
--color-prettylights-syntax-markup-inserted-bg: #dafbe1;
|
||||
--color-prettylights-syntax-markup-changed-text: #953800;
|
||||
--color-prettylights-syntax-markup-changed-bg: #ffd8b5;
|
||||
--color-prettylights-syntax-markup-ignored-text: #eaeef2;
|
||||
--color-prettylights-syntax-markup-ignored-bg: #0550ae;
|
||||
--color-prettylights-syntax-meta-diff-range: #8250df;
|
||||
--color-prettylights-syntax-brackethighlighter-angle: #57606a;
|
||||
--color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f;
|
||||
--color-prettylights-syntax-constant-other-reference-link: #0a3069;
|
||||
--color-codemirror-text: #24292f;
|
||||
--color-codemirror-bg: #ffffff;
|
||||
--color-codemirror-gutters-bg: #ffffff;
|
||||
--color-codemirror-guttermarker-text: #ffffff;
|
||||
--color-codemirror-guttermarker-subtle-text: #6e7781;
|
||||
--color-codemirror-linenumber-text: #57606a;
|
||||
--color-codemirror-cursor: #24292f;
|
||||
--color-codemirror-selection-bg: rgba(84,174,255,0.4);
|
||||
--color-codemirror-activeline-bg: rgba(234,238,242,0.5);
|
||||
--color-codemirror-matchingbracket-text: #24292f;
|
||||
--color-codemirror-lines-bg: #ffffff;
|
||||
--color-codemirror-syntax-comment: #24292f;
|
||||
--color-codemirror-syntax-constant: #0550ae;
|
||||
--color-codemirror-syntax-entity: #8250df;
|
||||
--color-codemirror-syntax-keyword: #cf222e;
|
||||
--color-codemirror-syntax-storage: #cf222e;
|
||||
--color-codemirror-syntax-string: #0a3069;
|
||||
--color-codemirror-syntax-support: #0550ae;
|
||||
--color-codemirror-syntax-variable: #953800;
|
||||
--color-checks-bg: #24292f;
|
||||
--color-checks-run-border-width: 0px;
|
||||
--color-checks-container-border-width: 0px;
|
||||
--color-checks-text-primary: #f6f8fa;
|
||||
--color-checks-text-secondary: #8c959f;
|
||||
--color-checks-text-link: #54aeff;
|
||||
--color-checks-btn-icon: #afb8c1;
|
||||
--color-checks-btn-hover-icon: #f6f8fa;
|
||||
--color-checks-btn-hover-bg: rgba(255,255,255,0.125);
|
||||
--color-checks-input-text: #eaeef2;
|
||||
--color-checks-input-placeholder-text: #8c959f;
|
||||
--color-checks-input-focus-text: #8c959f;
|
||||
--color-checks-input-bg: #32383f;
|
||||
--color-checks-input-shadow: none;
|
||||
--color-checks-donut-error: #fa4549;
|
||||
--color-checks-donut-pending: #bf8700;
|
||||
--color-checks-donut-success: #2da44e;
|
||||
--color-checks-donut-neutral: #afb8c1;
|
||||
--color-checks-dropdown-text: #afb8c1;
|
||||
--color-checks-dropdown-bg: #32383f;
|
||||
--color-checks-dropdown-border: #424a53;
|
||||
--color-checks-dropdown-shadow: rgba(27,31,36,0.3);
|
||||
--color-checks-dropdown-hover-text: #f6f8fa;
|
||||
--color-checks-dropdown-hover-bg: #424a53;
|
||||
--color-checks-dropdown-btn-hover-text: #f6f8fa;
|
||||
--color-checks-dropdown-btn-hover-bg: #32383f;
|
||||
--color-checks-scrollbar-thumb-bg: #57606a;
|
||||
--color-checks-header-label-text: #d0d7de;
|
||||
--color-checks-header-label-open-text: #f6f8fa;
|
||||
--color-checks-header-border: #32383f;
|
||||
--color-checks-header-icon: #8c959f;
|
||||
--color-checks-line-text: #d0d7de;
|
||||
--color-checks-line-num-text: rgba(140,149,159,0.75);
|
||||
--color-checks-line-timestamp-text: #8c959f;
|
||||
--color-checks-line-hover-bg: #32383f;
|
||||
--color-checks-line-selected-bg: rgba(33,139,255,0.15);
|
||||
--color-checks-line-selected-num-text: #54aeff;
|
||||
--color-checks-line-dt-fm-text: #24292f;
|
||||
--color-checks-line-dt-fm-bg: #9a6700;
|
||||
--color-checks-gate-bg: rgba(125,78,0,0.15);
|
||||
--color-checks-gate-text: #d0d7de;
|
||||
--color-checks-gate-waiting-text: #afb8c1;
|
||||
--color-checks-step-header-open-bg: #32383f;
|
||||
--color-checks-step-error-text: #ff8182;
|
||||
--color-checks-step-warning-text: #d4a72c;
|
||||
--color-checks-logline-text: #8c959f;
|
||||
--color-checks-logline-num-text: rgba(140,149,159,0.75);
|
||||
--color-checks-logline-debug-text: #c297ff;
|
||||
--color-checks-logline-error-text: #d0d7de;
|
||||
--color-checks-logline-error-num-text: #ff8182;
|
||||
--color-checks-logline-error-bg: rgba(164,14,38,0.15);
|
||||
--color-checks-logline-warning-text: #d0d7de;
|
||||
--color-checks-logline-warning-num-text: #d4a72c;
|
||||
--color-checks-logline-warning-bg: rgba(125,78,0,0.15);
|
||||
--color-checks-logline-command-text: #54aeff;
|
||||
--color-checks-logline-section-text: #4ac26b;
|
||||
--color-checks-ansi-black: #24292f;
|
||||
--color-checks-ansi-black-bright: #32383f;
|
||||
--color-checks-ansi-white: #d0d7de;
|
||||
--color-checks-ansi-white-bright: #d0d7de;
|
||||
--color-checks-ansi-gray: #8c959f;
|
||||
--color-checks-ansi-red: #ff8182;
|
||||
--color-checks-ansi-red-bright: #ffaba8;
|
||||
--color-checks-ansi-green: #4ac26b;
|
||||
--color-checks-ansi-green-bright: #6fdd8b;
|
||||
--color-checks-ansi-yellow: #d4a72c;
|
||||
--color-checks-ansi-yellow-bright: #eac54f;
|
||||
--color-checks-ansi-blue: #54aeff;
|
||||
--color-checks-ansi-blue-bright: #80ccff;
|
||||
--color-checks-ansi-magenta: #c297ff;
|
||||
--color-checks-ansi-magenta-bright: #d8b9ff;
|
||||
--color-checks-ansi-cyan: #76e3ea;
|
||||
--color-checks-ansi-cyan-bright: #b3f0ff;
|
||||
--color-project-header-bg: #24292f;
|
||||
--color-project-sidebar-bg: #ffffff;
|
||||
--color-project-gradient-in: #ffffff;
|
||||
--color-project-gradient-out: rgba(255,255,255,0);
|
||||
--color-mktg-success: rgba(36,146,67,1);
|
||||
--color-mktg-info: rgba(19,119,234,1);
|
||||
--color-mktg-bg-shade-gradient-top: rgba(27,31,36,0.065);
|
||||
--color-mktg-bg-shade-gradient-bottom: rgba(27,31,36,0);
|
||||
--color-mktg-btn-bg-top: hsla(228,82%,66%,1);
|
||||
--color-mktg-btn-bg-bottom: #4969ed;
|
||||
--color-mktg-btn-bg-overlay-top: hsla(228,74%,59%,1);
|
||||
--color-mktg-btn-bg-overlay-bottom: #3355e0;
|
||||
--color-mktg-btn-text: #ffffff;
|
||||
--color-mktg-btn-primary-bg-top: hsla(137,56%,46%,1);
|
||||
--color-mktg-btn-primary-bg-bottom: #2ea44f;
|
||||
--color-mktg-btn-primary-bg-overlay-top: hsla(134,60%,38%,1);
|
||||
--color-mktg-btn-primary-bg-overlay-bottom: #22863a;
|
||||
--color-mktg-btn-primary-text: #ffffff;
|
||||
--color-mktg-btn-enterprise-bg-top: hsla(249,100%,72%,1);
|
||||
--color-mktg-btn-enterprise-bg-bottom: #6f57ff;
|
||||
--color-mktg-btn-enterprise-bg-overlay-top: hsla(248,65%,63%,1);
|
||||
--color-mktg-btn-enterprise-bg-overlay-bottom: #614eda;
|
||||
--color-mktg-btn-enterprise-text: #ffffff;
|
||||
--color-mktg-btn-outline-text: #4969ed;
|
||||
--color-mktg-btn-outline-border: rgba(73,105,237,0.3);
|
||||
--color-mktg-btn-outline-hover-text: #3355e0;
|
||||
--color-mktg-btn-outline-hover-border: rgba(51,85,224,0.5);
|
||||
--color-mktg-btn-outline-focus-border: #4969ed;
|
||||
--color-mktg-btn-outline-focus-border-inset: rgba(73,105,237,0.5);
|
||||
--color-mktg-btn-dark-text: #ffffff;
|
||||
--color-mktg-btn-dark-border: rgba(255,255,255,0.3);
|
||||
--color-mktg-btn-dark-hover-text: #ffffff;
|
||||
--color-mktg-btn-dark-hover-border: rgba(255,255,255,0.5);
|
||||
--color-mktg-btn-dark-focus-border: #ffffff;
|
||||
--color-mktg-btn-dark-focus-border-inset: rgba(255,255,255,0.5);
|
||||
--color-avatar-bg: #ffffff;
|
||||
--color-avatar-border: rgba(27,31,36,0.15);
|
||||
--color-avatar-stack-fade: #afb8c1;
|
||||
--color-avatar-stack-fade-more: #d0d7de;
|
||||
--color-avatar-child-shadow: -2px -2px 0 rgba(255,255,255,0.8);
|
||||
--color-topic-tag-border: rgba(0,0,0,0);
|
||||
--color-select-menu-backdrop-border: rgba(0,0,0,0);
|
||||
--color-select-menu-tap-highlight: rgba(175,184,193,0.5);
|
||||
--color-select-menu-tap-focus-bg: #b6e3ff;
|
||||
--color-overlay-shadow: 0 1px 3px rgba(27,31,36,0.12), 0 8px 24px rgba(66,74,83,0.12);
|
||||
--color-header-text: rgba(255,255,255,0.7);
|
||||
--color-header-bg: #24292f;
|
||||
--color-header-logo: #ffffff;
|
||||
--color-header-search-bg: #24292f;
|
||||
--color-header-search-border: #57606a;
|
||||
--color-sidenav-selected-bg: #ffffff;
|
||||
--color-menu-bg-active: rgba(0,0,0,0);
|
||||
--color-control-transparent-bg-hover: #818b981a;
|
||||
--color-input-disabled-bg: rgba(175,184,193,0.2);
|
||||
--color-timeline-badge-bg: #eaeef2;
|
||||
--color-ansi-black: #24292f;
|
||||
--color-ansi-black-bright: #57606a;
|
||||
--color-ansi-white: #6e7781;
|
||||
--color-ansi-white-bright: #8c959f;
|
||||
--color-ansi-gray: #6e7781;
|
||||
--color-ansi-red: #cf222e;
|
||||
--color-ansi-red-bright: #a40e26;
|
||||
--color-ansi-green: #116329;
|
||||
--color-ansi-green-bright: #1a7f37;
|
||||
--color-ansi-yellow: #4d2d00;
|
||||
--color-ansi-yellow-bright: #633c01;
|
||||
--color-ansi-blue: #0969da;
|
||||
--color-ansi-blue-bright: #218bff;
|
||||
--color-ansi-magenta: #8250df;
|
||||
--color-ansi-magenta-bright: #a475f9;
|
||||
--color-ansi-cyan: #1b7c83;
|
||||
--color-ansi-cyan-bright: #3192aa;
|
||||
--color-btn-text: #24292f;
|
||||
--color-btn-bg: #f6f8fa;
|
||||
--color-btn-border: rgba(27,31,36,0.15);
|
||||
--color-btn-shadow: 0 1px 0 rgba(27,31,36,0.04);
|
||||
--color-btn-inset-shadow: inset 0 1px 0 rgba(255,255,255,0.25);
|
||||
--color-btn-hover-bg: #f3f4f6;
|
||||
--color-btn-hover-border: rgba(27,31,36,0.15);
|
||||
--color-btn-active-bg: hsla(220,14%,93%,1);
|
||||
--color-btn-active-border: rgba(27,31,36,0.15);
|
||||
--color-btn-selected-bg: hsla(220,14%,94%,1);
|
||||
--color-btn-focus-bg: #f6f8fa;
|
||||
--color-btn-focus-border: rgba(27,31,36,0.15);
|
||||
--color-btn-focus-shadow: 0 0 0 3px rgba(9,105,218,0.3);
|
||||
--color-btn-shadow-active: inset 0 0.15em 0.3em rgba(27,31,36,0.15);
|
||||
--color-btn-shadow-input-focus: 0 0 0 0.2em rgba(9,105,218,0.3);
|
||||
--color-btn-counter-bg: rgba(27,31,36,0.08);
|
||||
--color-btn-primary-text: #ffffff;
|
||||
--color-btn-primary-bg: #2da44e;
|
||||
--color-btn-primary-border: rgba(27,31,36,0.15);
|
||||
--color-btn-primary-shadow: 0 1px 0 rgba(27,31,36,0.1);
|
||||
--color-btn-primary-inset-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
|
||||
--color-btn-primary-hover-bg: #2c974b;
|
||||
--color-btn-primary-hover-border: rgba(27,31,36,0.15);
|
||||
--color-btn-primary-selected-bg: hsla(137,55%,36%,1);
|
||||
--color-btn-primary-selected-shadow: inset 0 1px 0 rgba(0,45,17,0.2);
|
||||
--color-btn-primary-disabled-text: rgba(255,255,255,0.8);
|
||||
--color-btn-primary-disabled-bg: #94d3a2;
|
||||
--color-btn-primary-disabled-border: rgba(27,31,36,0.15);
|
||||
--color-btn-primary-focus-bg: #2da44e;
|
||||
--color-btn-primary-focus-border: rgba(27,31,36,0.15);
|
||||
--color-btn-primary-focus-shadow: 0 0 0 3px rgba(45,164,78,0.4);
|
||||
--color-btn-primary-icon: rgba(255,255,255,0.8);
|
||||
--color-btn-primary-counter-bg: rgba(255,255,255,0.2);
|
||||
--color-btn-outline-text: #0969da;
|
||||
--color-btn-outline-hover-text: #ffffff;
|
||||
--color-btn-outline-hover-bg: #0969da;
|
||||
--color-btn-outline-hover-border: rgba(27,31,36,0.15);
|
||||
--color-btn-outline-hover-shadow: 0 1px 0 rgba(27,31,36,0.1);
|
||||
--color-btn-outline-hover-inset-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
|
||||
--color-btn-outline-hover-counter-bg: rgba(255,255,255,0.2);
|
||||
--color-btn-outline-selected-text: #ffffff;
|
||||
--color-btn-outline-selected-bg: hsla(212,92%,42%,1);
|
||||
--color-btn-outline-selected-border: rgba(27,31,36,0.15);
|
||||
--color-btn-outline-selected-shadow: inset 0 1px 0 rgba(0,33,85,0.2);
|
||||
--color-btn-outline-disabled-text: rgba(9,105,218,0.5);
|
||||
--color-btn-outline-disabled-bg: #f6f8fa;
|
||||
--color-btn-outline-disabled-counter-bg: rgba(9,105,218,0.05);
|
||||
--color-btn-outline-focus-border: rgba(27,31,36,0.15);
|
||||
--color-btn-outline-focus-shadow: 0 0 0 3px rgba(5,80,174,0.4);
|
||||
--color-btn-outline-counter-bg: rgba(9,105,218,0.1);
|
||||
--color-btn-danger-text: #cf222e;
|
||||
--color-btn-danger-hover-text: #ffffff;
|
||||
--color-btn-danger-hover-bg: #a40e26;
|
||||
--color-btn-danger-hover-border: rgba(27,31,36,0.15);
|
||||
--color-btn-danger-hover-shadow: 0 1px 0 rgba(27,31,36,0.1);
|
||||
--color-btn-danger-hover-inset-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
|
||||
--color-btn-danger-hover-counter-bg: rgba(255,255,255,0.2);
|
||||
--color-btn-danger-selected-text: #ffffff;
|
||||
--color-btn-danger-selected-bg: hsla(356,72%,44%,1);
|
||||
--color-btn-danger-selected-border: rgba(27,31,36,0.15);
|
||||
--color-btn-danger-selected-shadow: inset 0 1px 0 rgba(76,0,20,0.2);
|
||||
--color-btn-danger-disabled-text: rgba(207,34,46,0.5);
|
||||
--color-btn-danger-disabled-bg: #f6f8fa;
|
||||
--color-btn-danger-disabled-counter-bg: rgba(207,34,46,0.05);
|
||||
--color-btn-danger-focus-border: rgba(27,31,36,0.15);
|
||||
--color-btn-danger-focus-shadow: 0 0 0 3px rgba(164,14,38,0.4);
|
||||
--color-btn-danger-counter-bg: rgba(207,34,46,0.1);
|
||||
--color-btn-danger-icon: #cf222e;
|
||||
--color-btn-danger-hover-icon: #ffffff;
|
||||
--color-underlinenav-icon: #6e7781;
|
||||
--color-underlinenav-border-hover: rgba(175,184,193,0.2);
|
||||
--color-fg-default: #24292f;
|
||||
--color-fg-muted: #57606a;
|
||||
--color-fg-subtle: #6e7781;
|
||||
--color-fg-on-emphasis: #ffffff;
|
||||
--color-canvas-default: #ffffff;
|
||||
--color-canvas-overlay: #ffffff;
|
||||
--color-canvas-inset: #f6f8fa;
|
||||
--color-canvas-subtle: #f6f8fa;
|
||||
--color-border-default: #d0d7de;
|
||||
--color-border-muted: hsla(210,18%,87%,1);
|
||||
--color-border-subtle: rgba(27,31,36,0.15);
|
||||
--color-shadow-small: 0 1px 0 rgba(27,31,36,0.04);
|
||||
--color-shadow-medium: 0 3px 6px rgba(140,149,159,0.15);
|
||||
--color-shadow-large: 0 8px 24px rgba(140,149,159,0.2);
|
||||
--color-shadow-extra-large: 0 12px 28px rgba(140,149,159,0.3);
|
||||
--color-neutral-emphasis-plus: #24292f;
|
||||
--color-neutral-emphasis: #6e7781;
|
||||
--color-neutral-muted: rgba(175,184,193,0.2);
|
||||
--color-neutral-subtle: rgba(234,238,242,0.5);
|
||||
--color-accent-fg: #0969da;
|
||||
--color-accent-emphasis: #0969da;
|
||||
--color-accent-muted: rgba(84,174,255,0.4);
|
||||
--color-accent-subtle: #ddf4ff;
|
||||
--color-success-fg: #1a7f37;
|
||||
--color-success-emphasis: #2da44e;
|
||||
--color-success-muted: rgba(74,194,107,0.4);
|
||||
--color-success-subtle: #dafbe1;
|
||||
--color-attention-fg: #9a6700;
|
||||
--color-attention-emphasis: #bf8700;
|
||||
--color-attention-muted: rgba(212,167,44,0.4);
|
||||
--color-attention-subtle: #fff8c5;
|
||||
--color-severe-fg: #bc4c00;
|
||||
--color-severe-emphasis: #bc4c00;
|
||||
--color-severe-muted: rgba(251,143,68,0.4);
|
||||
--color-severe-subtle: #fff1e5;
|
||||
--color-danger-fg: #cf222e;
|
||||
--color-danger-emphasis: #cf222e;
|
||||
--color-danger-muted: rgba(255,129,130,0.4);
|
||||
--color-danger-subtle: #FFEBE9;
|
||||
--color-done-fg: #8250df;
|
||||
--color-done-emphasis: #8250df;
|
||||
--color-done-muted: rgba(194,151,255,0.4);
|
||||
--color-done-subtle: #fbefff;
|
||||
--color-sponsors-fg: #bf3989;
|
||||
--color-sponsors-emphasis: #bf3989;
|
||||
--color-sponsors-muted: rgba(255,128,200,0.4);
|
||||
--color-sponsors-subtle: #ffeff7;
|
||||
--color-primer-canvas-backdrop: rgba(27,31,36,0.5);
|
||||
--color-primer-canvas-sticky: rgba(255,255,255,0.95);
|
||||
--color-primer-border-active: #FD8C73;
|
||||
--color-primer-border-contrast: rgba(27,31,36,0.1);
|
||||
--color-primer-shadow-highlight: inset 0 1px 0 rgba(255,255,255,0.25);
|
||||
--color-primer-shadow-inset: inset 0 1px 0 rgba(208,215,222,0.2);
|
||||
--color-primer-shadow-focus: 0 0 0 3px rgba(9,105,218,0.3);
|
||||
--color-scale-black: #1b1f24;
|
||||
--color-scale-white: #ffffff;
|
||||
--color-scale-gray-0: #f6f8fa;
|
||||
--color-scale-gray-1: #eaeef2;
|
||||
--color-scale-gray-2: #d0d7de;
|
||||
--color-scale-gray-3: #afb8c1;
|
||||
--color-scale-gray-4: #8c959f;
|
||||
--color-scale-gray-5: #6e7781;
|
||||
--color-scale-gray-6: #57606a;
|
||||
--color-scale-gray-7: #424a53;
|
||||
--color-scale-gray-8: #32383f;
|
||||
--color-scale-gray-9: #24292f;
|
||||
--color-scale-blue-0: #ddf4ff;
|
||||
--color-scale-blue-1: #b6e3ff;
|
||||
--color-scale-blue-2: #80ccff;
|
||||
--color-scale-blue-3: #54aeff;
|
||||
--color-scale-blue-4: #218bff;
|
||||
--color-scale-blue-5: #0969da;
|
||||
--color-scale-blue-6: #0550ae;
|
||||
--color-scale-blue-7: #033d8b;
|
||||
--color-scale-blue-8: #0a3069;
|
||||
--color-scale-blue-9: #002155;
|
||||
--color-scale-green-0: #dafbe1;
|
||||
--color-scale-green-1: #aceebb;
|
||||
--color-scale-green-2: #6fdd8b;
|
||||
--color-scale-green-3: #4ac26b;
|
||||
--color-scale-green-4: #2da44e;
|
||||
--color-scale-green-5: #1a7f37;
|
||||
--color-scale-green-6: #116329;
|
||||
--color-scale-green-7: #044f1e;
|
||||
--color-scale-green-8: #003d16;
|
||||
--color-scale-green-9: #002d11;
|
||||
--color-scale-yellow-0: #fff8c5;
|
||||
--color-scale-yellow-1: #fae17d;
|
||||
--color-scale-yellow-2: #eac54f;
|
||||
--color-scale-yellow-3: #d4a72c;
|
||||
--color-scale-yellow-4: #bf8700;
|
||||
--color-scale-yellow-5: #9a6700;
|
||||
--color-scale-yellow-6: #7d4e00;
|
||||
--color-scale-yellow-7: #633c01;
|
||||
--color-scale-yellow-8: #4d2d00;
|
||||
--color-scale-yellow-9: #3b2300;
|
||||
--color-scale-orange-0: #fff1e5;
|
||||
--color-scale-orange-1: #ffd8b5;
|
||||
--color-scale-orange-2: #ffb77c;
|
||||
--color-scale-orange-3: #fb8f44;
|
||||
--color-scale-orange-4: #e16f24;
|
||||
--color-scale-orange-5: #bc4c00;
|
||||
--color-scale-orange-6: #953800;
|
||||
--color-scale-orange-7: #762c00;
|
||||
--color-scale-orange-8: #5c2200;
|
||||
--color-scale-orange-9: #471700;
|
||||
--color-scale-red-0: #FFEBE9;
|
||||
--color-scale-red-1: #ffcecb;
|
||||
--color-scale-red-2: #ffaba8;
|
||||
--color-scale-red-3: #ff8182;
|
||||
--color-scale-red-4: #fa4549;
|
||||
--color-scale-red-5: #cf222e;
|
||||
--color-scale-red-6: #a40e26;
|
||||
--color-scale-red-7: #82071e;
|
||||
--color-scale-red-8: #660018;
|
||||
--color-scale-red-9: #4c0014;
|
||||
--color-scale-purple-0: #fbefff;
|
||||
--color-scale-purple-1: #ecd8ff;
|
||||
--color-scale-purple-2: #d8b9ff;
|
||||
--color-scale-purple-3: #c297ff;
|
||||
--color-scale-purple-4: #a475f9;
|
||||
--color-scale-purple-5: #8250df;
|
||||
--color-scale-purple-6: #6639ba;
|
||||
--color-scale-purple-7: #512a97;
|
||||
--color-scale-purple-8: #3e1f79;
|
||||
--color-scale-purple-9: #2e1461;
|
||||
--color-scale-pink-0: #ffeff7;
|
||||
--color-scale-pink-1: #ffd3eb;
|
||||
--color-scale-pink-2: #ffadda;
|
||||
--color-scale-pink-3: #ff80c8;
|
||||
--color-scale-pink-4: #e85aad;
|
||||
--color-scale-pink-5: #bf3989;
|
||||
--color-scale-pink-6: #99286e;
|
||||
--color-scale-pink-7: #772057;
|
||||
--color-scale-pink-8: #611347;
|
||||
--color-scale-pink-9: #4d0336;
|
||||
--color-scale-coral-0: #FFF0EB;
|
||||
--color-scale-coral-1: #FFD6CC;
|
||||
--color-scale-coral-2: #FFB4A1;
|
||||
--color-scale-coral-3: #FD8C73;
|
||||
--color-scale-coral-4: #EC6547;
|
||||
--color-scale-coral-5: #C4432B;
|
||||
--color-scale-coral-6: #9E2F1C;
|
||||
--color-scale-coral-7: #801F0F;
|
||||
--color-scale-coral-8: #691105;
|
||||
--color-scale-coral-9: #510901
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-canvas-default-transparent: rgba(13,17,23,0);
|
||||
--color-marketing-icon-primary: #79c0ff;
|
||||
--color-marketing-icon-secondary: #1f6feb;
|
||||
--color-diff-blob-addition-num-text: #c9d1d9;
|
||||
--color-diff-blob-addition-fg: #c9d1d9;
|
||||
--color-diff-blob-addition-num-bg: rgba(63,185,80,0.3);
|
||||
--color-diff-blob-addition-line-bg: rgba(46,160,67,0.15);
|
||||
--color-diff-blob-addition-word-bg: rgba(46,160,67,0.4);
|
||||
--color-diff-blob-deletion-num-text: #c9d1d9;
|
||||
--color-diff-blob-deletion-fg: #c9d1d9;
|
||||
--color-diff-blob-deletion-num-bg: rgba(248,81,73,0.3);
|
||||
--color-diff-blob-deletion-line-bg: rgba(248,81,73,0.15);
|
||||
--color-diff-blob-deletion-word-bg: rgba(248,81,73,0.4);
|
||||
--color-diff-blob-hunk-num-bg: rgba(56,139,253,0.4);
|
||||
--color-diff-blob-expander-icon: #8b949e;
|
||||
--color-diff-blob-selected-line-highlight-mix-blend-mode: screen;
|
||||
--color-diffstat-deletion-border: rgba(240,246,252,0.1);
|
||||
--color-diffstat-addition-border: rgba(240,246,252,0.1);
|
||||
--color-diffstat-addition-bg: #3fb950;
|
||||
--color-search-keyword-hl: rgba(210,153,34,0.4);
|
||||
--color-prettylights-syntax-comment: #8b949e;
|
||||
--color-prettylights-syntax-constant: #79c0ff;
|
||||
--color-prettylights-syntax-entity: #d2a8ff;
|
||||
--color-prettylights-syntax-storage-modifier-import: #c9d1d9;
|
||||
--color-prettylights-syntax-entity-tag: #7ee787;
|
||||
--color-prettylights-syntax-keyword: #ff7b72;
|
||||
--color-prettylights-syntax-string: #a5d6ff;
|
||||
--color-prettylights-syntax-variable: #ffa657;
|
||||
--color-prettylights-syntax-brackethighlighter-unmatched: #f85149;
|
||||
--color-prettylights-syntax-invalid-illegal-text: #f0f6fc;
|
||||
--color-prettylights-syntax-invalid-illegal-bg: #8e1519;
|
||||
--color-prettylights-syntax-carriage-return-text: #f0f6fc;
|
||||
--color-prettylights-syntax-carriage-return-bg: #b62324;
|
||||
--color-prettylights-syntax-string-regexp: #7ee787;
|
||||
--color-prettylights-syntax-markup-list: #f2cc60;
|
||||
--color-prettylights-syntax-markup-heading: #1f6feb;
|
||||
--color-prettylights-syntax-markup-italic: #c9d1d9;
|
||||
--color-prettylights-syntax-markup-bold: #c9d1d9;
|
||||
--color-prettylights-syntax-markup-deleted-text: #ffdcd7;
|
||||
--color-prettylights-syntax-markup-deleted-bg: #67060c;
|
||||
--color-prettylights-syntax-markup-inserted-text: #aff5b4;
|
||||
--color-prettylights-syntax-markup-inserted-bg: #033a16;
|
||||
--color-prettylights-syntax-markup-changed-text: #ffdfb6;
|
||||
--color-prettylights-syntax-markup-changed-bg: #5a1e02;
|
||||
--color-prettylights-syntax-markup-ignored-text: #c9d1d9;
|
||||
--color-prettylights-syntax-markup-ignored-bg: #1158c7;
|
||||
--color-prettylights-syntax-meta-diff-range: #d2a8ff;
|
||||
--color-prettylights-syntax-brackethighlighter-angle: #8b949e;
|
||||
--color-prettylights-syntax-sublimelinter-gutter-mark: #484f58;
|
||||
--color-prettylights-syntax-constant-other-reference-link: #a5d6ff;
|
||||
--color-codemirror-text: #c9d1d9;
|
||||
--color-codemirror-bg: #0d1117;
|
||||
--color-codemirror-gutters-bg: #0d1117;
|
||||
--color-codemirror-guttermarker-text: #0d1117;
|
||||
--color-codemirror-guttermarker-subtle-text: #484f58;
|
||||
--color-codemirror-linenumber-text: #8b949e;
|
||||
--color-codemirror-cursor: #c9d1d9;
|
||||
--color-codemirror-selection-bg: rgba(56,139,253,0.4);
|
||||
--color-codemirror-activeline-bg: rgba(110,118,129,0.1);
|
||||
--color-codemirror-matchingbracket-text: #c9d1d9;
|
||||
--color-codemirror-lines-bg: #0d1117;
|
||||
--color-codemirror-syntax-comment: #8b949e;
|
||||
--color-codemirror-syntax-constant: #79c0ff;
|
||||
--color-codemirror-syntax-entity: #d2a8ff;
|
||||
--color-codemirror-syntax-keyword: #ff7b72;
|
||||
--color-codemirror-syntax-storage: #ff7b72;
|
||||
--color-codemirror-syntax-string: #a5d6ff;
|
||||
--color-codemirror-syntax-support: #79c0ff;
|
||||
--color-codemirror-syntax-variable: #ffa657;
|
||||
--color-checks-bg: #010409;
|
||||
--color-checks-run-border-width: 1px;
|
||||
--color-checks-container-border-width: 1px;
|
||||
--color-checks-text-primary: #c9d1d9;
|
||||
--color-checks-text-secondary: #8b949e;
|
||||
--color-checks-text-link: #58a6ff;
|
||||
--color-checks-btn-icon: #8b949e;
|
||||
--color-checks-btn-hover-icon: #c9d1d9;
|
||||
--color-checks-btn-hover-bg: rgba(110,118,129,0.1);
|
||||
--color-checks-input-text: #8b949e;
|
||||
--color-checks-input-placeholder-text: #484f58;
|
||||
--color-checks-input-focus-text: #c9d1d9;
|
||||
--color-checks-input-bg: #161b22;
|
||||
--color-checks-input-shadow: none;
|
||||
--color-checks-donut-error: #f85149;
|
||||
--color-checks-donut-pending: #d29922;
|
||||
--color-checks-donut-success: #2ea043;
|
||||
--color-checks-donut-neutral: #8b949e;
|
||||
--color-checks-dropdown-text: #c9d1d9;
|
||||
--color-checks-dropdown-bg: #161b22;
|
||||
--color-checks-dropdown-border: #30363d;
|
||||
--color-checks-dropdown-shadow: rgba(1,4,9,0.3);
|
||||
--color-checks-dropdown-hover-text: #c9d1d9;
|
||||
--color-checks-dropdown-hover-bg: rgba(110,118,129,0.1);
|
||||
--color-checks-dropdown-btn-hover-text: #c9d1d9;
|
||||
--color-checks-dropdown-btn-hover-bg: rgba(110,118,129,0.1);
|
||||
--color-checks-scrollbar-thumb-bg: rgba(110,118,129,0.4);
|
||||
--color-checks-header-label-text: #8b949e;
|
||||
--color-checks-header-label-open-text: #c9d1d9;
|
||||
--color-checks-header-border: #21262d;
|
||||
--color-checks-header-icon: #8b949e;
|
||||
--color-checks-line-text: #8b949e;
|
||||
--color-checks-line-num-text: #484f58;
|
||||
--color-checks-line-timestamp-text: #484f58;
|
||||
--color-checks-line-hover-bg: rgba(110,118,129,0.1);
|
||||
--color-checks-line-selected-bg: rgba(56,139,253,0.15);
|
||||
--color-checks-line-selected-num-text: #58a6ff;
|
||||
--color-checks-line-dt-fm-text: #f0f6fc;
|
||||
--color-checks-line-dt-fm-bg: #9e6a03;
|
||||
--color-checks-gate-bg: rgba(187,128,9,0.15);
|
||||
--color-checks-gate-text: #8b949e;
|
||||
--color-checks-gate-waiting-text: #d29922;
|
||||
--color-checks-step-header-open-bg: #161b22;
|
||||
--color-checks-step-error-text: #f85149;
|
||||
--color-checks-step-warning-text: #d29922;
|
||||
--color-checks-logline-text: #8b949e;
|
||||
--color-checks-logline-num-text: #484f58;
|
||||
--color-checks-logline-debug-text: #a371f7;
|
||||
--color-checks-logline-error-text: #8b949e;
|
||||
--color-checks-logline-error-num-text: #484f58;
|
||||
--color-checks-logline-error-bg: rgba(248,81,73,0.15);
|
||||
--color-checks-logline-warning-text: #8b949e;
|
||||
--color-checks-logline-warning-num-text: #d29922;
|
||||
--color-checks-logline-warning-bg: rgba(187,128,9,0.15);
|
||||
--color-checks-logline-command-text: #58a6ff;
|
||||
--color-checks-logline-section-text: #3fb950;
|
||||
--color-checks-ansi-black: #0d1117;
|
||||
--color-checks-ansi-black-bright: #161b22;
|
||||
--color-checks-ansi-white: #b1bac4;
|
||||
--color-checks-ansi-white-bright: #b1bac4;
|
||||
--color-checks-ansi-gray: #6e7681;
|
||||
--color-checks-ansi-red: #ff7b72;
|
||||
--color-checks-ansi-red-bright: #ffa198;
|
||||
--color-checks-ansi-green: #3fb950;
|
||||
--color-checks-ansi-green-bright: #56d364;
|
||||
--color-checks-ansi-yellow: #d29922;
|
||||
--color-checks-ansi-yellow-bright: #e3b341;
|
||||
--color-checks-ansi-blue: #58a6ff;
|
||||
--color-checks-ansi-blue-bright: #79c0ff;
|
||||
--color-checks-ansi-magenta: #bc8cff;
|
||||
--color-checks-ansi-magenta-bright: #d2a8ff;
|
||||
--color-checks-ansi-cyan: #76e3ea;
|
||||
--color-checks-ansi-cyan-bright: #b3f0ff;
|
||||
--color-project-header-bg: #0d1117;
|
||||
--color-project-sidebar-bg: #161b22;
|
||||
--color-project-gradient-in: #161b22;
|
||||
--color-project-gradient-out: rgba(22,27,34,0);
|
||||
--color-mktg-success: rgba(41,147,61,1);
|
||||
--color-mktg-info: rgba(42,123,243,1);
|
||||
--color-mktg-bg-shade-gradient-top: rgba(1,4,9,0.065);
|
||||
--color-mktg-bg-shade-gradient-bottom: rgba(1,4,9,0);
|
||||
--color-mktg-btn-bg-top: hsla(228,82%,66%,1);
|
||||
--color-mktg-btn-bg-bottom: #4969ed;
|
||||
--color-mktg-btn-bg-overlay-top: hsla(228,74%,59%,1);
|
||||
--color-mktg-btn-bg-overlay-bottom: #3355e0;
|
||||
--color-mktg-btn-text: #f0f6fc;
|
||||
--color-mktg-btn-primary-bg-top: hsla(137,56%,46%,1);
|
||||
--color-mktg-btn-primary-bg-bottom: #2ea44f;
|
||||
--color-mktg-btn-primary-bg-overlay-top: hsla(134,60%,38%,1);
|
||||
--color-mktg-btn-primary-bg-overlay-bottom: #22863a;
|
||||
--color-mktg-btn-primary-text: #f0f6fc;
|
||||
--color-mktg-btn-enterprise-bg-top: hsla(249,100%,72%,1);
|
||||
--color-mktg-btn-enterprise-bg-bottom: #6f57ff;
|
||||
--color-mktg-btn-enterprise-bg-overlay-top: hsla(248,65%,63%,1);
|
||||
--color-mktg-btn-enterprise-bg-overlay-bottom: #614eda;
|
||||
--color-mktg-btn-enterprise-text: #f0f6fc;
|
||||
--color-mktg-btn-outline-text: #f0f6fc;
|
||||
--color-mktg-btn-outline-border: rgba(240,246,252,0.3);
|
||||
--color-mktg-btn-outline-hover-text: #f0f6fc;
|
||||
--color-mktg-btn-outline-hover-border: rgba(240,246,252,0.5);
|
||||
--color-mktg-btn-outline-focus-border: #f0f6fc;
|
||||
--color-mktg-btn-outline-focus-border-inset: rgba(240,246,252,0.5);
|
||||
--color-mktg-btn-dark-text: #f0f6fc;
|
||||
--color-mktg-btn-dark-border: rgba(240,246,252,0.3);
|
||||
--color-mktg-btn-dark-hover-text: #f0f6fc;
|
||||
--color-mktg-btn-dark-hover-border: rgba(240,246,252,0.5);
|
||||
--color-mktg-btn-dark-focus-border: #f0f6fc;
|
||||
--color-mktg-btn-dark-focus-border-inset: rgba(240,246,252,0.5);
|
||||
--color-avatar-bg: rgba(240,246,252,0.1);
|
||||
--color-avatar-border: rgba(240,246,252,0.1);
|
||||
--color-avatar-stack-fade: #30363d;
|
||||
--color-avatar-stack-fade-more: #21262d;
|
||||
--color-avatar-child-shadow: -2px -2px 0 #0d1117;
|
||||
--color-topic-tag-border: rgba(0,0,0,0);
|
||||
--color-select-menu-backdrop-border: #484f58;
|
||||
--color-select-menu-tap-highlight: rgba(48,54,61,0.5);
|
||||
--color-select-menu-tap-focus-bg: #0c2d6b;
|
||||
--color-overlay-shadow: 0 0 0 1px #30363d, 0 16px 32px rgba(1,4,9,0.85);
|
||||
--color-header-text: rgba(240,246,252,0.7);
|
||||
--color-header-bg: #161b22;
|
||||
--color-header-logo: #f0f6fc;
|
||||
--color-header-search-bg: #0d1117;
|
||||
--color-header-search-border: #30363d;
|
||||
--color-sidenav-selected-bg: #21262d;
|
||||
--color-menu-bg-active: #161b22;
|
||||
--color-control-transparent-bg-hover: #656c7633;
|
||||
--color-input-disabled-bg: rgba(110,118,129,0);
|
||||
--color-timeline-badge-bg: #21262d;
|
||||
--color-ansi-black: #484f58;
|
||||
--color-ansi-black-bright: #6e7681;
|
||||
--color-ansi-white: #b1bac4;
|
||||
--color-ansi-white-bright: #f0f6fc;
|
||||
--color-ansi-gray: #6e7681;
|
||||
--color-ansi-red: #ff7b72;
|
||||
--color-ansi-red-bright: #ffa198;
|
||||
--color-ansi-green: #3fb950;
|
||||
--color-ansi-green-bright: #56d364;
|
||||
--color-ansi-yellow: #d29922;
|
||||
--color-ansi-yellow-bright: #e3b341;
|
||||
--color-ansi-blue: #58a6ff;
|
||||
--color-ansi-blue-bright: #79c0ff;
|
||||
--color-ansi-magenta: #bc8cff;
|
||||
--color-ansi-magenta-bright: #d2a8ff;
|
||||
--color-ansi-cyan: #39c5cf;
|
||||
--color-ansi-cyan-bright: #56d4dd;
|
||||
--color-btn-text: #c9d1d9;
|
||||
--color-btn-bg: #21262d;
|
||||
--color-btn-border: rgba(240,246,252,0.1);
|
||||
--color-btn-shadow: 0 0 transparent;
|
||||
--color-btn-inset-shadow: 0 0 transparent;
|
||||
--color-btn-hover-bg: #30363d;
|
||||
--color-btn-hover-border: #8b949e;
|
||||
--color-btn-active-bg: hsla(212,12%,18%,1);
|
||||
--color-btn-active-border: #6e7681;
|
||||
--color-btn-selected-bg: #161b22;
|
||||
--color-btn-focus-bg: #21262d;
|
||||
--color-btn-focus-border: #8b949e;
|
||||
--color-btn-focus-shadow: 0 0 0 3px rgba(139,148,158,0.3);
|
||||
--color-btn-shadow-active: inset 0 0.15em 0.3em rgba(1,4,9,0.15);
|
||||
--color-btn-shadow-input-focus: 0 0 0 0.2em rgba(31,111,235,0.3);
|
||||
--color-btn-counter-bg: #30363d;
|
||||
--color-btn-primary-text: #ffffff;
|
||||
--color-btn-primary-bg: #238636;
|
||||
--color-btn-primary-border: rgba(240,246,252,0.1);
|
||||
--color-btn-primary-shadow: 0 0 transparent;
|
||||
--color-btn-primary-inset-shadow: 0 0 transparent;
|
||||
--color-btn-primary-hover-bg: #2ea043;
|
||||
--color-btn-primary-hover-border: rgba(240,246,252,0.1);
|
||||
--color-btn-primary-selected-bg: #238636;
|
||||
--color-btn-primary-selected-shadow: 0 0 transparent;
|
||||
--color-btn-primary-disabled-text: rgba(240,246,252,0.5);
|
||||
--color-btn-primary-disabled-bg: rgba(35,134,54,0.6);
|
||||
--color-btn-primary-disabled-border: rgba(240,246,252,0.1);
|
||||
--color-btn-primary-focus-bg: #238636;
|
||||
--color-btn-primary-focus-border: rgba(240,246,252,0.1);
|
||||
--color-btn-primary-focus-shadow: 0 0 0 3px rgba(46,164,79,0.4);
|
||||
--color-btn-primary-icon: #f0f6fc;
|
||||
--color-btn-primary-counter-bg: rgba(240,246,252,0.2);
|
||||
--color-btn-outline-text: #58a6ff;
|
||||
--color-btn-outline-hover-text: #58a6ff;
|
||||
--color-btn-outline-hover-bg: #30363d;
|
||||
--color-btn-outline-hover-border: rgba(240,246,252,0.1);
|
||||
--color-btn-outline-hover-shadow: 0 1px 0 rgba(1,4,9,0.1);
|
||||
--color-btn-outline-hover-inset-shadow: inset 0 1px 0 rgba(240,246,252,0.03);
|
||||
--color-btn-outline-hover-counter-bg: rgba(240,246,252,0.2);
|
||||
--color-btn-outline-selected-text: #f0f6fc;
|
||||
--color-btn-outline-selected-bg: #0d419d;
|
||||
--color-btn-outline-selected-border: rgba(240,246,252,0.1);
|
||||
--color-btn-outline-selected-shadow: 0 0 transparent;
|
||||
--color-btn-outline-disabled-text: rgba(88,166,255,0.5);
|
||||
--color-btn-outline-disabled-bg: #0d1117;
|
||||
--color-btn-outline-disabled-counter-bg: rgba(31,111,235,0.05);
|
||||
--color-btn-outline-focus-border: rgba(240,246,252,0.1);
|
||||
--color-btn-outline-focus-shadow: 0 0 0 3px rgba(17,88,199,0.4);
|
||||
--color-btn-outline-counter-bg: rgba(31,111,235,0.1);
|
||||
--color-btn-danger-text: #f85149;
|
||||
--color-btn-danger-hover-text: #f0f6fc;
|
||||
--color-btn-danger-hover-bg: #da3633;
|
||||
--color-btn-danger-hover-border: #f85149;
|
||||
--color-btn-danger-hover-shadow: 0 0 transparent;
|
||||
--color-btn-danger-hover-inset-shadow: 0 0 transparent;
|
||||
--color-btn-danger-hover-icon: #f0f6fc;
|
||||
--color-btn-danger-hover-counter-bg: rgba(255,255,255,0.2);
|
||||
--color-btn-danger-selected-text: #ffffff;
|
||||
--color-btn-danger-selected-bg: #b62324;
|
||||
--color-btn-danger-selected-border: #ff7b72;
|
||||
--color-btn-danger-selected-shadow: 0 0 transparent;
|
||||
--color-btn-danger-disabled-text: rgba(248,81,73,0.5);
|
||||
--color-btn-danger-disabled-bg: #0d1117;
|
||||
--color-btn-danger-disabled-counter-bg: rgba(218,54,51,0.05);
|
||||
--color-btn-danger-focus-border: #f85149;
|
||||
--color-btn-danger-focus-shadow: 0 0 0 3px rgba(248,81,73,0.4);
|
||||
--color-btn-danger-counter-bg: rgba(218,54,51,0.1);
|
||||
--color-btn-danger-icon: #f85149;
|
||||
--color-underlinenav-icon: #484f58;
|
||||
--color-underlinenav-border-hover: rgba(110,118,129,0.4);
|
||||
--color-fg-default: #c9d1d9;
|
||||
--color-fg-muted: #8b949e;
|
||||
--color-fg-subtle: #484f58;
|
||||
--color-fg-on-emphasis: #f0f6fc;
|
||||
--color-canvas-default: #0d1117;
|
||||
--color-canvas-overlay: #161b22;
|
||||
--color-canvas-inset: #010409;
|
||||
--color-canvas-subtle: #161b22;
|
||||
--color-border-default: #30363d;
|
||||
--color-border-muted: #21262d;
|
||||
--color-border-subtle: rgba(240,246,252,0.1);
|
||||
--color-shadow-small: 0 0 transparent;
|
||||
--color-shadow-medium: 0 3px 6px #010409;
|
||||
--color-shadow-large: 0 8px 24px #010409;
|
||||
--color-shadow-extra-large: 0 12px 48px #010409;
|
||||
--color-neutral-emphasis-plus: #6e7681;
|
||||
--color-neutral-emphasis: #6e7681;
|
||||
--color-neutral-muted: rgba(110,118,129,0.4);
|
||||
--color-neutral-subtle: rgba(110,118,129,0.1);
|
||||
--color-accent-fg: #58a6ff;
|
||||
--color-accent-emphasis: #1f6feb;
|
||||
--color-accent-muted: rgba(56,139,253,0.4);
|
||||
--color-accent-subtle: rgba(56,139,253,0.15);
|
||||
--color-success-fg: #3fb950;
|
||||
--color-success-emphasis: #238636;
|
||||
--color-success-muted: rgba(46,160,67,0.4);
|
||||
--color-success-subtle: rgba(46,160,67,0.15);
|
||||
--color-attention-fg: #d29922;
|
||||
--color-attention-emphasis: #9e6a03;
|
||||
--color-attention-muted: rgba(187,128,9,0.4);
|
||||
--color-attention-subtle: rgba(187,128,9,0.15);
|
||||
--color-severe-fg: #db6d28;
|
||||
--color-severe-emphasis: #bd561d;
|
||||
--color-severe-muted: rgba(219,109,40,0.4);
|
||||
--color-severe-subtle: rgba(219,109,40,0.15);
|
||||
--color-danger-fg: #f85149;
|
||||
--color-danger-emphasis: #da3633;
|
||||
--color-danger-muted: rgba(248,81,73,0.4);
|
||||
--color-danger-subtle: rgba(248,81,73,0.15);
|
||||
--color-done-fg: #a371f7;
|
||||
--color-done-emphasis: #8957e5;
|
||||
--color-done-muted: rgba(163,113,247,0.4);
|
||||
--color-done-subtle: rgba(163,113,247,0.15);
|
||||
--color-sponsors-fg: #db61a2;
|
||||
--color-sponsors-emphasis: #bf4b8a;
|
||||
--color-sponsors-muted: rgba(219,97,162,0.4);
|
||||
--color-sponsors-subtle: rgba(219,97,162,0.15);
|
||||
--color-primer-canvas-backdrop: rgba(1,4,9,0.8);
|
||||
--color-primer-canvas-sticky: rgba(13,17,23,0.95);
|
||||
--color-primer-border-active: #F78166;
|
||||
--color-primer-border-contrast: rgba(240,246,252,0.2);
|
||||
--color-primer-shadow-highlight: 0 0 transparent;
|
||||
--color-primer-shadow-inset: 0 0 transparent;
|
||||
--color-primer-shadow-focus: 0 0 0 3px #0c2d6b;
|
||||
--color-scale-black: #010409;
|
||||
--color-scale-white: #f0f6fc;
|
||||
--color-scale-gray-0: #f0f6fc;
|
||||
--color-scale-gray-1: #c9d1d9;
|
||||
--color-scale-gray-2: #b1bac4;
|
||||
--color-scale-gray-3: #8b949e;
|
||||
--color-scale-gray-4: #6e7681;
|
||||
--color-scale-gray-5: #484f58;
|
||||
--color-scale-gray-6: #30363d;
|
||||
--color-scale-gray-7: #21262d;
|
||||
--color-scale-gray-8: #161b22;
|
||||
--color-scale-gray-9: #0d1117;
|
||||
--color-scale-blue-0: #cae8ff;
|
||||
--color-scale-blue-1: #a5d6ff;
|
||||
--color-scale-blue-2: #79c0ff;
|
||||
--color-scale-blue-3: #58a6ff;
|
||||
--color-scale-blue-4: #388bfd;
|
||||
--color-scale-blue-5: #1f6feb;
|
||||
--color-scale-blue-6: #1158c7;
|
||||
--color-scale-blue-7: #0d419d;
|
||||
--color-scale-blue-8: #0c2d6b;
|
||||
--color-scale-blue-9: #051d4d;
|
||||
--color-scale-green-0: #aff5b4;
|
||||
--color-scale-green-1: #7ee787;
|
||||
--color-scale-green-2: #56d364;
|
||||
--color-scale-green-3: #3fb950;
|
||||
--color-scale-green-4: #2ea043;
|
||||
--color-scale-green-5: #238636;
|
||||
--color-scale-green-6: #196c2e;
|
||||
--color-scale-green-7: #0f5323;
|
||||
--color-scale-green-8: #033a16;
|
||||
--color-scale-green-9: #04260f;
|
||||
--color-scale-yellow-0: #f8e3a1;
|
||||
--color-scale-yellow-1: #f2cc60;
|
||||
--color-scale-yellow-2: #e3b341;
|
||||
--color-scale-yellow-3: #d29922;
|
||||
--color-scale-yellow-4: #bb8009;
|
||||
--color-scale-yellow-5: #9e6a03;
|
||||
--color-scale-yellow-6: #845306;
|
||||
--color-scale-yellow-7: #693e00;
|
||||
--color-scale-yellow-8: #4b2900;
|
||||
--color-scale-yellow-9: #341a00;
|
||||
--color-scale-orange-0: #ffdfb6;
|
||||
--color-scale-orange-1: #ffc680;
|
||||
--color-scale-orange-2: #ffa657;
|
||||
--color-scale-orange-3: #f0883e;
|
||||
--color-scale-orange-4: #db6d28;
|
||||
--color-scale-orange-5: #bd561d;
|
||||
--color-scale-orange-6: #9b4215;
|
||||
--color-scale-orange-7: #762d0a;
|
||||
--color-scale-orange-8: #5a1e02;
|
||||
--color-scale-orange-9: #3d1300;
|
||||
--color-scale-red-0: #ffdcd7;
|
||||
--color-scale-red-1: #ffc1ba;
|
||||
--color-scale-red-2: #ffa198;
|
||||
--color-scale-red-3: #ff7b72;
|
||||
--color-scale-red-4: #f85149;
|
||||
--color-scale-red-5: #da3633;
|
||||
--color-scale-red-6: #b62324;
|
||||
--color-scale-red-7: #8e1519;
|
||||
--color-scale-red-8: #67060c;
|
||||
--color-scale-red-9: #490202;
|
||||
--color-scale-purple-0: #eddeff;
|
||||
--color-scale-purple-1: #e2c5ff;
|
||||
--color-scale-purple-2: #d2a8ff;
|
||||
--color-scale-purple-3: #bc8cff;
|
||||
--color-scale-purple-4: #a371f7;
|
||||
--color-scale-purple-5: #8957e5;
|
||||
--color-scale-purple-6: #6e40c9;
|
||||
--color-scale-purple-7: #553098;
|
||||
--color-scale-purple-8: #3c1e70;
|
||||
--color-scale-purple-9: #271052;
|
||||
--color-scale-pink-0: #ffdaec;
|
||||
--color-scale-pink-1: #ffbedd;
|
||||
--color-scale-pink-2: #ff9bce;
|
||||
--color-scale-pink-3: #f778ba;
|
||||
--color-scale-pink-4: #db61a2;
|
||||
--color-scale-pink-5: #bf4b8a;
|
||||
--color-scale-pink-6: #9e3670;
|
||||
--color-scale-pink-7: #7d2457;
|
||||
--color-scale-pink-8: #5e103e;
|
||||
--color-scale-pink-9: #42062a;
|
||||
--color-scale-coral-0: #FFDDD2;
|
||||
--color-scale-coral-1: #FFC2B2;
|
||||
--color-scale-coral-2: #FFA28B;
|
||||
--color-scale-coral-3: #F78166;
|
||||
--color-scale-coral-4: #EA6045;
|
||||
--color-scale-coral-5: #CF462D;
|
||||
--color-scale-coral-6: #AC3220;
|
||||
--color-scale-coral-7: #872012;
|
||||
--color-scale-coral-8: #640D04;
|
||||
--color-scale-coral-9: #460701
|
||||
}
|
||||
}
|
||||
262
packages/extension/src/ui/connect.css
Normal file
262
packages/extension/src/ui/connect.css
Normal file
@@ -0,0 +1,262 @@
|
||||
/*
|
||||
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;
|
||||
}
|
||||
|
||||
/* Auth token section */
|
||||
.auth-token-section {
|
||||
margin: 16px 0;
|
||||
padding: 16px;
|
||||
background-color: #f6f8fa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.auth-token-description {
|
||||
font-size: 12px;
|
||||
color: #656d76;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.auth-token-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background-color: #ffffff;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.auth-token-code {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: #1f2328;
|
||||
border: none;
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.auth-token-refresh {
|
||||
flex: none;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--color-fg-muted);
|
||||
background: transparent;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.auth-token-refresh svg {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-token-refresh:not(:disabled):hover {
|
||||
background-color: var(--color-btn-selected-bg);
|
||||
}
|
||||
29
packages/extension/src/ui/connect.html
Normal file
29
packages/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>
|
||||
263
packages/extension/src/ui/connect.tsx
Normal file
263
packages/extension/src/ui/connect.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* 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, { useCallback, useEffect, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Button, TabItem } from './tabItem';
|
||||
import { AuthTokenSection, getOrCreateAuthToken } from './authToken';
|
||||
|
||||
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 runAsync = async () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const relayUrl = params.get('mcpRelayUrl');
|
||||
|
||||
if (!relayUrl) {
|
||||
handleReject('Missing mcpRelayUrl parameter in URL.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const host = new URL(relayUrl).hostname;
|
||||
if (host !== '127.0.0.1' && host !== '[::1]') {
|
||||
handleReject(`MCP extension only allows loopback connections (127.0.0.1 or [::1]). Received host: ${host}`);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
handleReject(`Invalid mcpRelayUrl parameter in URL: ${relayUrl}. ${e}`);
|
||||
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;
|
||||
}
|
||||
|
||||
const expectedToken = getOrCreateAuthToken();
|
||||
const token = params.get('token');
|
||||
if (token === expectedToken) {
|
||||
await connectToMCPRelay(relayUrl);
|
||||
await handleConnectToTab();
|
||||
return;
|
||||
}
|
||||
if (token) {
|
||||
handleReject('Invalid token provided.');
|
||||
return;
|
||||
}
|
||||
|
||||
await 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 {
|
||||
await loadTabs();
|
||||
}
|
||||
};
|
||||
void runAsync();
|
||||
}, []);
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
{status?.type === 'connecting' && (
|
||||
<AuthTokenSection />
|
||||
)}
|
||||
|
||||
{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 />);
|
||||
}
|
||||
39
packages/extension/src/ui/copyToClipboard.css
Normal file
39
packages/extension/src/ui/copyToClipboard.css
Normal file
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
.copy-icon {
|
||||
flex: none;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--color-fg-muted);
|
||||
background: transparent;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.copy-icon svg {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.copy-icon:not(:disabled):hover {
|
||||
background-color: var(--color-btn-selected-bg);
|
||||
}
|
||||
54
packages/extension/src/ui/copyToClipboard.tsx
Normal file
54
packages/extension/src/ui/copyToClipboard.tsx
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 * as React from 'react';
|
||||
import * as icons from './icons';
|
||||
import './copyToClipboard.css';
|
||||
|
||||
type CopyToClipboardProps = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A copy to clipboard button.
|
||||
*/
|
||||
export const CopyToClipboard: React.FunctionComponent<CopyToClipboardProps> = ({ value }) => {
|
||||
type IconType = 'copy' | 'check' | 'cross';
|
||||
const [icon, setIcon] = React.useState<IconType>('copy');
|
||||
|
||||
React.useEffect(() => {
|
||||
setIcon('copy');
|
||||
}, [value]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (icon === 'check') {
|
||||
const timeout = setTimeout(() => {
|
||||
setIcon('copy');
|
||||
}, 3000);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [icon]);
|
||||
|
||||
const handleCopy = React.useCallback(() => {
|
||||
navigator.clipboard.writeText(value).then(() => {
|
||||
setIcon('check');
|
||||
}, () => {
|
||||
setIcon('cross');
|
||||
});
|
||||
}, [value]);
|
||||
const iconElement = icon === 'check' ? icons.check() : icon === 'cross' ? icons.cross() : icons.copy();
|
||||
return <button className='copy-icon' title='Copy to clipboard' aria-label='Copy to clipboard' onClick={handleCopy}>{iconElement}</button>;
|
||||
};
|
||||
32
packages/extension/src/ui/icons.css
Normal file
32
packages/extension/src/ui/icons.css
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
.octicon {
|
||||
display: inline-block;
|
||||
overflow: visible !important;
|
||||
vertical-align: text-bottom;
|
||||
fill: currentColor;
|
||||
margin-right: 7px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.color-icon-success {
|
||||
color: var(--color-success-fg) !important;
|
||||
}
|
||||
|
||||
.color-text-danger {
|
||||
color: var(--color-danger-fg) !important;
|
||||
}
|
||||
49
packages/extension/src/ui/icons.tsx
Normal file
49
packages/extension/src/ui/icons.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
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 './icons.css';
|
||||
import './colors.css';
|
||||
|
||||
export const cross = () => {
|
||||
return <svg className='octicon color-text-danger' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'>
|
||||
<path fillRule='evenodd' d='M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z'></path>
|
||||
</svg>;
|
||||
};
|
||||
|
||||
export const check = () => {
|
||||
return <svg aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16' data-view-component='true' className='octicon color-icon-success'>
|
||||
<path fillRule='evenodd' d='M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z'></path>
|
||||
</svg>;
|
||||
};
|
||||
|
||||
export const copy = () => {
|
||||
return <svg className='octicon' viewBox='0 0 16 16' width='16' height='16' aria-hidden='true'>
|
||||
<path d='M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z'></path>
|
||||
<path d='M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z'></path>
|
||||
</svg>;
|
||||
};
|
||||
|
||||
export const refresh = () => {
|
||||
return <svg className='octicon' viewBox="0 0 16 16" width="16" height="16" aria-hidden='true'>
|
||||
<path d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z"></path>
|
||||
</svg>;
|
||||
};
|
||||
|
||||
export const chevronDown = () => {
|
||||
return <svg className='octicon' viewBox="0 0 16 16" width="16" height="16" aria-hidden='true'>
|
||||
<path d="M12.78 5.22a.749.749 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.06 0L3.22 6.28a.749.749 0 1 1 1.06-1.06L8 8.939l3.72-3.719a.749.749 0 0 1 1.06 0Z"></path>
|
||||
</svg>;
|
||||
};
|
||||
13
packages/extension/src/ui/status.html
Normal file
13
packages/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>
|
||||
112
packages/extension/src/ui/status.tsx
Normal file
112
packages/extension/src/ui/status.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 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';
|
||||
import { AuthTokenSection } from './authToken';
|
||||
|
||||
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>
|
||||
)}
|
||||
<AuthTokenSection />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Initialize the React app
|
||||
const container = document.getElementById('root');
|
||||
if (container) {
|
||||
const root = createRoot(container);
|
||||
root.render(<StatusApp />);
|
||||
}
|
||||
67
packages/extension/src/ui/tabItem.tsx
Normal file
67
packages/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
packages/extension/src/ui/tsconfig.json
Normal file
4
packages/extension/src/ui/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
// Help VSCode to find right tsconfig file.
|
||||
{
|
||||
"extends": "../../tsconfig.ui.json"
|
||||
}
|
||||
304
packages/extension/tests/extension.spec.ts
Normal file
304
packages/extension/tests/extension.spec.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* 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 '../../playwright-mcp/tests/fixtures';
|
||||
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import type { StartClient } from '../../playwright-mcp/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 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);
|
||||
},
|
||||
});
|
||||
|
||||
test(`navigate with extension`, async ({ browserWithExtension, startClient, server }) => {
|
||||
const browserContext = await browserWithExtension.launch();
|
||||
|
||||
const client = await startWithExtensionFlag(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({
|
||||
snapshot: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||
});
|
||||
});
|
||||
|
||||
test(`snapshot of an existing page`, 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 startWithExtensionFlag(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({
|
||||
snapshot: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||
});
|
||||
|
||||
expect(browserContext.pages()).toHaveLength(4);
|
||||
});
|
||||
|
||||
test(`extension not installed timeout`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
|
||||
useShortConnectionTimeout(100);
|
||||
|
||||
const browserContext = await browserWithExtension.launch();
|
||||
|
||||
const client = await startWithExtensionFlag(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({
|
||||
error: expect.stringContaining('Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed.'),
|
||||
isError: true,
|
||||
});
|
||||
|
||||
await confirmationPagePromise;
|
||||
});
|
||||
|
||||
testWithOldExtensionVersion(`works with old extension version`, 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 startWithExtensionFlag(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({
|
||||
snapshot: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||
});
|
||||
});
|
||||
|
||||
test(`extension needs update`, 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 startWithExtensionFlag(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({
|
||||
error: 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 },
|
||||
});
|
||||
expect(await navigateResponse).toHaveResponse({
|
||||
error: 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?');
|
||||
});
|
||||
|
||||
test(`bypass connection dialog with token`, async ({ browserWithExtension, startClient, server }) => {
|
||||
const browserContext = await browserWithExtension.launch();
|
||||
|
||||
const page = await browserContext.newPage();
|
||||
await page.goto('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/status.html');
|
||||
const token = await page.locator('.auth-token-code').textContent();
|
||||
const [name, value] = token?.split('=') || [];
|
||||
|
||||
const { client } = await startClient({
|
||||
args: [`--extension`],
|
||||
extensionToken: value,
|
||||
config: {
|
||||
browser: {
|
||||
userDataDir: browserWithExtension.userDataDir,
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const navigateResponse = await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
|
||||
expect(await navigateResponse).toHaveResponse({
|
||||
snapshot: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||
});
|
||||
});
|
||||
22
packages/extension/tsconfig.json
Normal file
22
packages/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
packages/extension/tsconfig.ui.json
Normal file
19
packages/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
packages/extension/vite.config.mts
Normal file
54
packages/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.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures.js';
|
||||
import { resolve } from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
test('browser_install', async ({ client, mcpBrowser }) => {
|
||||
test.skip(mcpBrowser !== 'chromium', 'Test only chromium');
|
||||
expect(await client.callTool({
|
||||
name: 'browser_install',
|
||||
})).toContainTextContent(`No open pages available.`);
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/background.ts'),
|
||||
fileName: 'lib/background',
|
||||
formats: ['es']
|
||||
},
|
||||
outDir: 'dist',
|
||||
emptyOutDir: false,
|
||||
minify: false
|
||||
}
|
||||
});
|
||||
2
packages/playwright-mcp/.gitignore
vendored
Normal file
2
packages/playwright-mcp/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
README.md
|
||||
LICENSE
|
||||
7
packages/playwright-mcp/.npmignore
Normal file
7
packages/playwright-mcp/.npmignore
Normal file
@@ -0,0 +1,7 @@
|
||||
**/*
|
||||
!README.md
|
||||
!LICENSE
|
||||
!cli.js
|
||||
!playwright-cli.js
|
||||
!index.*
|
||||
!config.d.ts
|
||||
12
src/package.ts → packages/playwright-mcp/cli.js
Normal file → Executable file
12
src/package.ts → packages/playwright-mcp/cli.js
Normal file → Executable file
@@ -1,3 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
@@ -14,9 +15,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import url from 'node:url';
|
||||
import path from 'node:path';
|
||||
const { program } = require('playwright-core/lib/utilsBundle');
|
||||
const { decorateCommand } = require('playwright/lib/mcp/program');
|
||||
|
||||
const __filename = url.fileURLToPath(import.meta.url);
|
||||
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8'));
|
||||
const packageJSON = require('./package.json');
|
||||
const p = program.version('Version ' + packageJSON.version).name('Playwright MCP');
|
||||
decorateCommand(p, packageJSON.version)
|
||||
void program.parseAsync(process.argv);
|
||||
111
config.d.ts → packages/playwright-mcp/config.d.ts
vendored
111
config.d.ts → packages/playwright-mcp/config.d.ts
vendored
@@ -16,7 +16,17 @@
|
||||
|
||||
import type * as playwright from 'playwright';
|
||||
|
||||
export type ToolCapability = 'core' | 'core-tabs' | 'core-install' | 'vision' | 'pdf';
|
||||
export type ToolCapability =
|
||||
'core' |
|
||||
'core-input' |
|
||||
'core-navigation' |
|
||||
'core-tabs' |
|
||||
'core-install' |
|
||||
'core-input' |
|
||||
'vision' |
|
||||
'pdf' |
|
||||
'testing' |
|
||||
'tracing';
|
||||
|
||||
export type Config = {
|
||||
/**
|
||||
@@ -59,10 +69,31 @@ export type Config = {
|
||||
*/
|
||||
cdpEndpoint?: string;
|
||||
|
||||
/**
|
||||
* CDP headers to send with the connect request.
|
||||
*/
|
||||
cdpHeaders?: Record<string, string>;
|
||||
|
||||
/**
|
||||
* Timeout in milliseconds for connecting to CDP endpoint. Defaults to 30000 (30 seconds). Pass 0 to disable timeout.
|
||||
*/
|
||||
cdpTimeout?: number;
|
||||
|
||||
/**
|
||||
* Remote endpoint to connect to an existing Playwright server.
|
||||
*/
|
||||
remoteEndpoint?: string;
|
||||
|
||||
/**
|
||||
* Paths to TypeScript files to add as initialization scripts for Playwright page.
|
||||
*/
|
||||
initPage?: string[];
|
||||
|
||||
/**
|
||||
* Paths to JavaScript files to add as initialization scripts.
|
||||
* The scripts will be evaluated in every page before any of the page's scripts.
|
||||
*/
|
||||
initScript?: string[];
|
||||
},
|
||||
|
||||
server?: {
|
||||
@@ -75,6 +106,12 @@ export type Config = {
|
||||
* The host to bind the server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.
|
||||
*/
|
||||
host?: string;
|
||||
|
||||
/**
|
||||
* The hosts this server is allowed to serve from. Defaults to the host server is bound to.
|
||||
* This is not for CORS, but rather for the DNS rebinding protection.
|
||||
*/
|
||||
allowedHosts?: string[];
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -85,16 +122,53 @@ export type Config = {
|
||||
*/
|
||||
capabilities?: ToolCapability[];
|
||||
|
||||
/**
|
||||
* Whether to save the Playwright session into the output directory.
|
||||
*/
|
||||
saveSession?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to save the Playwright trace of the session into the output directory.
|
||||
*/
|
||||
saveTrace?: boolean;
|
||||
|
||||
/**
|
||||
* If specified, saves the Playwright video of the session into the output directory.
|
||||
*/
|
||||
saveVideo?: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reuse the same browser context between all connected HTTP clients.
|
||||
*/
|
||||
sharedBrowserContext?: boolean;
|
||||
|
||||
/**
|
||||
* Secrets are used to prevent LLM from getting sensitive data while
|
||||
* automating scenarios such as authentication.
|
||||
* Prefer the browser.contextOptions.storageState over secrets file as a more secure alternative.
|
||||
*/
|
||||
secrets?: Record<string, string>;
|
||||
|
||||
/**
|
||||
* The directory to save output files.
|
||||
*/
|
||||
outputDir?: string;
|
||||
|
||||
/**
|
||||
* Whether to save snapshots, console messages, network logs and other session logs to a file or to the standard output. Defaults to "stdout".
|
||||
*/
|
||||
outputMode?: 'file' | 'stdout';
|
||||
|
||||
console?: {
|
||||
/**
|
||||
* The level of console messages to return. Each level includes the messages of more severe levels. Defaults to "info".
|
||||
*/
|
||||
level?: 'error' | 'warning' | 'info' | 'debug';
|
||||
},
|
||||
|
||||
network?: {
|
||||
/**
|
||||
* List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
|
||||
@@ -107,8 +181,43 @@ export type Config = {
|
||||
blockedOrigins?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Specify the attribute to use for test ids, defaults to "data-testid".
|
||||
*/
|
||||
testIdAttribute?: string;
|
||||
|
||||
timeouts?: {
|
||||
/*
|
||||
* Configures default action timeout: https://playwright.dev/docs/api/class-page#page-set-default-timeout. Defaults to 5000ms.
|
||||
*/
|
||||
action?: number;
|
||||
|
||||
/*
|
||||
* Configures default navigation timeout: https://playwright.dev/docs/api/class-page#page-set-default-navigation-timeout. Defaults to 60000ms.
|
||||
*/
|
||||
navigation?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether to send image responses to the client. Can be "allow", "omit", or "auto". Defaults to "auto", which sends images if the client can display them.
|
||||
*/
|
||||
imageResponses?: 'allow' | 'omit';
|
||||
|
||||
snapshot?: {
|
||||
/**
|
||||
* When taking snapshots for responses, specifies the mode to use.
|
||||
*/
|
||||
mode?: 'incremental' | 'full' | 'none';
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether to allow file uploads from anywhere on the file system.
|
||||
* By default (false), file uploads are restricted to paths within the MCP roots only.
|
||||
*/
|
||||
allowUnrestrictedFileAccess?: boolean;
|
||||
|
||||
/**
|
||||
* Specify the language to use for code generation.
|
||||
*/
|
||||
codegen?: 'typescript' | 'none';
|
||||
};
|
||||
@@ -16,13 +16,8 @@
|
||||
*/
|
||||
|
||||
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import type { Config } from './config.js';
|
||||
import type { Config } from './config';
|
||||
import type { BrowserContext } from 'playwright';
|
||||
|
||||
export type Connection = {
|
||||
server: Server;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
export declare function createConnection(config?: Config, contextGetter?: () => Promise<BrowserContext>): Promise<Connection>;
|
||||
export declare function createConnection(config?: Config, contextGetter?: () => Promise<BrowserContext>): Promise<Server>;
|
||||
export {};
|
||||
@@ -15,5 +15,5 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createConnection } from './lib/index.js';
|
||||
export { createConnection };
|
||||
const { createConnection } = require('playwright/lib/mcp/index');
|
||||
module.exports = { createConnection };
|
||||
45
packages/playwright-mcp/package.json
Normal file
45
packages/playwright-mcp/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.59",
|
||||
"description": "Playwright Tools for MCP",
|
||||
"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": {
|
||||
"lint": "node update-readme.js",
|
||||
"test": "playwright test",
|
||||
"ctest": "playwright test --project=chrome",
|
||||
"ftest": "playwright test --project=firefox",
|
||||
"wtest": "playwright test --project=webkit",
|
||||
"dtest": "MCP_IN_DOCKER=1 playwright test --project=chromium-docker",
|
||||
"build": "echo OK",
|
||||
"npm-publish": "npm run lint && npm run test && npm publish",
|
||||
"copy-config": "cp ../../../playwright/packages/playwright/src/mcp/config.d.ts . && perl -pi -e \"s|import type \\* as playwright from 'playwright-core';|import type * as playwright from 'playwright';|\" ./config.d.ts",
|
||||
"roll": "npm run copy-config && npm run lint"
|
||||
},
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"types": "./index.d.ts",
|
||||
"default": "./index.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.5",
|
||||
"playwright": "1.59.0-alpha-1769364499000",
|
||||
"playwright-core": "1.59.0-alpha-1769364499000"
|
||||
},
|
||||
"bin": {
|
||||
"mcp": "cli.js",
|
||||
"playwright-cli": "./playwright-cli.js"
|
||||
}
|
||||
}
|
||||
@@ -15,4 +15,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import './lib/program.js';
|
||||
const { program } = require('playwright/lib/mcp/terminal/program');
|
||||
const packageJSON = require('./package.json');
|
||||
program({ version: packageJSON.version }).catch(e => {
|
||||
console.error(e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -16,19 +16,16 @@
|
||||
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
import type { TestOptions } from './tests/fixtures.js';
|
||||
import type { TestOptions } from './tests/fixtures';
|
||||
|
||||
export default defineConfig<TestOptions>({
|
||||
testDir: './tests',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
workers: process.env.CI ? 2 : undefined,
|
||||
reporter: 'list',
|
||||
projects: [
|
||||
{ name: 'chrome' },
|
||||
{ name: 'msedge', use: { mcpBrowser: 'msedge' } },
|
||||
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
||||
...process.env.MCP_IN_DOCKER ? [{
|
||||
name: 'chromium-docker',
|
||||
grep: /browser_navigate|browser_click/,
|
||||
@@ -37,7 +34,5 @@ export default defineConfig<TestOptions>({
|
||||
mcpMode: 'docker' as const
|
||||
}
|
||||
}] : [],
|
||||
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
||||
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
||||
],
|
||||
});
|
||||
3
packages/playwright-mcp/src/README.md
Normal file
3
packages/playwright-mcp/src/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Where is the source?
|
||||
|
||||
Playwright MCP source code is located in the [Playwright monorepo](https://github.com/microsoft/playwright/blob/main/packages/playwright/src/mcp). Please refer to the contributor's guide in [CONTRIBUTING.md](../CONTRIBUTING.md) for more details.
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures.js';
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('test snapshot tool list', async ({ client }) => {
|
||||
const { tools } = await client.listTools();
|
||||
@@ -24,6 +24,7 @@ test('test snapshot tool list', async ({ client }) => {
|
||||
'browser_drag',
|
||||
'browser_evaluate',
|
||||
'browser_file_upload',
|
||||
'browser_fill_form',
|
||||
'browser_handle_dialog',
|
||||
'browser_hover',
|
||||
'browser_select_option',
|
||||
@@ -31,16 +32,13 @@ test('test snapshot tool list', async ({ client }) => {
|
||||
'browser_close',
|
||||
'browser_install',
|
||||
'browser_navigate_back',
|
||||
'browser_navigate_forward',
|
||||
'browser_navigate',
|
||||
'browser_network_requests',
|
||||
'browser_press_key',
|
||||
'browser_resize',
|
||||
'browser_run_code',
|
||||
'browser_snapshot',
|
||||
'browser_tab_close',
|
||||
'browser_tab_list',
|
||||
'browser_tab_new',
|
||||
'browser_tab_select',
|
||||
'browser_tabs',
|
||||
'browser_take_screenshot',
|
||||
'browser_wait_for',
|
||||
]));
|
||||
32
packages/playwright-mcp/tests/cli.spec.ts
Normal file
32
packages/playwright-mcp/tests/cli.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 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 '@playwright/test';
|
||||
import childProcess from 'child_process';
|
||||
|
||||
function runCLI(args: string[]) {
|
||||
return childProcess.spawnSync(process.execPath, [require.resolve('../playwright-cli'), ...args], { encoding: 'utf-8' });
|
||||
}
|
||||
|
||||
test('prints help', async () => {
|
||||
const { stdout } = runCLI(['--help']);
|
||||
expect(stdout).toContain('Usage: playwright-cli <command>');
|
||||
});
|
||||
|
||||
test('prints version', async () => {
|
||||
const { stdout } = runCLI(['--version']);
|
||||
expect(stdout).toContain(require('../package.json').version);
|
||||
});
|
||||
49
packages/playwright-mcp/tests/click.spec.ts
Normal file
49
packages/playwright-mcp/tests/click.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('browser_click', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<title>Title</title>
|
||||
<button>Submit</button>
|
||||
<script>
|
||||
const button = document.querySelector('button');
|
||||
button.addEventListener('click', () => {
|
||||
button.focus(); // without manual focus, webkit focuses body
|
||||
});
|
||||
</script>
|
||||
`, 'text/html');
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
})).toHaveResponse({
|
||||
code: `await page.goto('${server.PREFIX}');`,
|
||||
snapshot: expect.stringContaining(`- button \"Submit\" [ref=e2]`),
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Submit button',
|
||||
ref: 'e2',
|
||||
},
|
||||
})).toHaveResponse({
|
||||
code: `await page.getByRole('button', { name: 'Submit' }).click();`,
|
||||
snapshot: expect.stringContaining(`button "Submit" [active] [ref=e2]`),
|
||||
});
|
||||
});
|
||||
@@ -14,12 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import debug from 'debug';
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
const errorsDebug = debug('pw:mcp:errors');
|
||||
|
||||
export function logUnhandledError(error: unknown) {
|
||||
errorsDebug(error);
|
||||
}
|
||||
|
||||
export const testDebug = debug('pw:mcp:test');
|
||||
test('browser_navigate', async ({ client, server }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toHaveResponse({
|
||||
code: `await page.goto('${server.HELLO_WORLD}');`,
|
||||
snapshot: expect.stringContaining(`generic [active] [ref=e1]: Hello, world!`),
|
||||
});
|
||||
});
|
||||
@@ -15,14 +15,14 @@
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import url from 'url';
|
||||
import path from 'path';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
import { test as baseTest, expect as baseExpect } from '@playwright/test';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { TestServer } from './testserver/index.ts';
|
||||
import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { TestServer } from './testserver/index';
|
||||
|
||||
import type { Config } from '../config';
|
||||
import type { BrowserContext } from 'playwright';
|
||||
@@ -30,6 +30,7 @@ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import type { Stream } from 'stream';
|
||||
|
||||
export type TestOptions = {
|
||||
mcpArgs: string[] | undefined;
|
||||
mcpBrowser: string | undefined;
|
||||
mcpMode: 'docker' | undefined;
|
||||
};
|
||||
@@ -39,9 +40,19 @@ type CDPServer = {
|
||||
start: () => Promise<BrowserContext>;
|
||||
};
|
||||
|
||||
export type StartClient = (options?: {
|
||||
clientName?: string,
|
||||
args?: string[],
|
||||
config?: Config,
|
||||
roots?: { name: string, uri: string }[],
|
||||
rootsResponseDelay?: number,
|
||||
extensionToken?: string,
|
||||
}) => Promise<{ client: Client, stderr: () => string }>;
|
||||
|
||||
|
||||
type TestFixtures = {
|
||||
client: Client;
|
||||
startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<{ client: Client, stderr: () => string }>;
|
||||
startClient: StartClient;
|
||||
wsEndpoint: string;
|
||||
cdpServer: CDPServer;
|
||||
server: TestServer;
|
||||
@@ -55,20 +66,19 @@ type WorkerFixtures = {
|
||||
|
||||
export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>({
|
||||
|
||||
mcpArgs: [undefined, { option: true }],
|
||||
|
||||
client: async ({ startClient }, use) => {
|
||||
const { client } = await startClient();
|
||||
await use(client);
|
||||
},
|
||||
|
||||
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
|
||||
const userDataDir = mcpMode !== 'docker' ? testInfo.outputPath('user-data-dir') : undefined;
|
||||
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode, mcpArgs }, use, testInfo) => {
|
||||
const configDir = path.dirname(test.info().config.configFile!);
|
||||
let client: Client | undefined;
|
||||
const clients: Client[] = [];
|
||||
|
||||
await use(async options => {
|
||||
const args: string[] = [];
|
||||
if (userDataDir)
|
||||
args.push('--user-data-dir', userDataDir);
|
||||
const args: string[] = mcpArgs ?? [];
|
||||
if (process.env.CI && process.platform === 'linux')
|
||||
args.push('--no-sandbox');
|
||||
if (mcpHeadless)
|
||||
@@ -83,20 +93,30 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
||||
args.push(`--config=${path.relative(configDir, configFile)}`);
|
||||
}
|
||||
|
||||
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
|
||||
const { transport, stderr } = await createTransport(args, mcpMode);
|
||||
const client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }, options?.roots ? { capabilities: { roots: {} } } : undefined);
|
||||
if (options?.roots) {
|
||||
client.setRequestHandler(ListRootsRequestSchema, async request => {
|
||||
if (options.rootsResponseDelay)
|
||||
await new Promise(resolve => setTimeout(resolve, options.rootsResponseDelay));
|
||||
return {
|
||||
roots: options.roots,
|
||||
};
|
||||
});
|
||||
}
|
||||
const { transport, stderr } = await createTransport(args, mcpMode, testInfo.outputPath('ms-playwright'), options?.extensionToken);
|
||||
let stderrBuffer = '';
|
||||
stderr?.on('data', data => {
|
||||
if (process.env.PWMCP_DEBUG)
|
||||
process.stderr.write(data);
|
||||
stderrBuffer += data.toString();
|
||||
});
|
||||
clients.push(client);
|
||||
await client.connect(transport);
|
||||
await client.ping();
|
||||
return { client, stderr: () => stderrBuffer };
|
||||
});
|
||||
|
||||
await client?.close();
|
||||
await Promise.all(clients.map(client => client.close()));
|
||||
},
|
||||
|
||||
wsEndpoint: async ({ }, use) => {
|
||||
@@ -113,6 +133,8 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
||||
await use({
|
||||
endpoint: `http://localhost:${port}`,
|
||||
start: async () => {
|
||||
if (browserContext)
|
||||
throw new Error('CDP server already exists');
|
||||
browserContext = await chromium.launchPersistentContext(testInfo.outputPath('cdp-user-data-dir'), {
|
||||
channel: mcpBrowser,
|
||||
headless: true,
|
||||
@@ -160,12 +182,10 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
||||
},
|
||||
});
|
||||
|
||||
async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): Promise<{
|
||||
async function createTransport(args: string[], mcpMode: TestOptions['mcpMode'], profilesDir: string, extensionToken?: string): Promise<{
|
||||
transport: Transport,
|
||||
stderr: Stream | null,
|
||||
}> {
|
||||
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||
const __filename = url.fileURLToPath(import.meta.url);
|
||||
if (mcpMode === 'docker') {
|
||||
const dockerArgs = ['run', '--rm', '-i', '--network=host', '-v', `${test.info().project.outputDir}:/app/test-results`];
|
||||
const transport = new StdioClientTransport({
|
||||
@@ -180,14 +200,16 @@ async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']):
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
|
||||
cwd: path.join(path.dirname(__filename), '..'),
|
||||
args: [path.join(__dirname, '../cli.js'), ...args],
|
||||
cwd: path.dirname(test.info().config.configFile!),
|
||||
stderr: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
DEBUG: 'pw:mcp:test',
|
||||
DEBUG_COLORS: '0',
|
||||
DEBUG_HIDE_DATE: '1',
|
||||
PWMCP_PROFILES_DIR_FOR_TEST: profilesDir,
|
||||
...(extensionToken ? { PLAYWRIGHT_MCP_EXTENSION_TOKEN: extensionToken } : {}),
|
||||
},
|
||||
});
|
||||
return {
|
||||
@@ -199,42 +221,15 @@ async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']):
|
||||
type Response = Awaited<ReturnType<Client['callTool']>>;
|
||||
|
||||
export const expect = baseExpect.extend({
|
||||
toHaveTextContent(response: Response, content: string | RegExp) {
|
||||
toHaveResponse(response: Response, object: any) {
|
||||
const parsed = parseResponse(response);
|
||||
const isNot = this.isNot;
|
||||
try {
|
||||
const text = (response.content as any)[0].text;
|
||||
if (typeof content === 'string') {
|
||||
if (isNot)
|
||||
baseExpect(text.trim()).not.toBe(content.trim());
|
||||
else
|
||||
baseExpect(text.trim()).toBe(content.trim());
|
||||
} else {
|
||||
if (isNot)
|
||||
baseExpect(text).not.toMatch(content);
|
||||
else
|
||||
baseExpect(text).toMatch(content);
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
pass: isNot,
|
||||
message: () => e.message,
|
||||
};
|
||||
}
|
||||
return {
|
||||
pass: !isNot,
|
||||
message: () => ``,
|
||||
};
|
||||
},
|
||||
|
||||
toContainTextContent(response: Response, content: string) {
|
||||
const isNot = this.isNot;
|
||||
try {
|
||||
const texts = (response.content as any).map(c => c.text).join('\n');
|
||||
if (isNot)
|
||||
expect(texts).not.toContain(content);
|
||||
expect(parsed).not.toEqual(expect.objectContaining(object));
|
||||
else
|
||||
expect(texts).toContain(content);
|
||||
} catch (e) {
|
||||
expect(parsed).toEqual(expect.objectContaining(object));
|
||||
} catch (e: any) {
|
||||
return {
|
||||
pass: isNot,
|
||||
message: () => e.message,
|
||||
@@ -250,3 +245,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 error = sections.get('Error');
|
||||
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 snapshot = sections.get('Snapshot');
|
||||
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 {
|
||||
error,
|
||||
result,
|
||||
code: codeNoFrame,
|
||||
tabs,
|
||||
pageState,
|
||||
snapshot,
|
||||
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;
|
||||
}
|
||||
@@ -13,9 +13,9 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { test, expect } from './fixtures.js';
|
||||
import fs from 'node:fs/promises';
|
||||
import child_process from 'node:child_process';
|
||||
import child_process from 'child_process';
|
||||
import fs from 'fs/promises';
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
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');
|
||||
@@ -16,7 +16,6 @@
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import url from 'node:url';
|
||||
import http from 'http';
|
||||
import https from 'https';
|
||||
import path from 'path';
|
||||
@@ -25,9 +24,6 @@ import debug from 'debug';
|
||||
const fulfillSymbol = Symbol('fulfil 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 {
|
||||
private _server: http.Server;
|
||||
readonly debugServer: any;
|
||||
@@ -16,29 +16,36 @@
|
||||
*/
|
||||
// @ts-check
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import url from 'node:url'
|
||||
import zodToJsonSchema from 'zod-to-json-schema'
|
||||
import { execSync } from 'node:child_process';
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
import { allTools } from '../lib/tools.js';
|
||||
const { browserTools } = require('playwright/lib/mcp/browser/tools');
|
||||
|
||||
const capabilities = {
|
||||
'core-navigation': 'Core automation',
|
||||
'core': 'Core automation',
|
||||
'core-tabs': 'Tab management',
|
||||
'core-input': 'Core automation',
|
||||
'core-install': 'Browser installation',
|
||||
'vision': 'Coordinate-based (opt-in via --caps=vision)',
|
||||
'pdf': 'PDF generation (opt-in via --caps=pdf)',
|
||||
'testing': 'Test assertions (opt-in via --caps=testing)',
|
||||
'tracing': 'Tracing (opt-in via --caps=tracing)',
|
||||
};
|
||||
|
||||
const toolsByCapability = Object.fromEntries(Object.entries(capabilities).map(([capability, title]) => [title, allTools.filter(tool => tool.capability === capability).sort((a, b) => a.schema.name.localeCompare(b.schema.name))]));
|
||||
|
||||
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||
const __filename = url.fileURLToPath(import.meta.url);
|
||||
/** @type {Record<string, any[]>} */
|
||||
const toolsByCapability = {};
|
||||
for (const [capability, title] of Object.entries(capabilities)) {
|
||||
let tools = browserTools.filter(tool => tool.capability === capability && !tool.skillOnly);
|
||||
tools = (toolsByCapability[title] || []).concat(tools);
|
||||
toolsByCapability[title] = tools;
|
||||
}
|
||||
for (const [, tools] of Object.entries(toolsByCapability))
|
||||
tools.sort((a, b) => a.schema.name.localeCompare(b.schema.name));
|
||||
|
||||
/**
|
||||
* @param {import('../src/tools/tool.js').ToolSchema<any>} tool
|
||||
* @param {any} tool
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function formatToolForReadme(tool) {
|
||||
@@ -49,7 +56,7 @@ function formatToolForReadme(tool) {
|
||||
lines.push(` - Title: ${tool.title}`);
|
||||
lines.push(` - Description: ${tool.description}`);
|
||||
|
||||
const inputSchema = /** @type {any} */ (zodToJsonSchema(tool.inputSchema || {}));
|
||||
const inputSchema = /** @type {any} */ (tool.inputSchema ? tool.inputSchema.toJSONSchema() : {});
|
||||
const requiredParams = inputSchema.required || [];
|
||||
if (inputSchema.properties && Object.keys(inputSchema.properties).length) {
|
||||
lines.push(` - Parameters:`);
|
||||
@@ -121,29 +128,102 @@ async function updateTools(content) {
|
||||
*/
|
||||
async function updateOptions(content) {
|
||||
console.log('Listing options...');
|
||||
const output = execSync('node cli.js --help');
|
||||
execSync('node cli.js --help > help.txt');
|
||||
const output = fs.readFileSync('help.txt');
|
||||
fs.unlinkSync('help.txt');
|
||||
const lines = output.toString().split('\n');
|
||||
const firstLine = lines.findIndex(line => line.includes('--version'));
|
||||
lines.splice(0, firstLine + 1);
|
||||
const lastLine = lines.findIndex(line => line.includes('--help'));
|
||||
lines.splice(lastLine);
|
||||
|
||||
/**
|
||||
* @type {{ name: string, value: string }[]}
|
||||
*/
|
||||
const options = [];
|
||||
for (let line of lines) {
|
||||
if (line.startsWith(' --')) {
|
||||
const l = line.substring(' --'.length);
|
||||
const gapIndex = l.indexOf(' ');
|
||||
const name = l.substring(0, gapIndex).trim();
|
||||
const value = l.substring(gapIndex).trim();
|
||||
options.push({ name, value });
|
||||
} else {
|
||||
const value = line.trim();
|
||||
options[options.length - 1].value += ' ' + value;
|
||||
}
|
||||
}
|
||||
|
||||
const table = [];
|
||||
table.push(`| Option | Description |`);
|
||||
table.push(`|--------|-------------|`);
|
||||
for (const option of options) {
|
||||
const prefix = option.name.split(' ')[0];
|
||||
const envName = `PLAYWRIGHT_MCP_` + prefix.toUpperCase().replace(/-/g, '_');
|
||||
table.push(`| --${option.name} | ${option.value}<br>*env* \`${envName}\` |`);
|
||||
}
|
||||
|
||||
if (process.env.PRINT_ENV) {
|
||||
const envTable = [];
|
||||
envTable.push(`| Environment |`);
|
||||
envTable.push(`|-------------|`);
|
||||
for (const option of options) {
|
||||
const prefix = option.name.split(' ')[0];
|
||||
const envName = `PLAYWRIGHT_MCP_` + prefix.toUpperCase().replace(/-/g, '_');
|
||||
envTable.push(`| \`${envName}\` ${option.value} |`);
|
||||
}
|
||||
console.log(envTable.join('\n'));
|
||||
}
|
||||
|
||||
const startMarker = `<!--- Options generated by ${path.basename(__filename)} -->`;
|
||||
const endMarker = `<!--- End of options generated section -->`;
|
||||
return updateSection(content, startMarker, endMarker, table);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} content
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function updateConfig(content) {
|
||||
console.log('Updating config schema from config.d.ts...');
|
||||
const configPath = path.join(__dirname, 'config.d.ts');
|
||||
const configContent = await fs.promises.readFile(configPath, 'utf-8');
|
||||
|
||||
// Extract the Config type definition
|
||||
const configTypeMatch = configContent.match(/export type Config = (\{[\s\S]*?\n\});/);
|
||||
if (!configTypeMatch)
|
||||
throw new Error('Config type not found in config.d.ts');
|
||||
|
||||
const configType = configTypeMatch[1]; // Use capture group to get just the object definition
|
||||
|
||||
const startMarker = `<!--- Config generated by ${path.basename(__filename)} -->`;
|
||||
const endMarker = `<!--- End of config generated section -->`;
|
||||
return updateSection(content, startMarker, endMarker, [
|
||||
'```',
|
||||
'> npx @playwright/mcp@latest --help',
|
||||
...lines,
|
||||
'```typescript',
|
||||
configType,
|
||||
'```',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} filePath
|
||||
*/
|
||||
async function copyToPackage(filePath) {
|
||||
await fs.promises.copyFile(path.join(__dirname, '../../', filePath), path.join(__dirname, filePath));
|
||||
console.log(`${filePath} copied successfully`);
|
||||
}
|
||||
|
||||
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 withTools = await updateTools(readmeContent);
|
||||
const withOptions = await updateOptions(withTools);
|
||||
await fs.promises.writeFile(readmePath, withOptions, 'utf-8');
|
||||
const withConfig = await updateConfig(withOptions);
|
||||
await fs.promises.writeFile(readmePath, withConfig, 'utf-8');
|
||||
console.log('README updated successfully');
|
||||
|
||||
await copyToPackage('README.md');
|
||||
await copyToPackage('LICENSE');
|
||||
}
|
||||
|
||||
updateReadme().catch(err => {
|
||||
@@ -1,229 +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 net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
import * as playwright from 'playwright';
|
||||
|
||||
import { logUnhandledError, testDebug } from './log.js';
|
||||
|
||||
import type { FullConfig } from './config.js';
|
||||
|
||||
export function contextFactory(browserConfig: FullConfig['browser']): BrowserContextFactory {
|
||||
if (browserConfig.remoteEndpoint)
|
||||
return new RemoteContextFactory(browserConfig);
|
||||
if (browserConfig.cdpEndpoint)
|
||||
return new CdpContextFactory(browserConfig);
|
||||
if (browserConfig.isolated)
|
||||
return new IsolatedContextFactory(browserConfig);
|
||||
return new PersistentContextFactory(browserConfig);
|
||||
}
|
||||
|
||||
export interface BrowserContextFactory {
|
||||
createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
|
||||
}
|
||||
|
||||
class BaseContextFactory implements BrowserContextFactory {
|
||||
readonly browserConfig: FullConfig['browser'];
|
||||
protected _browserPromise: Promise<playwright.Browser> | undefined;
|
||||
readonly name: string;
|
||||
|
||||
constructor(name: string, browserConfig: FullConfig['browser']) {
|
||||
this.name = name;
|
||||
this.browserConfig = browserConfig;
|
||||
}
|
||||
|
||||
protected async _obtainBrowser(): Promise<playwright.Browser> {
|
||||
if (this._browserPromise)
|
||||
return this._browserPromise;
|
||||
testDebug(`obtain browser (${this.name})`);
|
||||
this._browserPromise = this._doObtainBrowser();
|
||||
void this._browserPromise.then(browser => {
|
||||
browser.on('disconnected', () => {
|
||||
this._browserPromise = undefined;
|
||||
});
|
||||
}).catch(() => {
|
||||
this._browserPromise = undefined;
|
||||
});
|
||||
return this._browserPromise;
|
||||
}
|
||||
|
||||
protected async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||
testDebug(`create browser context (${this.name})`);
|
||||
const browser = await this._obtainBrowser();
|
||||
const browserContext = await this._doCreateContext(browser);
|
||||
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
|
||||
}
|
||||
|
||||
protected async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser) {
|
||||
testDebug(`close browser context (${this.name})`);
|
||||
if (browser.contexts().length === 1)
|
||||
this._browserPromise = undefined;
|
||||
await browserContext.close().catch(logUnhandledError);
|
||||
if (browser.contexts().length === 0) {
|
||||
testDebug(`close browser (${this.name})`);
|
||||
await browser.close().catch(logUnhandledError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class IsolatedContextFactory extends BaseContextFactory {
|
||||
constructor(browserConfig: FullConfig['browser']) {
|
||||
super('isolated', browserConfig);
|
||||
}
|
||||
|
||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||
await injectCdpPort(this.browserConfig);
|
||||
const browserType = playwright[this.browserConfig.browserName];
|
||||
return browserType.launch({
|
||||
...this.browserConfig.launchOptions,
|
||||
handleSIGINT: false,
|
||||
handleSIGTERM: false,
|
||||
}).catch(error => {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||
return browser.newContext(this.browserConfig.contextOptions);
|
||||
}
|
||||
}
|
||||
|
||||
class CdpContextFactory extends BaseContextFactory {
|
||||
constructor(browserConfig: FullConfig['browser']) {
|
||||
super('cdp', browserConfig);
|
||||
}
|
||||
|
||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||
return playwright.chromium.connectOverCDP(this.browserConfig.cdpEndpoint!);
|
||||
}
|
||||
|
||||
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||
return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteContextFactory extends BaseContextFactory {
|
||||
constructor(browserConfig: FullConfig['browser']) {
|
||||
super('remote', browserConfig);
|
||||
}
|
||||
|
||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||
const url = new URL(this.browserConfig.remoteEndpoint!);
|
||||
url.searchParams.set('browser', this.browserConfig.browserName);
|
||||
if (this.browserConfig.launchOptions)
|
||||
url.searchParams.set('launch-options', JSON.stringify(this.browserConfig.launchOptions));
|
||||
return playwright[this.browserConfig.browserName].connect(String(url));
|
||||
}
|
||||
|
||||
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||
return browser.newContext();
|
||||
}
|
||||
}
|
||||
|
||||
class PersistentContextFactory implements BrowserContextFactory {
|
||||
readonly browserConfig: FullConfig['browser'];
|
||||
private _userDataDirs = new Set<string>();
|
||||
|
||||
constructor(browserConfig: FullConfig['browser']) {
|
||||
this.browserConfig = browserConfig;
|
||||
}
|
||||
|
||||
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||
await injectCdpPort(this.browserConfig);
|
||||
testDebug('create browser context (persistent)');
|
||||
const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir();
|
||||
|
||||
this._userDataDirs.add(userDataDir);
|
||||
testDebug('lock user data dir', userDataDir);
|
||||
|
||||
const browserType = playwright[this.browserConfig.browserName];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
try {
|
||||
const browserContext = await browserType.launchPersistentContext(userDataDir, {
|
||||
...this.browserConfig.launchOptions,
|
||||
...this.browserConfig.contextOptions,
|
||||
handleSIGINT: false,
|
||||
handleSIGTERM: false,
|
||||
});
|
||||
const close = () => this._closeBrowserContext(browserContext, userDataDir);
|
||||
return { browserContext, close };
|
||||
} 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.`);
|
||||
if (error.message.includes('ProcessSingleton') || error.message.includes('Invalid URL')) {
|
||||
// User data directory is already in use, try again.
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`);
|
||||
}
|
||||
|
||||
private async _closeBrowserContext(browserContext: playwright.BrowserContext, userDataDir: string) {
|
||||
testDebug('close browser context (persistent)');
|
||||
testDebug('release user data dir', userDataDir);
|
||||
await browserContext.close().catch(() => {});
|
||||
this._userDataDirs.delete(userDataDir);
|
||||
testDebug('close browser context complete (persistent)');
|
||||
}
|
||||
|
||||
private async _createUserDataDir() {
|
||||
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-${this.browserConfig.launchOptions?.channel ?? this.browserConfig?.browserName}-profile`);
|
||||
await fs.promises.mkdir(result, { recursive: true });
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
async function injectCdpPort(browserConfig: FullConfig['browser']) {
|
||||
if (browserConfig.browserName === 'chromium')
|
||||
(browserConfig.launchOptions as any).cdpPort = await findFreePort();
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
317
src/config.ts
317
src/config.ts
@@ -1,317 +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 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?: 'allow' | 'omit';
|
||||
sandbox?: boolean;
|
||||
outputDir?: string;
|
||||
port?: number;
|
||||
proxyBypass?: string;
|
||||
proxyServer?: string;
|
||||
saveTrace?: boolean;
|
||||
storageState?: string;
|
||||
userAgent?: string;
|
||||
userDataDir?: string;
|
||||
viewportSize?: string;
|
||||
};
|
||||
|
||||
const defaultConfig: FullConfig = {
|
||||
browser: {
|
||||
browserName: 'chromium',
|
||||
launchOptions: {
|
||||
channel: 'chrome',
|
||||
headless: os.platform() === 'linux' && !process.env.DISPLAY,
|
||||
chromiumSandbox: true,
|
||||
},
|
||||
contextOptions: {
|
||||
viewport: null,
|
||||
},
|
||||
},
|
||||
network: {
|
||||
allowedOrigins: undefined,
|
||||
blockedOrigins: undefined,
|
||||
},
|
||||
server: {},
|
||||
outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())),
|
||||
};
|
||||
|
||||
type BrowserUserConfig = NonNullable<Config['browser']>;
|
||||
|
||||
export type FullConfig = Config & {
|
||||
browser: Omit<BrowserUserConfig, 'browserName'> & {
|
||||
browserName: 'chromium' | 'firefox' | 'webkit';
|
||||
launchOptions: NonNullable<BrowserUserConfig['launchOptions']>;
|
||||
contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
|
||||
},
|
||||
network: NonNullable<Config['network']>,
|
||||
outputDir: string;
|
||||
server: NonNullable<Config['server']>,
|
||||
};
|
||||
|
||||
export async function resolveConfig(config: Config): Promise<FullConfig> {
|
||||
return mergeConfig(defaultConfig, config);
|
||||
}
|
||||
|
||||
export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConfig> {
|
||||
const configInFile = await loadConfig(cliOptions.config);
|
||||
const envOverrides = configFromEnv();
|
||||
const cliOverrides = configFromCLIOptions(cliOptions);
|
||||
let result = defaultConfig;
|
||||
result = mergeConfig(result, configInFile);
|
||||
result = mergeConfig(result, envOverrides);
|
||||
result = mergeConfig(result, cliOverrides);
|
||||
// Derive artifact output directory from config.outputDir
|
||||
if (result.saveTrace)
|
||||
result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
|
||||
return result;
|
||||
}
|
||||
|
||||
export function configFromCLIOptions(cliOptions: CLIOptions): Config {
|
||||
let browserName: 'chromium' | 'firefox' | 'webkit' | undefined;
|
||||
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;
|
||||
}
|
||||
|
||||
// Launch options
|
||||
const launchOptions: LaunchOptions = {
|
||||
channel,
|
||||
executablePath: cliOptions.executablePath,
|
||||
headless: cliOptions.headless,
|
||||
};
|
||||
|
||||
// --no-sandbox was passed, disable the sandbox
|
||||
if (!cliOptions.sandbox)
|
||||
launchOptions.chromiumSandbox = false;
|
||||
|
||||
if (cliOptions.proxyServer) {
|
||||
launchOptions.proxy = {
|
||||
server: cliOptions.proxyServer
|
||||
};
|
||||
if (cliOptions.proxyBypass)
|
||||
launchOptions.proxy.bypass = cliOptions.proxyBypass;
|
||||
}
|
||||
|
||||
if (cliOptions.device && cliOptions.cdpEndpoint)
|
||||
throw new Error('Device emulation is not supported with cdpEndpoint.');
|
||||
|
||||
// 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 as ToolCapability[],
|
||||
network: {
|
||||
allowedOrigins: cliOptions.allowedOrigins,
|
||||
blockedOrigins: cliOptions.blockedOrigins,
|
||||
},
|
||||
saveTrace: cliOptions.saveTrace,
|
||||
outputDir: cliOptions.outputDir,
|
||||
imageResponses: cliOptions.imageResponses,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function configFromEnv(): Config {
|
||||
const options: CLIOptions = {};
|
||||
options.allowedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_ALLOWED_ORIGINS);
|
||||
options.blockedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_BLOCKED_ORIGINS);
|
||||
options.blockServiceWorkers = envToBoolean(process.env.PLAYWRIGHT_MCP_BLOCK_SERVICE_WORKERS);
|
||||
options.browser = envToString(process.env.PLAYWRIGHT_MCP_BROWSER);
|
||||
options.caps = commaSeparatedList(process.env.PLAYWRIGHT_MCP_CAPS);
|
||||
options.cdpEndpoint = envToString(process.env.PLAYWRIGHT_MCP_CDP_ENDPOINT);
|
||||
options.config = envToString(process.env.PLAYWRIGHT_MCP_CONFIG);
|
||||
options.device = envToString(process.env.PLAYWRIGHT_MCP_DEVICE);
|
||||
options.executablePath = envToString(process.env.PLAYWRIGHT_MCP_EXECUTABLE_PATH);
|
||||
options.headless = envToBoolean(process.env.PLAYWRIGHT_MCP_HEADLESS);
|
||||
options.host = envToString(process.env.PLAYWRIGHT_MCP_HOST);
|
||||
options.ignoreHttpsErrors = envToBoolean(process.env.PLAYWRIGHT_MCP_IGNORE_HTTPS_ERRORS);
|
||||
options.isolated = envToBoolean(process.env.PLAYWRIGHT_MCP_ISOLATED);
|
||||
if (process.env.PLAYWRIGHT_MCP_IMAGE_RESPONSES === 'omit')
|
||||
options.imageResponses = 'omit';
|
||||
options.sandbox = envToBoolean(process.env.PLAYWRIGHT_MCP_SANDBOX);
|
||||
options.outputDir = envToString(process.env.PLAYWRIGHT_MCP_OUTPUT_DIR);
|
||||
options.port = envToNumber(process.env.PLAYWRIGHT_MCP_PORT);
|
||||
options.proxyBypass = envToString(process.env.PLAYWRIGHT_MCP_PROXY_BYPASS);
|
||||
options.proxyServer = envToString(process.env.PLAYWRIGHT_MCP_PROXY_SERVER);
|
||||
options.saveTrace = envToBoolean(process.env.PLAYWRIGHT_MCP_SAVE_TRACE);
|
||||
options.storageState = envToString(process.env.PLAYWRIGHT_MCP_STORAGE_STATE);
|
||||
options.userAgent = envToString(process.env.PLAYWRIGHT_MCP_USER_AGENT);
|
||||
options.userDataDir = envToString(process.env.PLAYWRIGHT_MCP_USER_DATA_DIR);
|
||||
options.viewportSize = envToString(process.env.PLAYWRIGHT_MCP_VIEWPORT_SIZE);
|
||||
return configFromCLIOptions(options);
|
||||
}
|
||||
|
||||
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: FullConfig, name: string): Promise<string> {
|
||||
await fs.promises.mkdir(config.outputDir, { recursive: true });
|
||||
const fileName = sanitizeForFilePath(name);
|
||||
return path.join(config.outputDir, 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: FullConfig, overrides: Config): FullConfig {
|
||||
const browser: FullConfig['browser'] = {
|
||||
...pickDefined(base.browser),
|
||||
...pickDefined(overrides.browser),
|
||||
browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium',
|
||||
isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false,
|
||||
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),
|
||||
},
|
||||
server: {
|
||||
...pickDefined(base.server),
|
||||
...pickDefined(overrides.server),
|
||||
},
|
||||
} as FullConfig;
|
||||
}
|
||||
|
||||
export function semicolonSeparatedList(value: string | undefined): string[] | undefined {
|
||||
if (!value)
|
||||
return undefined;
|
||||
return value.split(';').map(v => v.trim());
|
||||
}
|
||||
|
||||
export function commaSeparatedList(value: string | undefined): string[] | undefined {
|
||||
if (!value)
|
||||
return undefined;
|
||||
return value.split(',').map(v => v.trim());
|
||||
}
|
||||
|
||||
function envToNumber(value: string | undefined): number | undefined {
|
||||
if (!value)
|
||||
return undefined;
|
||||
return +value;
|
||||
}
|
||||
|
||||
function envToBoolean(value: string | undefined): boolean | undefined {
|
||||
if (value === 'true' || value === '1')
|
||||
return true;
|
||||
if (value === 'false' || value === '0')
|
||||
return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function envToString(value: string | undefined): string | undefined {
|
||||
return value ? value.trim() : undefined;
|
||||
}
|
||||
@@ -1,96 +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 as McpServer } 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 } from './context.js';
|
||||
import { allTools } from './tools.js';
|
||||
import { packageJSON } from './package.js';
|
||||
|
||||
import { FullConfig } from './config.js';
|
||||
|
||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||
|
||||
export function createConnection(config: FullConfig, browserContextFactory: BrowserContextFactory): Connection {
|
||||
const tools = allTools.filter(tool => tool.capability.startsWith('core') || config.capabilities?.includes(tool.capability));
|
||||
const context = new Context(tools, config, browserContextFactory);
|
||||
const server = new McpServer({ 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));
|
||||
}
|
||||
});
|
||||
|
||||
return new Connection(server, context);
|
||||
}
|
||||
|
||||
export class Connection {
|
||||
readonly server: McpServer;
|
||||
readonly context: Context;
|
||||
|
||||
constructor(server: McpServer, context: Context) {
|
||||
this.server = server;
|
||||
this.context = context;
|
||||
this.server.oninitialized = () => {
|
||||
this.context.clientVersion = this.server.getClientVersion();
|
||||
};
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.server.close();
|
||||
await this.context.close();
|
||||
}
|
||||
}
|
||||
361
src/context.ts
361
src/context.ts
@@ -1,361 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import debug from 'debug';
|
||||
import * as playwright from 'playwright';
|
||||
|
||||
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
|
||||
import { ManualPromise } from './manualPromise.js';
|
||||
import { Tab } from './tab.js';
|
||||
import { outputFile } from './config.js';
|
||||
|
||||
import type { ModalState, Tool, ToolActionResult } from './tools/tool.js';
|
||||
import type { FullConfig } from './config.js';
|
||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||
|
||||
type PendingAction = {
|
||||
dialogShown: ManualPromise<void>;
|
||||
};
|
||||
|
||||
const testDebug = debug('pw:mcp:test');
|
||||
|
||||
export class Context {
|
||||
readonly tools: Tool[];
|
||||
readonly config: FullConfig;
|
||||
private _browserContextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> | undefined;
|
||||
private _browserContextFactory: BrowserContextFactory;
|
||||
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 }[] = [];
|
||||
clientVersion: { name: string; version: string; } | undefined;
|
||||
|
||||
constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) {
|
||||
this.tools = tools;
|
||||
this.config = config;
|
||||
this._browserContextFactory = browserContextFactory;
|
||||
testDebug('create context');
|
||||
}
|
||||
|
||||
clientSupportsImages(): boolean {
|
||||
if (this.config.imageResponses === 'omit')
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
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 or 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];
|
||||
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.title();
|
||||
const url = tab.page.url();
|
||||
const current = tab === this._currentTab ? ' (current)' : '';
|
||||
lines.push(`- ${i}:${current} [${title}] (${url})`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async closeTab(index: number | undefined) {
|
||||
const tab = index === undefined ? this._currentTab : this._tabs[index];
|
||||
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;
|
||||
|
||||
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.
|
||||
const actionResult = await this._raceAgainstModalDialogs(async () => {
|
||||
try {
|
||||
if (waitForNetwork)
|
||||
return await waitForCompletion(this, tab, async () => action?.()) ?? undefined;
|
||||
else
|
||||
return await action?.() ?? 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'),
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
const messages = tab.takeRecentConsoleMessages();
|
||||
if (messages.length) {
|
||||
result.push('', `### New console messages`);
|
||||
for (const message of messages)
|
||||
result.push(`- ${trim(message.toString(), 100)}`);
|
||||
}
|
||||
|
||||
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()} ...`);
|
||||
}
|
||||
}
|
||||
|
||||
if (captureSnapshot && tab.hasSnapshot()) {
|
||||
if (this.tabs().length > 1)
|
||||
result.push('', await this.listTabsMarkdown());
|
||||
|
||||
if (this.tabs().length > 1)
|
||||
result.push('', '### Current tab');
|
||||
else
|
||||
result.push('', '### Page state');
|
||||
|
||||
result.push(
|
||||
`- Page URL: ${tab.page.url()}`,
|
||||
`- Page Title: ${await tab.title()}`
|
||||
);
|
||||
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 new Promise(f => setTimeout(f, time));
|
||||
return;
|
||||
}
|
||||
|
||||
await callOnPageNoTrace(this._currentTab.page, page => {
|
||||
return page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
testDebug('close context');
|
||||
|
||||
const promise = this._browserContextPromise;
|
||||
this._browserContextPromise = undefined;
|
||||
|
||||
await promise.then(async ({ browserContext, close }) => {
|
||||
if (this.config.saveTrace)
|
||||
await browserContext.tracing.stop();
|
||||
await close();
|
||||
});
|
||||
}
|
||||
|
||||
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<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||
// TODO: move to the browser context factory to make it based on isolation mode.
|
||||
const result = await this._browserContextFactory.createContext();
|
||||
const { browserContext } = result;
|
||||
await this._setupRequestInterception(browserContext);
|
||||
for (const page of browserContext.pages())
|
||||
this._onPageCreated(page);
|
||||
browserContext.on('page', page => this._onPageCreated(page));
|
||||
if (this.config.saveTrace) {
|
||||
await browserContext.tracing.start({
|
||||
name: 'trace',
|
||||
screenshots: false,
|
||||
snapshots: true,
|
||||
sources: false,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
function trim(text: string, maxLength: number) {
|
||||
if (text.length <= maxLength)
|
||||
return text;
|
||||
return text.slice(0, maxLength) + '...';
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { FullConfig } from './config.js';
|
||||
|
||||
export function cacheDir() {
|
||||
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);
|
||||
return path.join(cacheDirectory, 'ms-playwright');
|
||||
}
|
||||
|
||||
export async function userDataDir(browserConfig: FullConfig['browser']) {
|
||||
return path.join(cacheDir(), 'ms-playwright', `mcp-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
|
||||
}
|
||||
@@ -1,232 +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 path from 'path';
|
||||
import http from 'http';
|
||||
import net from 'net';
|
||||
|
||||
import mime from 'mime';
|
||||
|
||||
import { ManualPromise } from './manualPromise.js';
|
||||
|
||||
|
||||
export type ServerRouteHandler = (request: http.IncomingMessage, response: http.ServerResponse) => void;
|
||||
|
||||
export type Transport = {
|
||||
sendEvent?: (method: string, params: any) => void;
|
||||
close?: () => void;
|
||||
onconnect: () => void;
|
||||
dispatch: (method: string, params: any) => Promise<any>;
|
||||
onclose: () => void;
|
||||
};
|
||||
|
||||
export class HttpServer {
|
||||
private _server: http.Server;
|
||||
private _urlPrefixPrecise: string = '';
|
||||
private _urlPrefixHumanReadable: string = '';
|
||||
private _port: number = 0;
|
||||
private _routes: { prefix?: string, exact?: string, handler: ServerRouteHandler }[] = [];
|
||||
|
||||
constructor() {
|
||||
this._server = http.createServer(this._onRequest.bind(this));
|
||||
decorateServer(this._server);
|
||||
}
|
||||
|
||||
server() {
|
||||
return this._server;
|
||||
}
|
||||
|
||||
routePrefix(prefix: string, handler: ServerRouteHandler) {
|
||||
this._routes.push({ prefix, handler });
|
||||
}
|
||||
|
||||
routePath(path: string, handler: ServerRouteHandler) {
|
||||
this._routes.push({ exact: path, handler });
|
||||
}
|
||||
|
||||
port(): number {
|
||||
return this._port;
|
||||
}
|
||||
|
||||
private async _tryStart(port: number | undefined, host: string) {
|
||||
const errorPromise = new ManualPromise();
|
||||
const errorListener = (error: Error) => errorPromise.reject(error);
|
||||
this._server.on('error', errorListener);
|
||||
|
||||
try {
|
||||
this._server.listen(port, host);
|
||||
await Promise.race([
|
||||
new Promise(cb => this._server!.once('listening', cb)),
|
||||
errorPromise,
|
||||
]);
|
||||
} finally {
|
||||
this._server.removeListener('error', errorListener);
|
||||
}
|
||||
}
|
||||
|
||||
async start(options: { port?: number, preferredPort?: number, host?: string } = {}): Promise<void> {
|
||||
const host = options.host || 'localhost';
|
||||
if (options.preferredPort) {
|
||||
try {
|
||||
await this._tryStart(options.preferredPort, host);
|
||||
} catch (e: any) {
|
||||
if (!e || !e.message || !e.message.includes('EADDRINUSE'))
|
||||
throw e;
|
||||
await this._tryStart(undefined, host);
|
||||
}
|
||||
} else {
|
||||
await this._tryStart(options.port, host);
|
||||
}
|
||||
|
||||
const address = this._server.address();
|
||||
if (typeof address === 'string') {
|
||||
this._urlPrefixPrecise = address;
|
||||
this._urlPrefixHumanReadable = address;
|
||||
} else {
|
||||
this._port = address!.port;
|
||||
const resolvedHost = address!.family === 'IPv4' ? address!.address : `[${address!.address}]`;
|
||||
this._urlPrefixPrecise = `http://${resolvedHost}:${address!.port}`;
|
||||
this._urlPrefixHumanReadable = `http://${host}:${address!.port}`;
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
await new Promise(cb => this._server!.close(cb));
|
||||
}
|
||||
|
||||
urlPrefix(purpose: 'human-readable' | 'precise'): string {
|
||||
return purpose === 'human-readable' ? this._urlPrefixHumanReadable : this._urlPrefixPrecise;
|
||||
}
|
||||
|
||||
serveFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string, headers?: { [name: string]: string }): boolean {
|
||||
try {
|
||||
for (const [name, value] of Object.entries(headers || {}))
|
||||
response.setHeader(name, value);
|
||||
if (request.headers.range)
|
||||
this._serveRangeFile(request, response, absoluteFilePath);
|
||||
else
|
||||
this._serveFile(response, absoluteFilePath);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_serveFile(response: http.ServerResponse, absoluteFilePath: string) {
|
||||
const content = fs.readFileSync(absoluteFilePath);
|
||||
response.statusCode = 200;
|
||||
const contentType = mime.getType(path.extname(absoluteFilePath)) || 'application/octet-stream';
|
||||
response.setHeader('Content-Type', contentType);
|
||||
response.setHeader('Content-Length', content.byteLength);
|
||||
response.end(content);
|
||||
}
|
||||
|
||||
_serveRangeFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string) {
|
||||
const range = request.headers.range;
|
||||
if (!range || !range.startsWith('bytes=') || range.includes(', ') || [...range].filter(char => char === '-').length !== 1) {
|
||||
response.statusCode = 400;
|
||||
return response.end('Bad request');
|
||||
}
|
||||
|
||||
// Parse the range header: https://datatracker.ietf.org/doc/html/rfc7233#section-2.1
|
||||
const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
|
||||
|
||||
// Both start and end (when passing to fs.createReadStream) and the range header are inclusive and start counting at 0.
|
||||
let start: number;
|
||||
let end: number;
|
||||
const size = fs.statSync(absoluteFilePath).size;
|
||||
if (startStr !== '' && endStr === '') {
|
||||
// No end specified: use the whole file
|
||||
start = +startStr;
|
||||
end = size - 1;
|
||||
} else if (startStr === '' && endStr !== '') {
|
||||
// No start specified: calculate start manually
|
||||
start = size - +endStr;
|
||||
end = size - 1;
|
||||
} else {
|
||||
start = +startStr;
|
||||
end = +endStr;
|
||||
}
|
||||
|
||||
// Handle unavailable range request
|
||||
if (Number.isNaN(start) || Number.isNaN(end) || start >= size || end >= size || start > end) {
|
||||
// Return the 416 Range Not Satisfiable: https://datatracker.ietf.org/doc/html/rfc7233#section-4.4
|
||||
response.writeHead(416, {
|
||||
'Content-Range': `bytes */${size}`
|
||||
});
|
||||
return response.end();
|
||||
}
|
||||
|
||||
// Sending Partial Content: https://datatracker.ietf.org/doc/html/rfc7233#section-4.1
|
||||
response.writeHead(206, {
|
||||
'Content-Range': `bytes ${start}-${end}/${size}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': end - start + 1,
|
||||
'Content-Type': mime.getType(path.extname(absoluteFilePath))!,
|
||||
});
|
||||
|
||||
const readable = fs.createReadStream(absoluteFilePath, { start, end });
|
||||
readable.pipe(response);
|
||||
}
|
||||
|
||||
private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
|
||||
if (request.method === 'OPTIONS') {
|
||||
response.writeHead(200);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
request.on('error', () => response.end());
|
||||
try {
|
||||
if (!request.url) {
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const url = new URL('http://localhost' + request.url);
|
||||
for (const route of this._routes) {
|
||||
if (route.exact && url.pathname === route.exact) {
|
||||
route.handler(request, response);
|
||||
return;
|
||||
}
|
||||
if (route.prefix && url.pathname.startsWith(route.prefix)) {
|
||||
route.handler(request, response);
|
||||
return;
|
||||
}
|
||||
}
|
||||
response.statusCode = 404;
|
||||
response.end();
|
||||
} catch (e) {
|
||||
response.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function decorateServer(server: net.Server) {
|
||||
const sockets = new Set<net.Socket>();
|
||||
server.on('connection', socket => {
|
||||
sockets.add(socket);
|
||||
socket.once('close', () => sockets.delete(socket));
|
||||
});
|
||||
|
||||
const close = server.close;
|
||||
server.close = (callback?: (err?: Error) => void) => {
|
||||
for (const socket of sockets)
|
||||
socket.destroy();
|
||||
sockets.clear();
|
||||
return close.call(server, callback);
|
||||
};
|
||||
}
|
||||
46
src/index.ts
46
src/index.ts
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createConnection as createConnectionImpl } from './connection.js';
|
||||
import type { Connection } from '../index.js';
|
||||
import { resolveConfig } from './config.js';
|
||||
import { contextFactory } from './browserContextFactory.js';
|
||||
|
||||
import type { Config } from '../config.js';
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||
|
||||
export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Connection> {
|
||||
const config = await resolveConfig(userConfig);
|
||||
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config.browser);
|
||||
return createConnectionImpl(config, factory);
|
||||
}
|
||||
|
||||
class SimpleBrowserContextFactory implements BrowserContextFactory {
|
||||
private readonly _contextGetter: () => Promise<BrowserContext>;
|
||||
|
||||
constructor(contextGetter: () => Promise<BrowserContext>) {
|
||||
this._contextGetter = contextGetter;
|
||||
}
|
||||
|
||||
async createContext(): Promise<{ browserContext: BrowserContext, close: () => Promise<void> }> {
|
||||
const browserContext = await this._contextGetter();
|
||||
return {
|
||||
browserContext,
|
||||
close: () => browserContext.close()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,55 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as playwright from 'playwright';
|
||||
import { callOnPageNoTrace } from './tools/utils.js';
|
||||
|
||||
type PageEx = playwright.Page & {
|
||||
_snapshotForAI: () => Promise<string>;
|
||||
};
|
||||
|
||||
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 snapshot = await callOnPageNoTrace(this._page, page => (page as PageEx)._snapshotForAI());
|
||||
this._text = [
|
||||
`- Page Snapshot:`,
|
||||
'```yaml',
|
||||
snapshot,
|
||||
'```',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
refLocator(params: { element: string, ref: string }): playwright.Locator {
|
||||
return this._page.locator(`aria-ref=${params.ref}`).describe(params.element);
|
||||
}
|
||||
}
|
||||
@@ -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, Option } from 'commander';
|
||||
// @ts-ignore
|
||||
import { startTraceViewerServer } from 'playwright-core/lib/server';
|
||||
|
||||
import { startHttpServer, startHttpTransport, startStdioTransport } from './transport.js';
|
||||
import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js';
|
||||
import { Server } from './server.js';
|
||||
import { packageJSON } from './package.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 additional capabilities to enable, possible values: vision, pdf.', commaSeparatedList)
|
||||
.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('--image-responses <mode>', 'whether to send image responses to the client. Can be "allow" or "omit", Defaults to "allow".')
|
||||
.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('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.')
|
||||
.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"')
|
||||
.addOption(new Option('--vision', 'Legacy option, use --caps=vision instead').hideHelp())
|
||||
.action(async options => {
|
||||
if (options.vision) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('The --vision option is deprecated, use --caps=vision instead');
|
||||
options.caps = 'vision';
|
||||
}
|
||||
const config = await resolveCLIConfig(options);
|
||||
const httpServer = config.server.port !== undefined ? await startHttpServer(config.server) : undefined;
|
||||
|
||||
const server = new Server(config);
|
||||
server.setupExitWatchdog();
|
||||
|
||||
if (httpServer)
|
||||
startHttpTransport(httpServer, server);
|
||||
else
|
||||
await startStdioTransport(server);
|
||||
|
||||
if (config.saveTrace) {
|
||||
const server = await startTraceViewerServer();
|
||||
const urlPrefix = server.urlPrefix('human-readable');
|
||||
const url = urlPrefix + '/trace/index.html?trace=' + config.browser.launchOptions.tracesDir + '/trace.json';
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('\nTrace viewer listening on ' + url);
|
||||
}
|
||||
});
|
||||
|
||||
void program.parseAsync(process.argv);
|
||||
@@ -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 { createConnection } from './connection.js';
|
||||
import { contextFactory } from './browserContextFactory.js';
|
||||
|
||||
import type { FullConfig } from './config.js';
|
||||
import type { Connection } from './connection.js';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||
|
||||
export class Server {
|
||||
readonly config: FullConfig;
|
||||
private _connectionList: Connection[] = [];
|
||||
private _browserConfig: FullConfig['browser'];
|
||||
private _contextFactory: BrowserContextFactory;
|
||||
|
||||
constructor(config: FullConfig) {
|
||||
this.config = config;
|
||||
this._browserConfig = config.browser;
|
||||
this._contextFactory = contextFactory(this._browserConfig);
|
||||
}
|
||||
|
||||
async createConnection(transport: Transport): Promise<Connection> {
|
||||
const connection = createConnection(this.config, this._contextFactory);
|
||||
this._connectionList.push(connection);
|
||||
await connection.server.connect(transport);
|
||||
return connection;
|
||||
}
|
||||
|
||||
setupExitWatchdog() {
|
||||
let isExiting = false;
|
||||
const handleExit = async () => {
|
||||
if (isExiting)
|
||||
return;
|
||||
isExiting = true;
|
||||
setTimeout(() => process.exit(0), 15000);
|
||||
await Promise.all(this._connectionList.map(connection => connection.close()));
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.stdin.on('close', handleExit);
|
||||
process.on('SIGINT', handleExit);
|
||||
process.on('SIGTERM', handleExit);
|
||||
}
|
||||
}
|
||||
164
src/tab.ts
164
src/tab.ts
@@ -1,164 +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 { callOnPageNoTrace } from './tools/utils.js';
|
||||
import { logUnhandledError } from './log.js';
|
||||
|
||||
import type { Context } from './context.js';
|
||||
|
||||
export class Tab {
|
||||
readonly context: Context;
|
||||
readonly page: playwright.Page;
|
||||
private _consoleMessages: ConsoleMessage[] = [];
|
||||
private _recentConsoleMessages: 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._handleConsoleMessage(messageToConsoleMessage(event)));
|
||||
page.on('pageerror', error => this._handleConsoleMessage(pageErrorToConsoleMessage(error)));
|
||||
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._recentConsoleMessages.length = 0;
|
||||
this._requests.clear();
|
||||
}
|
||||
|
||||
private _handleConsoleMessage(message: ConsoleMessage) {
|
||||
this._consoleMessages.push(message);
|
||||
this._recentConsoleMessages.push(message);
|
||||
}
|
||||
|
||||
private _onClose() {
|
||||
this._clearCollectedArtifacts();
|
||||
this._onPageClose(this);
|
||||
}
|
||||
|
||||
async title(): Promise<string> {
|
||||
return await callOnPageNoTrace(this.page, page => page.title());
|
||||
}
|
||||
|
||||
async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise<void> {
|
||||
await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(logUnhandledError));
|
||||
}
|
||||
|
||||
async navigate(url: string) {
|
||||
this._clearCollectedArtifacts();
|
||||
|
||||
const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(logUnhandledError));
|
||||
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, 1000)),
|
||||
]);
|
||||
if (!download)
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Cap load event to 5 seconds, the page is operational at this point.
|
||||
await this.waitForLoadState('load', { timeout: 5000 });
|
||||
}
|
||||
|
||||
hasSnapshot(): boolean {
|
||||
return !!this._snapshot;
|
||||
}
|
||||
|
||||
snapshotOrDie(): PageSnapshot {
|
||||
if (!this._snapshot)
|
||||
throw new Error('No snapshot available');
|
||||
return this._snapshot;
|
||||
}
|
||||
|
||||
consoleMessages(): ConsoleMessage[] {
|
||||
return this._consoleMessages;
|
||||
}
|
||||
|
||||
requests(): Map<playwright.Request, playwright.Response | null> {
|
||||
return this._requests;
|
||||
}
|
||||
|
||||
async captureSnapshot() {
|
||||
this._snapshot = await PageSnapshot.create(this.page);
|
||||
}
|
||||
|
||||
takeRecentConsoleMessages(): ConsoleMessage[] {
|
||||
const result = this._recentConsoleMessages.slice();
|
||||
this._recentConsoleMessages.length = 0;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export type ConsoleMessage = {
|
||||
type: ReturnType<playwright.ConsoleMessage['type']> | undefined;
|
||||
text: string;
|
||||
toString(): string;
|
||||
};
|
||||
|
||||
function messageToConsoleMessage(message: playwright.ConsoleMessage): ConsoleMessage {
|
||||
return {
|
||||
type: message.type(),
|
||||
text: message.text(),
|
||||
toString: () => `[${message.type().toUpperCase()}] ${message.text()} @ ${message.location().url}:${message.location().lineNumber}`,
|
||||
};
|
||||
}
|
||||
|
||||
function pageErrorToConsoleMessage(errorOrValue: Error | any): ConsoleMessage {
|
||||
if (errorOrValue instanceof Error) {
|
||||
return {
|
||||
type: undefined,
|
||||
text: errorOrValue.message,
|
||||
toString: () => errorOrValue.stack || errorOrValue.message,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: undefined,
|
||||
text: String(errorOrValue),
|
||||
toString: () => String(errorOrValue),
|
||||
};
|
||||
}
|
||||
51
src/tools.ts
51
src/tools.ts
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import common from './tools/common.js';
|
||||
import console from './tools/console.js';
|
||||
import dialogs from './tools/dialogs.js';
|
||||
import evaluate from './tools/evaluate.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 wait from './tools/wait.js';
|
||||
import mouse from './tools/mouse.js';
|
||||
|
||||
import type { Tool } from './tools/tool.js';
|
||||
|
||||
export const allTools: Tool<any>[] = [
|
||||
...common,
|
||||
...console,
|
||||
...dialogs,
|
||||
...evaluate,
|
||||
...files,
|
||||
...install,
|
||||
...keyboard,
|
||||
...navigate,
|
||||
...network,
|
||||
...mouse,
|
||||
...pdf,
|
||||
...screenshot,
|
||||
...snapshot,
|
||||
...tabs,
|
||||
...wait,
|
||||
];
|
||||
@@ -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 } 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 = 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: true,
|
||||
waitForNetwork: true
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
close,
|
||||
resize
|
||||
];
|
||||
@@ -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.toString()).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 } from './tool.js';
|
||||
|
||||
const handleDialog = 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: true,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
|
||||
clearsModalState: 'dialog',
|
||||
});
|
||||
|
||||
export default [
|
||||
handleDialog,
|
||||
];
|
||||
@@ -1,71 +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';
|
||||
|
||||
import type * as playwright from 'playwright';
|
||||
|
||||
const evaluateSchema = z.object({
|
||||
function: z.string().describe('() => { /* code */ } or (element) => { /* code */ } when element is provided'),
|
||||
element: z.string().optional().describe('Human-readable element description used to obtain permission to interact with the element'),
|
||||
ref: z.string().optional().describe('Exact target element reference from the page snapshot'),
|
||||
});
|
||||
|
||||
const evaluate = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_evaluate',
|
||||
title: 'Evaluate JavaScript',
|
||||
description: 'Evaluate JavaScript expression on page or element',
|
||||
inputSchema: evaluateSchema,
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const tab = context.currentTabOrDie();
|
||||
const code: string[] = [];
|
||||
|
||||
let locator: playwright.Locator | undefined;
|
||||
if (params.ref && params.element) {
|
||||
const snapshot = tab.snapshotOrDie();
|
||||
locator = snapshot.refLocator({ ref: params.ref, element: params.element });
|
||||
code.push(`await page.${await generateLocator(locator)}.evaluate(${javascript.quote(params.function)});`);
|
||||
} else {
|
||||
code.push(`await page.evaluate(${javascript.quote(params.function)});`);
|
||||
}
|
||||
|
||||
return {
|
||||
code,
|
||||
action: async () => {
|
||||
const receiver = locator ?? tab.page as any;
|
||||
const result = await receiver._evaluateFunction(params.function);
|
||||
return {
|
||||
content: [{ type: 'text', text: '- Result: ' + (JSON.stringify(result, null, 2) || 'undefined') }],
|
||||
};
|
||||
},
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
evaluate,
|
||||
];
|
||||
@@ -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';
|
||||
|
||||
const uploadFile = defineTool({
|
||||
capability: 'core',
|
||||
|
||||
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: true,
|
||||
waitForNetwork: true,
|
||||
};
|
||||
},
|
||||
clearsModalState: 'fileChooser',
|
||||
});
|
||||
|
||||
export default [
|
||||
uploadFile,
|
||||
];
|
||||
@@ -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: 'core-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,107 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { defineTool } from './tool.js';
|
||||
import { elementSchema } from './snapshot.js';
|
||||
import { generateLocator } from './utils.js';
|
||||
import * as javascript from '../javascript.js';
|
||||
|
||||
const pressKey = 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: 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);
|
||||
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
pressKey,
|
||||
type,
|
||||
];
|
||||
@@ -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 } from './tool.js';
|
||||
|
||||
const elementSchema = z.object({
|
||||
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
|
||||
});
|
||||
|
||||
const mouseMove = defineTool({
|
||||
capability: 'vision',
|
||||
schema: {
|
||||
name: 'browser_mouse_move_xy',
|
||||
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 mouseClick = defineTool({
|
||||
capability: 'vision',
|
||||
schema: {
|
||||
name: 'browser_mouse_click_xy',
|
||||
title: 'Click',
|
||||
description: 'Click left mouse button at a given position',
|
||||
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 mouseDrag = defineTool({
|
||||
capability: 'vision',
|
||||
schema: {
|
||||
name: 'browser_mouse_drag_xy',
|
||||
title: 'Drag mouse',
|
||||
description: 'Drag left mouse button to a given position',
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
mouseMove,
|
||||
mouseClick,
|
||||
mouseDrag,
|
||||
];
|
||||
@@ -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 } from './tool.js';
|
||||
|
||||
const navigate = 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: true,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const goBack = defineTool({
|
||||
capability: 'core',
|
||||
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: true,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const goForward = defineTool({
|
||||
capability: 'core',
|
||||
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: true,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
navigate,
|
||||
goBack,
|
||||
goForward,
|
||||
];
|
||||
@@ -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({ element: params.element || '', ref: 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.clientSupportsImages();
|
||||
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: false,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export default [
|
||||
screenshot,
|
||||
];
|
||||
@@ -1,188 +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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export 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 clickSchema = elementSchema.extend({
|
||||
doubleClick: z.boolean().optional().describe('Whether to perform a double click instead of a single click'),
|
||||
button: z.enum(['left', 'right', 'middle']).optional().describe('Button to click, defaults to left'),
|
||||
});
|
||||
|
||||
const click = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_click',
|
||||
title: 'Click',
|
||||
description: 'Perform click on a web page',
|
||||
inputSchema: clickSchema,
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
const tab = context.currentTabOrDie();
|
||||
const locator = tab.snapshotOrDie().refLocator(params);
|
||||
const button = params.button;
|
||||
const buttonAttr = button ? `{ button: '${button}' }` : '';
|
||||
|
||||
const code: string[] = [];
|
||||
if (params.doubleClick) {
|
||||
code.push(`// Double click ${params.element}`);
|
||||
code.push(`await page.${await generateLocator(locator)}.dblclick(${buttonAttr});`);
|
||||
} else {
|
||||
code.push(`// Click ${params.element}`);
|
||||
code.push(`await page.${await generateLocator(locator)}.click(${buttonAttr});`);
|
||||
}
|
||||
|
||||
return {
|
||||
code,
|
||||
action: () => params.doubleClick ? locator.dblclick({ button }) : locator.click({ button }),
|
||||
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({ ref: params.startRef, element: params.startElement });
|
||||
const endLocator = snapshot.refLocator({ ref: params.endRef, element: params.endElement });
|
||||
|
||||
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);
|
||||
|
||||
const code = [
|
||||
`// Hover over ${params.element}`,
|
||||
`await page.${await generateLocator(locator)}.hover();`
|
||||
];
|
||||
|
||||
return {
|
||||
code,
|
||||
action: () => locator.hover(),
|
||||
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);
|
||||
|
||||
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,
|
||||
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 } from './tool.js';
|
||||
|
||||
const listTabs = defineTool({
|
||||
capability: 'core-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 = defineTool({
|
||||
capability: 'core-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: true,
|
||||
waitForNetwork: false
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const newTab = defineTool({
|
||||
capability: 'core-tabs',
|
||||
|
||||
schema: {
|
||||
name: 'browser_tab_new',
|
||||
title: 'Open a new tab',
|
||||
description: 'Open a new tab',
|
||||
inputSchema: z.object({
|
||||
url: z.string().optional().describe('The URL to navigate to in the new tab. If not provided, the new tab will be blank.'),
|
||||
}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
await context.newTab();
|
||||
if (params.url)
|
||||
await context.currentTabOrDie().navigate(params.url);
|
||||
|
||||
const code = [
|
||||
`// <internal code to open a new tab>`,
|
||||
];
|
||||
return {
|
||||
code,
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: false
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const closeTab = defineTool({
|
||||
capability: 'core-tabs',
|
||||
|
||||
schema: {
|
||||
name: 'browser_tab_close',
|
||||
title: 'Close a tab',
|
||||
description: 'Close a tab',
|
||||
inputSchema: z.object({
|
||||
index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
await context.closeTab(params.index);
|
||||
const code = [
|
||||
`// <internal code to close tab ${params.index}>`,
|
||||
];
|
||||
return {
|
||||
code,
|
||||
captureSnapshot: true,
|
||||
waitForNetwork: false
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
listTabs,
|
||||
newTab,
|
||||
selectTab,
|
||||
closeTab,
|
||||
];
|
||||
@@ -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 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 function defineTool<Input extends InputType>(tool: Tool<Input>): Tool<Input> {
|
||||
return tool;
|
||||
}
|
||||
@@ -1,94 +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.
|
||||
*/
|
||||
|
||||
// @ts-ignore
|
||||
import { asLocator } from 'playwright-core/lib/utils';
|
||||
|
||||
import type * as playwright from 'playwright';
|
||||
import type { Context } from '../context.js';
|
||||
import type { Tab } from '../tab.js';
|
||||
|
||||
export async function waitForCompletion<R>(context: Context, tab: Tab, 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 tab.waitForLoadState('load').then(waitCallback);
|
||||
};
|
||||
|
||||
const onTimeout = () => {
|
||||
dispose();
|
||||
waitCallback();
|
||||
};
|
||||
|
||||
tab.page.on('request', requestListener);
|
||||
tab.page.on('requestfinished', requestFinishedListener);
|
||||
tab.page.on('framenavigated', frameNavigateListener);
|
||||
const timeout = setTimeout(onTimeout, 10000);
|
||||
|
||||
const dispose = () => {
|
||||
tab.page.off('request', requestListener);
|
||||
tab.page.off('requestfinished', requestFinishedListener);
|
||||
tab.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> {
|
||||
try {
|
||||
const { resolvedSelector } = await (locator as any)._resolveSelector();
|
||||
return asLocator('javascript', resolvedSelector);
|
||||
} catch (e) {
|
||||
throw new Error('Ref not found, likely because element was removed. Use browser_snapshot to see what elements are currently on the page.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function callOnPageNoTrace<T>(page: playwright.Page, callback: (page: playwright.Page) => Promise<T>): Promise<T> {
|
||||
return await (page as any)._wrapApiCall(() => callback(page), { internal: true });
|
||||
}
|
||||
@@ -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 } from './tool.js';
|
||||
|
||||
const wait = defineTool({
|
||||
capability: 'core',
|
||||
|
||||
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(30000, 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: true,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
wait,
|
||||
];
|
||||
149
src/transport.ts
149
src/transport.ts
@@ -1,149 +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 debug from 'debug';
|
||||
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 type { AddressInfo } from 'node:net';
|
||||
import type { Server } from './server.js';
|
||||
|
||||
export async function startStdioTransport(server: Server) {
|
||||
await server.createConnection(new StdioServerTransport());
|
||||
}
|
||||
|
||||
const testDebug = debug('pw:mcp:test');
|
||||
|
||||
async function handleSSE(server: Server, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>) {
|
||||
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);
|
||||
testDebug(`create SSE session: ${transport.sessionId}`);
|
||||
const connection = await server.createConnection(transport);
|
||||
res.on('close', () => {
|
||||
testDebug(`delete SSE session: ${transport.sessionId}`);
|
||||
sessions.delete(transport.sessionId);
|
||||
// eslint-disable-next-line no-console
|
||||
void connection.close().catch(e => console.error(e));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 405;
|
||||
res.end('Method not allowed');
|
||||
}
|
||||
|
||||
async function handleStreamable(server: Server, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, StreamableHTTPServerTransport>) {
|
||||
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);
|
||||
};
|
||||
await server.createConnection(transport);
|
||||
await transport.handleRequest(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 400;
|
||||
res.end('Invalid request');
|
||||
}
|
||||
|
||||
export async function startHttpServer(config: { host?: string, port?: number }): Promise<http.Server> {
|
||||
const { host, port } = config;
|
||||
const httpServer = http.createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
httpServer.on('error', reject);
|
||||
httpServer.listen(port, host, () => {
|
||||
resolve();
|
||||
httpServer.removeListener('error', reject);
|
||||
});
|
||||
});
|
||||
return httpServer;
|
||||
}
|
||||
|
||||
export function startHttpTransport(httpServer: http.Server, mcpServer: Server) {
|
||||
const sseSessions = new Map<string, SSEServerTransport>();
|
||||
const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
|
||||
httpServer.on('request', async (req, res) => {
|
||||
const url = new URL(`http://localhost${req.url}`);
|
||||
if (url.pathname.startsWith('/mcp'))
|
||||
await handleStreamable(mcpServer, req, res, streamableSessions);
|
||||
else
|
||||
await handleSSE(mcpServer, req, res, url, sseSessions);
|
||||
});
|
||||
const url = httpAddressToString(httpServer.address());
|
||||
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.error(message);
|
||||
}
|
||||
|
||||
export function httpAddressToString(address: string | AddressInfo | null): string {
|
||||
assert(address, 'Could not bind server socket');
|
||||
if (typeof address === 'string')
|
||||
return address;
|
||||
const resolvedPort = address.port;
|
||||
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
|
||||
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
|
||||
resolvedHost = 'localhost';
|
||||
return `http://${resolvedHost}:${resolvedPort}`;
|
||||
}
|
||||
@@ -1,93 +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 path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test('cdp server', async ({ cdpServer, startClient, server }) => {
|
||||
await cdpServer.start();
|
||||
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
|
||||
});
|
||||
|
||||
test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
|
||||
const browserContext = await cdpServer.start();
|
||||
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
||||
|
||||
const [page] = browserContext.pages();
|
||||
await page.goto(server.HELLO_WORLD);
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Hello, world!',
|
||||
ref: 'f0',
|
||||
},
|
||||
})).toHaveTextContent(`Error: No current snapshot available. Capture a snapshot or 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 state
|
||||
- Page URL: ${server.HELLO_WORLD}
|
||||
- Page Title: Title
|
||||
- Page Snapshot:
|
||||
\`\`\`yaml
|
||||
- generic [active] [ref=e1]: Hello, world!
|
||||
\`\`\`
|
||||
`);
|
||||
});
|
||||
|
||||
test('should throw connection error and allow re-connecting', async ({ cdpServer, startClient, server }) => {
|
||||
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
||||
|
||||
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 cdpServer.start();
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
|
||||
});
|
||||
|
||||
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||
const __filename = url.fileURLToPath(import.meta.url);
|
||||
|
||||
test('does not support --device', async () => {
|
||||
const result = spawnSync('node', [
|
||||
path.join(__filename, '../../cli.js'), '--device=Pixel 5', '--cdp-endpoint=http://localhost:1234',
|
||||
]);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr.toString()).toContain('Device emulation is not supported with cdpEndpoint.');
|
||||
});
|
||||
@@ -1,119 +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_click', async ({ client, server, mcpBrowser }) => {
|
||||
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 state
|
||||
- Page URL: ${server.PREFIX}
|
||||
- Page Title: Title
|
||||
- Page Snapshot:
|
||||
\`\`\`yaml
|
||||
- button "Submit" ${mcpBrowser !== 'webkit' || process.platform === 'linux' ? '[active] ' : ''}[ref=e2]
|
||||
\`\`\`
|
||||
`);
|
||||
});
|
||||
|
||||
test('browser_click (double)', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<title>Title</title>
|
||||
<script>
|
||||
function handle() {
|
||||
document.querySelector('h1').textContent = 'Double clicked';
|
||||
}
|
||||
</script>
|
||||
<h1 ondblclick="handle()">Click me</h1>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Click me',
|
||||
ref: 'e2',
|
||||
doubleClick: true,
|
||||
},
|
||||
})).toHaveTextContent(`
|
||||
### Ran Playwright code
|
||||
\`\`\`js
|
||||
// Double click Click me
|
||||
await page.getByRole('heading', { name: 'Click me' }).dblclick();
|
||||
\`\`\`
|
||||
|
||||
### Page state
|
||||
- Page URL: ${server.PREFIX}
|
||||
- Page Title: Title
|
||||
- Page Snapshot:
|
||||
\`\`\`yaml
|
||||
- heading "Double clicked" [level=1] [ref=e3]
|
||||
\`\`\`
|
||||
`);
|
||||
});
|
||||
|
||||
test('browser_click (right)', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<button oncontextmenu="handle">Menu</button>
|
||||
<script>
|
||||
document.addEventListener('contextmenu', event => {
|
||||
event.preventDefault();
|
||||
document.querySelector('button').textContent = 'Right clicked';
|
||||
});
|
||||
</script>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
const result = await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Menu',
|
||||
ref: 'e2',
|
||||
button: 'right',
|
||||
},
|
||||
});
|
||||
expect(result).toContainTextContent(`await page.getByRole('button', { name: 'Menu' }).click({ button: 'right' });`);
|
||||
expect(result).toContainTextContent(`- button "Right clicked"`);
|
||||
});
|
||||
@@ -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 fs from 'node:fs';
|
||||
|
||||
import { Config } from '../config.js';
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test('config user data dir', async ({ startClient, server, mcpMode }, testInfo) => {
|
||||
server.setContent('/', `
|
||||
<title>Title</title>
|
||||
<body>Hello, world!</body>
|
||||
`, 'text/html');
|
||||
|
||||
const config: Config = {
|
||||
browser: {
|
||||
userDataDir: testInfo.outputPath('user-data-dir'),
|
||||
},
|
||||
};
|
||||
const configPath = testInfo.outputPath('config.json');
|
||||
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
const { client } = await startClient({ args: ['--config', configPath] });
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
})).toContainTextContent(`Hello, world!`);
|
||||
|
||||
const files = await fs.promises.readdir(config.browser!.userDataDir!);
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test.describe(() => {
|
||||
test.use({ mcpBrowser: '' });
|
||||
test('browserName', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/458' } }, async ({ startClient, mcpMode }, testInfo) => {
|
||||
const config: Config = {
|
||||
browser: {
|
||||
browserName: 'firefox',
|
||||
},
|
||||
};
|
||||
const configPath = testInfo.outputPath('config.json');
|
||||
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
const { client } = await startClient({ args: ['--config', configPath] });
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: 'data:text/html,<script>document.title = navigator.userAgent</script>' },
|
||||
})).toContainTextContent(`Firefox`);
|
||||
});
|
||||
});
|
||||
@@ -1,96 +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! @ ${server.PREFIX}:4`,
|
||||
`[ERROR] Error @ ${server.PREFIX}:5`,
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
test('browser_console_messages (page error)', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<script>
|
||||
throw new Error("Error in script");
|
||||
</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(/Error: Error in script/);
|
||||
expect(resource).toHaveTextContent(new RegExp(server.PREFIX));
|
||||
});
|
||||
|
||||
test('recent console messages', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<button onclick="console.log('Hello, world!');">Click me</button>
|
||||
</html>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: server.PREFIX,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Click me',
|
||||
ref: 'e2',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response).toContainTextContent(`
|
||||
### New console messages
|
||||
- [LOG] Hello, world! @`);
|
||||
});
|
||||
@@ -1,265 +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_navigate', async ({ client, server }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toHaveTextContent(`
|
||||
### Ran Playwright code
|
||||
\`\`\`js
|
||||
// Navigate to ${server.HELLO_WORLD}
|
||||
await page.goto('${server.HELLO_WORLD}');
|
||||
\`\`\`
|
||||
|
||||
### Page state
|
||||
- Page URL: ${server.HELLO_WORLD}
|
||||
- Page Title: Title
|
||||
- Page Snapshot:
|
||||
\`\`\`yaml
|
||||
- generic [active] [ref=e1]: Hello, world!
|
||||
\`\`\`
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
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 state
|
||||
- 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 state
|
||||
- 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,
|
||||
},
|
||||
});
|
||||
const response = await client.callTool({
|
||||
name: 'browser_console_messages',
|
||||
});
|
||||
expect(response).toHaveTextContent(/\[LOG\] Key pressed: H Text: /);
|
||||
expect(response).toHaveTextContent(/\[LOG\] Key pressed: i Text: H/);
|
||||
expect(response).toHaveTextContent(/\[LOG\] Key pressed: ! Text: Hi/);
|
||||
expect(response).toHaveTextContent(/\[LOG\] Key pressed: Enter Text: Hi!/);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
test('old locator error message', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<button>Button 1</button>
|
||||
<button>Button 2</button>
|
||||
<script>
|
||||
document.querySelector('button').addEventListener('click', () => {
|
||||
document.querySelectorAll('button')[1].remove();
|
||||
});
|
||||
</script>
|
||||
`, 'text/html');
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: server.PREFIX,
|
||||
},
|
||||
})).toContainTextContent(`
|
||||
- button "Button 1" [ref=e2]
|
||||
- button "Button 2" [ref=e3]
|
||||
`.trim());
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Button 1',
|
||||
ref: 'e2',
|
||||
},
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Button 2',
|
||||
ref: 'e3',
|
||||
},
|
||||
})).toContainTextContent('Ref not found');
|
||||
});
|
||||
|
||||
test('visibility: hidden > visible should be shown', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/535' } }, async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<div style="visibility: hidden;">
|
||||
<div style="visibility: visible;">
|
||||
<button>Button</button>
|
||||
</div>
|
||||
</div>
|
||||
`, 'text/html');
|
||||
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.PREFIX },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_snapshot'
|
||||
})).toContainTextContent('- button "Button"');
|
||||
});
|
||||
@@ -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, mcpMode }) => {
|
||||
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`);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user