Compare commits
167 Commits
copilot/fi
...
v0.0.60-2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
542b74d2b4 | ||
|
|
15c299778a | ||
|
|
5e0ac89c28 | ||
|
|
9e176c409f | ||
|
|
44fa8026c9 | ||
|
|
d6414a6426 | ||
|
|
c8a520fc48 | ||
|
|
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 |
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
|
||||
|
||||
44
.github/workflows/copilot-setup-steps.yml
vendored
@@ -1,44 +0,0 @@
|
||||
name: "Copilot Setup Steps"
|
||||
|
||||
# Automatically run the setup steps when they are changed to allow for easy validation, and
|
||||
# allow manual testing through the repository's "Actions" tab
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
|
||||
jobs:
|
||||
# The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot.
|
||||
copilot-setup-steps:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Set the permissions to the lowest permissions possible needed for your steps.
|
||||
# Copilot will be given its own token for its operations.
|
||||
permissions:
|
||||
# If you want to clone the repository as part of your setup steps, for example to install dependencies, you'll need the `contents: read` permission. If you don't clone the repository in your setup steps, Copilot will do this for you automatically after the steps complete.
|
||||
contents: read
|
||||
|
||||
# You can define any steps you want, and they will run before the agent starts.
|
||||
# If you do not check out your code, Copilot will do this for you.
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18.19"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install JavaScript dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Playwright install
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
129
.github/workflows/publish.yml
vendored
@@ -1,36 +1,108 @@
|
||||
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-cli-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 publish
|
||||
working-directory: ./packages/playwright-cli
|
||||
|
||||
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 +119,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 +139,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
|
||||
|
||||
1
.gitignore
vendored
@@ -1,4 +1,3 @@
|
||||
lib/
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
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
@@ -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"]
|
||||
|
||||
3
LICENSE
@@ -186,8 +186,7 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Portions Copyright (c) Microsoft Corporation.
|
||||
Portions Copyright 2017 Google Inc.
|
||||
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.
|
||||
|
||||
755
README.md
@@ -2,6 +2,14 @@
|
||||
|
||||
A Model Context Protocol (MCP) server that provides browser automation capabilities using [Playwright](https://playwright.dev). This server enables LLMs to interact with web pages through structured accessibility snapshots, bypassing the need for screenshots or visually-tuned models.
|
||||
|
||||
### Playwright MCP vs Playwright CLI
|
||||
|
||||
This package provides MCP interface into Playwright. If you are using a **coding agent**, you might benefit from using the [CLI+SKILLS](https://github.com/microsoft/playwright-cli) instead.
|
||||
|
||||
- **CLI**: Modern **coding agents** increasingly favor CLI–based workflows exposed as SKILLs over MCP because CLI invocations are more token-efficient: they avoid loading large tool schemas and verbose accessibility trees into the model context, allowing agents to act through concise, purpose-built commands. This makes CLI + SKILLs better suited for high-throughput coding agents that must balance browser automation with large codebases, tests, and reasoning within limited context windows.<br>**Learn more about [Playwright CLI with SKILLS](https://github.com/microsoft/playwright-cli)**.
|
||||
|
||||
- **MCP**: MCP remains relevant for specialized agentic loops that benefit from persistent state, rich introspection, and iterative reasoning over page structure, such as exploratory automation, self-healing tests, or long-running autonomous workflows where maintaining continuous browser context outweighs token cost concerns.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Fast and lightweight**. Uses Playwright's accessibility tree, not pixel-based input.
|
||||
@@ -38,6 +46,31 @@ First, install the Playwright MCP server with your client.
|
||||
|
||||
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
|
||||
|
||||
<details>
|
||||
<summary>Amp</summary>
|
||||
|
||||
Add via the Amp VS Code extension settings screen or by updating your settings.json file:
|
||||
|
||||
```json
|
||||
"amp.mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Amp CLI Setup:**
|
||||
|
||||
Add via the `amp mcp add`command below
|
||||
|
||||
```bash
|
||||
amp mcp add playwright -- npx @playwright/mcp@latest
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Claude Code</summary>
|
||||
@@ -56,16 +89,112 @@ Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user),
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Cline</summary>
|
||||
|
||||
Follow the instruction in the section [Configuring MCP Servers](https://docs.cline.bot/mcp/configuring-mcp-servers)
|
||||
|
||||
**Example: Local Setup**
|
||||
|
||||
Add the following to your [`cline_mcp_settings.json`](https://docs.cline.bot/mcp/configuring-mcp-servers#editing-mcp-settings-files) file:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"timeout": 30,
|
||||
"args": [
|
||||
"-y",
|
||||
"@playwright/mcp@latest"
|
||||
],
|
||||
"disabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Codex</summary>
|
||||
|
||||
Use the Codex CLI to add the Playwright MCP server:
|
||||
|
||||
```bash
|
||||
codex mcp add playwright npx "@playwright/mcp@latest"
|
||||
```
|
||||
|
||||
Alternatively, create or edit the configuration file `~/.codex/config.toml` and add:
|
||||
|
||||
```toml
|
||||
[mcp_servers.playwright]
|
||||
command = "npx"
|
||||
args = ["@playwright/mcp@latest"]
|
||||
```
|
||||
|
||||
For more information, see the [Codex MCP documentation](https://github.com/openai/codex/blob/main/codex-rs/config.md#mcp_servers).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Copilot</summary>
|
||||
|
||||
Use the Copilot CLI to interactively add the Playwright MCP server:
|
||||
|
||||
```bash
|
||||
/mcp add
|
||||
```
|
||||
|
||||
Alternatively, create or edit the configuration file `~/.copilot/mcp-config.json` and add:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"type": "local",
|
||||
"command": "npx",
|
||||
"tools": [
|
||||
"*"
|
||||
],
|
||||
"args": [
|
||||
"@playwright/mcp@latest"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For more information, see the [Copilot CLI documentation](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Cursor</summary>
|
||||
|
||||
#### Click the button to install:
|
||||
|
||||
[](https://cursor.com/install-mcp?name=playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
|
||||
[<img src="https://cursor.com/deeplink/mcp-install-dark.svg" alt="Install in Cursor">](https://cursor.com/en/install-mcp?name=Playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
|
||||
|
||||
#### Or install manually:
|
||||
|
||||
Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, use `command` type with the command `npx @playwright/mcp`. You can also verify config or add command like arguments via clicking `Edit`.
|
||||
Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, use `command` type with the command `npx @playwright/mcp@latest`. You can also verify config or add command like arguments via clicking `Edit`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Factory</summary>
|
||||
|
||||
Use the Factory CLI to add the Playwright MCP server:
|
||||
|
||||
```bash
|
||||
droid mcp add playwright "npx @playwright/mcp@latest"
|
||||
```
|
||||
|
||||
Alternatively, type `/mcp` within Factory droid to open an interactive UI for managing MCP servers.
|
||||
|
||||
For more information, see the [Factory MCP documentation](https://docs.factory.ai/cli/configuration/mcp).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -88,6 +217,25 @@ Follow the MCP install [guide](https://github.com/google-gemini/gemini-cli/blob/
|
||||
Go to `Advanced settings` -> `Extensions` -> `Add custom extension`. Name to your liking, use type `STDIO`, and set the `command` to `npx @playwright/mcp`. Click "Add Extension".
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Kiro</summary>
|
||||
|
||||
Follow the MCP Servers [documentation](https://kiro.dev/docs/mcp/). For example in `.kiro/settings/mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>LM Studio</summary>
|
||||
|
||||
@@ -100,6 +248,29 @@ Go to `Advanced settings` -> `Extensions` -> `Add custom extension`. Name to you
|
||||
Go to `Program` in the right sidebar -> `Install` -> `Edit mcp.json`. Use the standard config above.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>opencode</summary>
|
||||
|
||||
Follow the MCP Servers [documentation](https://opencode.ai/docs/mcp-servers/). For example in `~/.config/opencode/opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"playwright": {
|
||||
"type": "local",
|
||||
"command": [
|
||||
"npx",
|
||||
"@playwright/mcp@latest"
|
||||
],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Qodo Gen</summary>
|
||||
|
||||
@@ -127,6 +298,27 @@ code --add-mcp '{"name":"playwright","command":"npx","args":["@playwright/mcp@la
|
||||
After installation, the Playwright MCP server will be available for use with your GitHub Copilot agent in VS Code.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Warp</summary>
|
||||
|
||||
Go to `Settings` -> `AI` -> `Manage MCP Servers` -> `+ Add` to [add an MCP Server](https://docs.warp.dev/knowledge-and-collaboration/mcp#adding-an-mcp-server). Use the standard config above.
|
||||
|
||||
Alternatively, use the slash command `/add-mcp` in the Warp prompt and paste the standard config from above:
|
||||
```js
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Windsurf</summary>
|
||||
|
||||
@@ -140,60 +332,56 @@ Playwright MCP server supports following arguments. They can be provided in the
|
||||
|
||||
<!--- Options generated by update-readme.js -->
|
||||
|
||||
```
|
||||
> npx @playwright/mcp@latest --help
|
||||
--allowed-origins <origins> semicolon-separated list of origins to allow the
|
||||
browser to request. Default is to allow all.
|
||||
--blocked-origins <origins> semicolon-separated list of origins to block the
|
||||
browser from requesting. Blocklist is evaluated
|
||||
before allowlist. If used without the allowlist,
|
||||
requests not matching the blocklist are still
|
||||
allowed.
|
||||
--block-service-workers block service workers
|
||||
--browser <browser> browser or chrome channel to use, possible
|
||||
values: chrome, firefox, webkit, msedge.
|
||||
--caps <caps> comma-separated list of additional capabilities
|
||||
to enable, possible values: vision, pdf.
|
||||
--cdp-endpoint <endpoint> CDP endpoint to connect to.
|
||||
--config <path> path to the configuration file.
|
||||
--device <device> device to emulate, for example: "iPhone 15"
|
||||
--executable-path <path> path to the browser executable.
|
||||
--headless run browser in headless mode, headed by default
|
||||
--host <host> host to bind server to. Default is localhost. Use
|
||||
0.0.0.0 to bind to all interfaces.
|
||||
--ignore-https-errors ignore https errors
|
||||
--isolated keep the browser profile in memory, do not save
|
||||
it to disk.
|
||||
--image-responses <mode> whether to send image responses to the client.
|
||||
Can be "allow" or "omit", Defaults to "allow".
|
||||
--no-sandbox disable the sandbox for all process types that
|
||||
are normally sandboxed.
|
||||
--output-dir <path> path to the directory for output files.
|
||||
--permissions <permissions> comma-separated list of permissions to grant, for
|
||||
example "clipboard-read,clipboard-write"
|
||||
--port <port> port to listen on for SSE transport.
|
||||
--proxy-bypass <bypass> comma-separated domains to bypass proxy, for
|
||||
example ".com,chromium.org,.domain.com"
|
||||
--proxy-server <proxy> specify proxy server, for example
|
||||
"http://myproxy:3128" or "socks5://myproxy:8080"
|
||||
--save-session Whether to save the Playwright MCP session into
|
||||
the output directory.
|
||||
--save-trace Whether to save the Playwright Trace of the
|
||||
session into the output directory.
|
||||
--storage-state <path> path to the storage state file for isolated
|
||||
sessions.
|
||||
--user-agent <ua string> specify user agent string
|
||||
--user-data-dir <path> path to the user data directory. If not
|
||||
specified, a temporary directory will be created.
|
||||
--viewport-size <size> specify browser viewport size in pixels, for
|
||||
example "1280, 720"
|
||||
```
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| --allowed-hosts <hosts...> | comma-separated list of hosts this server is allowed to serve from. Defaults to the host the server is bound to. Pass '*' to disable the host check.<br>*env* `PLAYWRIGHT_MCP_ALLOWED_HOSTS` |
|
||||
| --allowed-origins <origins> | semicolon-separated list of TRUSTED origins to allow the browser to request. Default is to allow all. Important: *does not* serve as a security boundary and *does not* affect redirects.<br>*env* `PLAYWRIGHT_MCP_ALLOWED_ORIGINS` |
|
||||
| --allow-unrestricted-file-access | allow access to files outside of the workspace roots. Also allows unrestricted access to file:// URLs. By default access to file system is restricted to workspace root directories (or cwd if no roots are configured) only, and navigation to file:// URLs is blocked.<br>*env* `PLAYWRIGHT_MCP_ALLOW_UNRESTRICTED_FILE_ACCESS` |
|
||||
| --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. Important: *does not* serve as a security boundary and *does not* affect redirects.<br>*env* `PLAYWRIGHT_MCP_BLOCKED_ORIGINS` |
|
||||
| --block-service-workers | block service workers<br>*env* `PLAYWRIGHT_MCP_BLOCK_SERVICE_WORKERS` |
|
||||
| --browser <browser> | browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.<br>*env* `PLAYWRIGHT_MCP_BROWSER` |
|
||||
| --caps <caps> | comma-separated list of additional capabilities to enable, possible values: vision, pdf.<br>*env* `PLAYWRIGHT_MCP_CAPS` |
|
||||
| --cdp-endpoint <endpoint> | CDP endpoint to connect to.<br>*env* `PLAYWRIGHT_MCP_CDP_ENDPOINT` |
|
||||
| --cdp-header <headers...> | CDP headers to send with the connect request, multiple can be specified.<br>*env* `PLAYWRIGHT_MCP_CDP_HEADER` |
|
||||
| --codegen <lang> | specify the language to use for code generation, possible values: "typescript", "none". Default is "typescript".<br>*env* `PLAYWRIGHT_MCP_CODEGEN` |
|
||||
| --config <path> | path to the configuration file.<br>*env* `PLAYWRIGHT_MCP_CONFIG` |
|
||||
| --console-level <level> | level of console messages to return: "error", "warning", "info", "debug". Each level includes the messages of more severe levels.<br>*env* `PLAYWRIGHT_MCP_CONSOLE_LEVEL` |
|
||||
| --device <device> | device to emulate, for example: "iPhone 15"<br>*env* `PLAYWRIGHT_MCP_DEVICE` |
|
||||
| --executable-path <path> | path to the browser executable.<br>*env* `PLAYWRIGHT_MCP_EXECUTABLE_PATH` |
|
||||
| --extension | Connect to a running browser instance (Edge/Chrome only). Requires the "Playwright MCP Bridge" browser extension to be installed.<br>*env* `PLAYWRIGHT_MCP_EXTENSION` |
|
||||
| --grant-permissions <permissions...> | List of permissions to grant to the browser context, for example "geolocation", "clipboard-read", "clipboard-write".<br>*env* `PLAYWRIGHT_MCP_GRANT_PERMISSIONS` |
|
||||
| --headless | run browser in headless mode, headed by default<br>*env* `PLAYWRIGHT_MCP_HEADLESS` |
|
||||
| --host <host> | host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.<br>*env* `PLAYWRIGHT_MCP_HOST` |
|
||||
| --ignore-https-errors | ignore https errors<br>*env* `PLAYWRIGHT_MCP_IGNORE_HTTPS_ERRORS` |
|
||||
| --init-page <path...> | path to TypeScript file to evaluate on Playwright page object<br>*env* `PLAYWRIGHT_MCP_INIT_PAGE` |
|
||||
| --init-script <path...> | path to JavaScript file to add as an initialization script. The script will be evaluated in every page before any of the page's scripts. Can be specified multiple times.<br>*env* `PLAYWRIGHT_MCP_INIT_SCRIPT` |
|
||||
| --isolated | keep the browser profile in memory, do not save it to disk.<br>*env* `PLAYWRIGHT_MCP_ISOLATED` |
|
||||
| --image-responses <mode> | whether to send image responses to the client. Can be "allow" or "omit", Defaults to "allow".<br>*env* `PLAYWRIGHT_MCP_IMAGE_RESPONSES` |
|
||||
| --no-sandbox | disable the sandbox for all process types that are normally sandboxed.<br>*env* `PLAYWRIGHT_MCP_NO_SANDBOX` |
|
||||
| --output-dir <path> | path to the directory for output files.<br>*env* `PLAYWRIGHT_MCP_OUTPUT_DIR` |
|
||||
| --output-mode <mode> | whether to save snapshots, console messages, network logs to a file or to the standard output. Can be "file" or "stdout". Default is "stdout".<br>*env* `PLAYWRIGHT_MCP_OUTPUT_MODE` |
|
||||
| --port <port> | port to listen on for SSE transport.<br>*env* `PLAYWRIGHT_MCP_PORT` |
|
||||
| --proxy-bypass <bypass> | comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"<br>*env* `PLAYWRIGHT_MCP_PROXY_BYPASS` |
|
||||
| --proxy-server <proxy> | specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"<br>*env* `PLAYWRIGHT_MCP_PROXY_SERVER` |
|
||||
| --save-session | Whether to save the Playwright MCP session into the output directory.<br>*env* `PLAYWRIGHT_MCP_SAVE_SESSION` |
|
||||
| --save-trace | Whether to save the Playwright Trace of the session into the output directory.<br>*env* `PLAYWRIGHT_MCP_SAVE_TRACE` |
|
||||
| --save-video <size> | Whether to save the video of the session into the output directory. For example "--save-video=800x600"<br>*env* `PLAYWRIGHT_MCP_SAVE_VIDEO` |
|
||||
| --secrets <path> | path to a file containing secrets in the dotenv format<br>*env* `PLAYWRIGHT_MCP_SECRETS` |
|
||||
| --shared-browser-context | reuse the same browser context between all connected HTTP clients.<br>*env* `PLAYWRIGHT_MCP_SHARED_BROWSER_CONTEXT` |
|
||||
| --snapshot-mode <mode> | when taking snapshots for responses, specifies the mode to use. Can be "incremental", "full", or "none". Default is incremental.<br>*env* `PLAYWRIGHT_MCP_SNAPSHOT_MODE` |
|
||||
| --storage-state <path> | path to the storage state file for isolated sessions.<br>*env* `PLAYWRIGHT_MCP_STORAGE_STATE` |
|
||||
| --test-id-attribute <attribute> | specify the attribute to use for test ids, defaults to "data-testid"<br>*env* `PLAYWRIGHT_MCP_TEST_ID_ATTRIBUTE` |
|
||||
| --timeout-action <timeout> | specify action timeout in milliseconds, defaults to 5000ms<br>*env* `PLAYWRIGHT_MCP_TIMEOUT_ACTION` |
|
||||
| --timeout-navigation <timeout> | specify navigation timeout in milliseconds, defaults to 60000ms<br>*env* `PLAYWRIGHT_MCP_TIMEOUT_NAVIGATION` |
|
||||
| --user-agent <ua string> | specify user agent string<br>*env* `PLAYWRIGHT_MCP_USER_AGENT` |
|
||||
| --user-data-dir <path> | path to the user data directory. If not specified, a temporary directory will be created.<br>*env* `PLAYWRIGHT_MCP_USER_DATA_DIR` |
|
||||
| --viewport-size <size> | specify browser viewport size in pixels, for example "1280x720"<br>*env* `PLAYWRIGHT_MCP_VIEWPORT_SIZE` |
|
||||
|
||||
<!--- End of options generated section -->
|
||||
|
||||
### User profile
|
||||
|
||||
You can run Playwright MCP with persistent profile like a regular browser (default), or in the isolated contexts for the testing sessions.
|
||||
You can run Playwright MCP with persistent profile like a regular browser (default), in isolated contexts for testing sessions, or connect to your existing browser using the browser extension.
|
||||
|
||||
**Persistent profile**
|
||||
|
||||
@@ -233,6 +421,39 @@ state [here](https://playwright.dev/docs/auth).
|
||||
}
|
||||
```
|
||||
|
||||
**Browser Extension**
|
||||
|
||||
The Playwright MCP Chrome Extension allows you to connect to existing browser tabs and leverage your logged-in sessions and browser state. See [packages/extension/README.md](packages/extension/README.md) for installation and setup instructions.
|
||||
|
||||
### Initial state
|
||||
|
||||
There are multiple ways to provide the initial state to the browser context or a page.
|
||||
|
||||
For the storage state, you can either:
|
||||
- Start with a user data directory using the `--user-data-dir` argument. This will persist all browser data between the sessions.
|
||||
- Start with a storage state file using the `--storage-state` argument. This will load cookies and local storage from the file into an isolated browser context.
|
||||
|
||||
For the page state, you can use:
|
||||
|
||||
- `--init-page` to point to a TypeScript file that will be evaluated on the Playwright page object. This allows you to run arbitrary code to set up the page.
|
||||
|
||||
```ts
|
||||
// init-page.ts
|
||||
export default async ({ page }) => {
|
||||
await page.context().grantPermissions(['geolocation']);
|
||||
await page.context().setGeolocation({ latitude: 37.7749, longitude: -122.4194 });
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
};
|
||||
```
|
||||
|
||||
- `--init-script` to point to a JavaScript file that will be added as an initialization script. The script will be evaluated in every page before any of the page's scripts.
|
||||
This is useful for overriding browser APIs or setting up the environment.
|
||||
|
||||
```js
|
||||
// init-script.js
|
||||
window.isPlaywrightMCP = true;
|
||||
```
|
||||
|
||||
### Configuration file
|
||||
|
||||
The Playwright MCP server can be configured using a JSON configuration file. You can specify the configuration file
|
||||
@@ -245,75 +466,206 @@ npx @playwright/mcp@latest --config path/to/config.json
|
||||
<details>
|
||||
<summary>Configuration file schema</summary>
|
||||
|
||||
<!--- Config generated by update-readme.js -->
|
||||
|
||||
```typescript
|
||||
{
|
||||
// Browser configuration
|
||||
/**
|
||||
* The browser to use.
|
||||
*/
|
||||
browser?: {
|
||||
// Browser type to use (chromium, firefox, or webkit)
|
||||
/**
|
||||
* The type of browser to use.
|
||||
*/
|
||||
browserName?: 'chromium' | 'firefox' | 'webkit';
|
||||
|
||||
// Keep the browser profile in memory, do not save it to disk.
|
||||
/**
|
||||
* Keep the browser profile in memory, do not save it to disk.
|
||||
*/
|
||||
isolated?: boolean;
|
||||
|
||||
// Path to user data directory for browser profile persistence
|
||||
/**
|
||||
* Path to a user data directory for browser profile persistence.
|
||||
* Temporary directory is created by default.
|
||||
*/
|
||||
userDataDir?: string;
|
||||
|
||||
// Browser launch options (see Playwright docs)
|
||||
// @see https://playwright.dev/docs/api/class-browsertype#browser-type-launch
|
||||
launchOptions?: {
|
||||
channel?: string; // Browser channel (e.g. 'chrome')
|
||||
headless?: boolean; // Run in headless mode
|
||||
executablePath?: string; // Path to browser executable
|
||||
// ... other Playwright launch options
|
||||
};
|
||||
/**
|
||||
* Launch options passed to
|
||||
* @see https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context
|
||||
*
|
||||
* This is useful for settings options like `channel`, `headless`, `executablePath`, etc.
|
||||
*/
|
||||
launchOptions?: playwright.LaunchOptions;
|
||||
|
||||
// Browser context options
|
||||
// @see https://playwright.dev/docs/api/class-browser#browser-new-context
|
||||
contextOptions?: {
|
||||
viewport?: { width: number, height: number };
|
||||
// ... other Playwright context options
|
||||
};
|
||||
/**
|
||||
* Context options for the browser context.
|
||||
*
|
||||
* This is useful for settings options like `viewport`.
|
||||
*/
|
||||
contextOptions?: playwright.BrowserContextOptions;
|
||||
|
||||
// CDP endpoint for connecting to existing browser
|
||||
/**
|
||||
* Chrome DevTools Protocol endpoint to connect to an existing browser instance in case of Chromium family browsers.
|
||||
*/
|
||||
cdpEndpoint?: string;
|
||||
|
||||
// Remote Playwright server endpoint
|
||||
/**
|
||||
* 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 configuration
|
||||
server?: {
|
||||
port?: number; // Port to listen on
|
||||
host?: string; // Host to bind to (default: localhost)
|
||||
/**
|
||||
* The port to listen on for SSE or MCP transport.
|
||||
*/
|
||||
port?: number;
|
||||
|
||||
/**
|
||||
* The host to bind the server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.
|
||||
*/
|
||||
host?: string;
|
||||
|
||||
/**
|
||||
* 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[];
|
||||
},
|
||||
|
||||
// List of additional capabilities
|
||||
capabilities?: Array<
|
||||
'tabs' | // Tab management
|
||||
'install' | // Browser installation
|
||||
'pdf' | // PDF generation
|
||||
'vision' | // Coordinate-based interactions
|
||||
>;
|
||||
/**
|
||||
* List of enabled tool capabilities. Possible values:
|
||||
* - 'core': Core browser automation features.
|
||||
* - 'pdf': PDF generation and manipulation.
|
||||
* - 'vision': Coordinate-based interactions.
|
||||
*/
|
||||
capabilities?: ToolCapability[];
|
||||
|
||||
// Directory for output files
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// Network configuration
|
||||
/**
|
||||
* 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.
|
||||
/**
|
||||
* List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
|
||||
*/
|
||||
allowedOrigins?: string[];
|
||||
|
||||
// List of origins to block the browser to request. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
|
||||
/**
|
||||
* List of origins to block the browser to request. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
|
||||
*/
|
||||
blockedOrigins?: string[];
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Whether to send image responses to the client. Can be "allow" or "omit".
|
||||
* Defaults to "allow".
|
||||
* 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';
|
||||
}
|
||||
```
|
||||
|
||||
<!--- End of config generated section -->
|
||||
|
||||
</details>
|
||||
|
||||
### Standalone MCP server
|
||||
@@ -353,6 +705,19 @@ And then in MCP client config, set the `url` to the HTTP endpoint:
|
||||
}
|
||||
```
|
||||
|
||||
Or If you prefer to run the container as a long-lived service instead of letting the MCP client spawn it, use:
|
||||
|
||||
```
|
||||
docker run -d -i --rm --init --pull=always \
|
||||
--entrypoint node \
|
||||
--name playwright \
|
||||
-p 8931:8931 \
|
||||
mcr.microsoft.com/playwright/mcp \
|
||||
cli.js --headless --browser chromium --no-sandbox --port 8931
|
||||
```
|
||||
|
||||
The server will listen on host port **8931** and can be reached by any MCP client.
|
||||
|
||||
You can build the Docker image yourself.
|
||||
|
||||
```
|
||||
@@ -375,7 +740,7 @@ http.createServer(async (req, res) => {
|
||||
// Creates a headless Playwright MCP server with SSE transport
|
||||
const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
|
||||
const transport = new SSEServerTransport('/messages', res);
|
||||
await connection.sever.connect(transport);
|
||||
await connection.connect(transport);
|
||||
|
||||
// ...
|
||||
});
|
||||
@@ -395,10 +760,11 @@ http.createServer(async (req, res) => {
|
||||
- Title: Click
|
||||
- Description: Perform click on a web page
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
- `element` (string, optional): Human-readable element description used to obtain permission to interact with the element
|
||||
- `ref` (string): Exact target element reference from the page snapshot
|
||||
- `doubleClick` (boolean, optional): Whether to perform a double click instead of a single click
|
||||
- `button` (string, optional): Button to click, defaults to left
|
||||
- `modifiers` (array, optional): Modifier keys to press
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
@@ -407,14 +773,16 @@ http.createServer(async (req, res) => {
|
||||
- Title: Close browser
|
||||
- Description: Close the page
|
||||
- Parameters: None
|
||||
- Read-only: **true**
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_console_messages**
|
||||
- Title: Get console messages
|
||||
- Description: Returns all console messages
|
||||
- Parameters: None
|
||||
- Parameters:
|
||||
- `level` (string): Level of the console messages to return. Each level includes the messages of more severe levels. Defaults to "info".
|
||||
- `filename` (string, optional): Filename to save the console messages to. If not provided, messages are returned as text.
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
@@ -446,7 +814,16 @@ http.createServer(async (req, res) => {
|
||||
- Title: Upload files
|
||||
- Description: Upload one or multiple files
|
||||
- Parameters:
|
||||
- `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files.
|
||||
- `paths` (array, optional): The absolute paths to the files to upload. Can be single file or multiple files. If omitted, file chooser is cancelled.
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_fill_form**
|
||||
- Title: Fill form
|
||||
- Description: Fill multiple form fields
|
||||
- Parameters:
|
||||
- `fields` (array): Fields to fill in
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
@@ -465,9 +842,9 @@ http.createServer(async (req, res) => {
|
||||
- Title: Hover mouse
|
||||
- Description: Hover over element on page
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
- `element` (string, optional): Human-readable element description used to obtain permission to interact with the element
|
||||
- `ref` (string): Exact target element reference from the page snapshot
|
||||
- Read-only: **true**
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
@@ -482,24 +859,18 @@ http.createServer(async (req, res) => {
|
||||
|
||||
- **browser_navigate_back**
|
||||
- Title: Go back
|
||||
- Description: Go back to the previous page
|
||||
- Description: Go back to the previous page in the history
|
||||
- Parameters: None
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_navigate_forward**
|
||||
- Title: Go forward
|
||||
- Description: Go forward to the next page
|
||||
- Parameters: None
|
||||
- Read-only: **true**
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_network_requests**
|
||||
- Title: List network requests
|
||||
- Description: Returns all network requests since loading the page
|
||||
- Parameters: None
|
||||
- Parameters:
|
||||
- `includeStatic` (boolean): Whether to include successful static resources like images, fonts, scripts, etc. Defaults to false.
|
||||
- `filename` (string, optional): Filename to save the network requests to. If not provided, requests are returned as text.
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
@@ -519,7 +890,16 @@ http.createServer(async (req, res) => {
|
||||
- Parameters:
|
||||
- `width` (number): Width of the browser window
|
||||
- `height` (number): Height of the browser window
|
||||
- Read-only: **true**
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_run_code**
|
||||
- Title: Run Playwright code
|
||||
- Description: Run Playwright code snippet
|
||||
- Parameters:
|
||||
- `code` (string): A JavaScript function containing Playwright code to execute. It will be invoked with a single argument, page, which you can use for any page interaction. For example: `async (page) => { await page.getByRole('button', { name: 'Submit' }).click(); return await page.title(); }`
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
@@ -527,7 +907,7 @@ http.createServer(async (req, res) => {
|
||||
- Title: Select option
|
||||
- Description: Select an option in a dropdown
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
- `element` (string, optional): Human-readable element description used to obtain permission to interact with the element
|
||||
- `ref` (string): Exact target element reference from the page snapshot
|
||||
- `values` (array): Array of values to select in the dropdown. This can be a single value or multiple values.
|
||||
- Read-only: **false**
|
||||
@@ -537,7 +917,8 @@ http.createServer(async (req, res) => {
|
||||
- **browser_snapshot**
|
||||
- Title: Page snapshot
|
||||
- Description: Capture accessibility snapshot of the current page, this is better than screenshot
|
||||
- Parameters: None
|
||||
- Parameters:
|
||||
- `filename` (string, optional): Save snapshot to markdown file instead of returning it in the response.
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
@@ -546,8 +927,8 @@ http.createServer(async (req, res) => {
|
||||
- Title: Take a screenshot
|
||||
- Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.
|
||||
- Parameters:
|
||||
- `type` (string, optional): Image format for the screenshot. Default is png.
|
||||
- `filename` (string, optional): File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.
|
||||
- `type` (string): Image format for the screenshot. Default is png.
|
||||
- `filename` (string, optional): File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified. Prefer relative file names to stay within the output directory.
|
||||
- `element` (string, optional): Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.
|
||||
- `ref` (string, optional): Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.
|
||||
- `fullPage` (boolean, optional): When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Cannot be used with element screenshots.
|
||||
@@ -559,7 +940,7 @@ http.createServer(async (req, res) => {
|
||||
- Title: Type text
|
||||
- Description: Type text into editable element
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
- `element` (string, optional): Human-readable element description used to obtain permission to interact with the element
|
||||
- `ref` (string): Exact target element reference from the page snapshot
|
||||
- `text` (string): Text to type into the element
|
||||
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
|
||||
@@ -575,7 +956,7 @@ http.createServer(async (req, res) => {
|
||||
- `time` (number, optional): The time to wait in seconds
|
||||
- `text` (string, optional): The text to wait for
|
||||
- `textGone` (string, optional): The text to wait for to disappear
|
||||
- Read-only: **true**
|
||||
- Read-only: **false**
|
||||
|
||||
</details>
|
||||
|
||||
@@ -584,39 +965,14 @@ http.createServer(async (req, res) => {
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_tab_close**
|
||||
- Title: Close a tab
|
||||
- Description: Close a tab
|
||||
- **browser_tabs**
|
||||
- Title: Manage tabs
|
||||
- Description: List, create, close, or select a browser tab.
|
||||
- Parameters:
|
||||
- `index` (number, optional): The index of the tab to close. Closes current tab if not provided.
|
||||
- `action` (string): Operation to perform
|
||||
- `index` (number, optional): Tab index, used for close/select. If omitted for close, current tab is closed.
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_tab_list**
|
||||
- Title: List tabs
|
||||
- Description: List browser tabs
|
||||
- Parameters: None
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_tab_new**
|
||||
- Title: Open a new tab
|
||||
- Description: Open a new tab
|
||||
- Parameters:
|
||||
- `url` (string, optional): The URL to navigate to in the new tab. If not provided, the new tab will be blank.
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_tab_select**
|
||||
- Title: Select a tab
|
||||
- Description: Select a tab by index
|
||||
- Parameters:
|
||||
- `index` (number): The index of the tab to select
|
||||
- Read-only: **true**
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -641,18 +997,25 @@ http.createServer(async (req, res) => {
|
||||
- Title: Click
|
||||
- Description: Click left mouse button at a given position
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
- `x` (number): X coordinate
|
||||
- `y` (number): Y coordinate
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_mouse_down**
|
||||
- Title: Press mouse down
|
||||
- Description: Press mouse down
|
||||
- Parameters:
|
||||
- `button` (string, optional): Button to press, defaults to left
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_mouse_drag_xy**
|
||||
- Title: Drag mouse
|
||||
- Description: Drag left mouse button to a given position
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
- `startX` (number): Start X coordinate
|
||||
- `startY` (number): Start Y coordinate
|
||||
- `endX` (number): End X coordinate
|
||||
@@ -665,10 +1028,28 @@ http.createServer(async (req, res) => {
|
||||
- Title: Move mouse
|
||||
- Description: Move mouse to a given position
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||
- `x` (number): X coordinate
|
||||
- `y` (number): Y coordinate
|
||||
- Read-only: **true**
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_mouse_up**
|
||||
- Title: Press mouse up
|
||||
- Description: Press mouse up
|
||||
- Parameters:
|
||||
- `button` (string, optional): Button to press, defaults to left
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_mouse_wheel**
|
||||
- Title: Scroll mouse wheel
|
||||
- Description: Scroll mouse wheel
|
||||
- Parameters:
|
||||
- `deltaX` (number): X delta
|
||||
- `deltaY` (number): Y delta
|
||||
- Read-only: **false**
|
||||
|
||||
</details>
|
||||
|
||||
@@ -681,7 +1062,85 @@ http.createServer(async (req, res) => {
|
||||
- Title: Save as PDF
|
||||
- Description: Save page as PDF
|
||||
- Parameters:
|
||||
- `filename` (string, optional): File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.
|
||||
- `filename` (string, optional): File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified. Prefer relative file names to stay within the output directory.
|
||||
- Read-only: **true**
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Test assertions (opt-in via --caps=testing)</b></summary>
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_generate_locator**
|
||||
- Title: Create locator for element
|
||||
- Description: Generate locator for the given element to use in tests
|
||||
- Parameters:
|
||||
- `element` (string, optional): Human-readable element description used to obtain permission to interact with the element
|
||||
- `ref` (string): Exact target element reference from the page snapshot
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_verify_element_visible**
|
||||
- Title: Verify element visible
|
||||
- Description: Verify element is visible on the page
|
||||
- Parameters:
|
||||
- `role` (string): ROLE of the element. Can be found in the snapshot like this: `- {ROLE} "Accessible Name":`
|
||||
- `accessibleName` (string): ACCESSIBLE_NAME of the element. Can be found in the snapshot like this: `- role "{ACCESSIBLE_NAME}"`
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_verify_list_visible**
|
||||
- Title: Verify list visible
|
||||
- Description: Verify list is visible on the page
|
||||
- Parameters:
|
||||
- `element` (string): Human-readable list description
|
||||
- `ref` (string): Exact target element reference that points to the list
|
||||
- `items` (array): Items to verify
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_verify_text_visible**
|
||||
- Title: Verify text visible
|
||||
- Description: Verify text is visible on the page. Prefer browser_verify_element_visible if possible.
|
||||
- Parameters:
|
||||
- `text` (string): TEXT to verify. Can be found in the snapshot like this: `- role "Accessible Name": {TEXT}` or like this: `- text: {TEXT}`
|
||||
- Read-only: **false**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_verify_value**
|
||||
- Title: Verify value
|
||||
- Description: Verify element value
|
||||
- Parameters:
|
||||
- `type` (string): Type of the element
|
||||
- `element` (string): Human-readable element description
|
||||
- `ref` (string): Exact target element reference that points to the element
|
||||
- `value` (string): Value to verify. For checkbox, use "true" or "false".
|
||||
- Read-only: **false**
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Tracing (opt-in via --caps=tracing)</b></summary>
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_start_tracing**
|
||||
- Title: Start tracing
|
||||
- Description: Start trace recording
|
||||
- Parameters: None
|
||||
- Read-only: **true**
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_stop_tracing**
|
||||
- Title: Stop tracing
|
||||
- Description: Stop trace recording
|
||||
- Parameters: None
|
||||
- Read-only: **true**
|
||||
|
||||
</details>
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 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"),
|
||||
}
|
||||
};
|
||||
|
||||
const importOrderRules = {
|
||||
"import/order": [
|
||||
2,
|
||||
{
|
||||
groups: [
|
||||
"builtin",
|
||||
"external",
|
||||
"internal",
|
||||
["parent", "sibling"],
|
||||
"index",
|
||||
"type",
|
||||
],
|
||||
},
|
||||
],
|
||||
"import/consistent-type-specifier-style": [2, "prefer-top-level"],
|
||||
};
|
||||
|
||||
const noFloatingPromisesRules = {
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
};
|
||||
|
||||
const noBooleanCompareRules = {
|
||||
"@typescript-eslint/no-unnecessary-boolean-literal-compare": 2,
|
||||
};
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ["**/*.js"],
|
||||
},
|
||||
{
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
plugins,
|
||||
languageOptions,
|
||||
rules: {
|
||||
...baseRules,
|
||||
...importOrderRules,
|
||||
...noFloatingPromisesRules,
|
||||
...noBooleanCompareRules,
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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.
|
||||
1574
extension/package-lock.json
generated
@@ -1,124 +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 { RelayConnection, debugLog } from './relayConnection.js';
|
||||
|
||||
type PageMessage = {
|
||||
type: 'connectToMCPRelay';
|
||||
mcpRelayUrl: string;
|
||||
tabId: number;
|
||||
windowId: number;
|
||||
} | {
|
||||
type: 'getTabs';
|
||||
};
|
||||
|
||||
class TabShareExtension {
|
||||
private _activeConnection: RelayConnection | undefined;
|
||||
private _connectedTabId: number | null = null;
|
||||
|
||||
constructor() {
|
||||
chrome.tabs.onRemoved.addListener(this._onTabRemoved.bind(this));
|
||||
chrome.tabs.onUpdated.addListener(this._onTabUpdated.bind(this));
|
||||
chrome.runtime.onMessage.addListener(this._onMessage.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._connectTab(message.tabId, message.windowId, message.mcpRelayUrl!).then(
|
||||
() => sendResponse({ success: true }),
|
||||
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||
return true; // Return true to indicate that the response will be sent asynchronously
|
||||
case 'getTabs':
|
||||
this._getTabs().then(
|
||||
tabs => sendResponse({ success: true, tabs, currentTabId: sender.tab?.id }),
|
||||
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async _connectTab(tabId: number, windowId: number, mcpRelayUrl: string): Promise<void> {
|
||||
try {
|
||||
debugLog(`Connecting tab ${tabId} to bridge 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, tabId);
|
||||
const connectionClosed = (m: string) => {
|
||||
debugLog(m);
|
||||
if (this._activeConnection === connection) {
|
||||
this._activeConnection = undefined;
|
||||
void this._setConnectedTabId(null);
|
||||
}
|
||||
};
|
||||
socket.onclose = () => connectionClosed('WebSocket closed');
|
||||
socket.onerror = error => connectionClosed(`WebSocket error: ${error}`);
|
||||
this._activeConnection = connection;
|
||||
|
||||
await Promise.all([
|
||||
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: '', color: null });
|
||||
if (tabId)
|
||||
await this._updateBadge(tabId, { text: '●', color: '#4CAF50' });
|
||||
}
|
||||
|
||||
private async _updateBadge(tabId: number, { text, color }: { text: string; color: string | null }): Promise<void> {
|
||||
await chrome.action.setBadgeText({ tabId, text });
|
||||
if (color)
|
||||
await chrome.action.setBadgeBackgroundColor({ tabId, color });
|
||||
}
|
||||
|
||||
private async _onTabRemoved(tabId: number): Promise<void> {
|
||||
if (this._connectedTabId !== tabId)
|
||||
return;
|
||||
this._activeConnection?.close('Browser tab closed');
|
||||
this._activeConnection = undefined;
|
||||
this._connectedTabId = null;
|
||||
}
|
||||
|
||||
private async _onTabUpdated(tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab): Promise<void> {
|
||||
if (changeInfo.status === 'complete' && this._connectedTabId === tabId)
|
||||
await 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)));
|
||||
}
|
||||
}
|
||||
|
||||
new TabShareExtension();
|
||||
@@ -1,209 +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 React, { useState, useEffect, useCallback } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
interface TabInfo {
|
||||
id: number;
|
||||
windowId: number;
|
||||
title: string;
|
||||
url: string;
|
||||
favIconUrl?: string;
|
||||
}
|
||||
|
||||
type StatusType = 'connected' | 'error' | 'connecting';
|
||||
|
||||
const ConnectApp: React.FC = () => {
|
||||
const [tabs, setTabs] = useState<TabInfo[]>([]);
|
||||
const [selectedTab, setSelectedTab] = useState<TabInfo | undefined>();
|
||||
const [status, setStatus] = useState<{ type: StatusType; message: string } | null>(null);
|
||||
const [showButtons, setShowButtons] = useState(true);
|
||||
const [showTabList, setShowTabList] = useState(true);
|
||||
const [clientInfo, setClientInfo] = useState('unknown');
|
||||
const [mcpRelayUrl, setMcpRelayUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const relayUrl = params.get('mcpRelayUrl');
|
||||
|
||||
if (!relayUrl) {
|
||||
setShowButtons(false);
|
||||
setStatus({ type: 'error', message: 'Missing mcpRelayUrl parameter in URL.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setMcpRelayUrl(relayUrl);
|
||||
|
||||
try {
|
||||
const client = JSON.parse(params.get('client') || '{}');
|
||||
const info = `${client.name}/${client.version}`;
|
||||
setClientInfo(info);
|
||||
setStatus({
|
||||
type: 'connecting',
|
||||
message: `MCP client "${info}" is trying to connect. Do you want to continue?`
|
||||
});
|
||||
} catch (e) {
|
||||
setStatus({ type: 'error', message: 'Failed to parse client version.' });
|
||||
return;
|
||||
}
|
||||
|
||||
void loadTabs();
|
||||
}, []);
|
||||
|
||||
const loadTabs = useCallback(async () => {
|
||||
const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
|
||||
if (response.success) {
|
||||
setTabs(response.tabs);
|
||||
const currentTab = response.tabs.find((tab: TabInfo) => tab.id === response.currentTabId);
|
||||
setSelectedTab(currentTab);
|
||||
} else {
|
||||
setStatus({ type: 'error', message: 'Failed to load tabs: ' + response.error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleContinue = useCallback(async () => {
|
||||
setShowButtons(false);
|
||||
setShowTabList(false);
|
||||
|
||||
if (!selectedTab) {
|
||||
setStatus({ type: 'error', message: 'Tab not selected.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'connectToMCPRelay',
|
||||
mcpRelayUrl,
|
||||
tabId: selectedTab.id,
|
||||
windowId: selectedTab.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}`
|
||||
});
|
||||
}
|
||||
}, [selectedTab, clientInfo, mcpRelayUrl]);
|
||||
|
||||
const handleReject = useCallback(() => {
|
||||
setShowButtons(false);
|
||||
setShowTabList(false);
|
||||
setStatus({ type: 'error', message: 'Connection rejected. This tab can be closed.' });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className='app-container'>
|
||||
<div className='content-wrapper'>
|
||||
<h1 className='main-title'>
|
||||
Playwright MCP Extension
|
||||
</h1>
|
||||
|
||||
{status && <StatusBanner type={status.type} message={status.message} />}
|
||||
|
||||
{showButtons && (
|
||||
<div className='button-container'>
|
||||
<Button variant='primary' onClick={handleContinue}>
|
||||
Continue
|
||||
</Button>
|
||||
<Button variant='default' onClick={handleReject}>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{showTabList && (
|
||||
<div>
|
||||
<h2 className='tab-section-title'>
|
||||
Select page to expose to MCP server:
|
||||
</h2>
|
||||
<div>
|
||||
{tabs.map(tab => (
|
||||
<TabItem
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isSelected={selectedTab?.id === tab.id}
|
||||
onSelect={() => setSelectedTab(tab)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StatusBanner: React.FC<{ type: StatusType; message: string }> = ({ type, message }) => {
|
||||
return <div className={`status-banner ${type}`}>{message}</div>;
|
||||
};
|
||||
|
||||
const Button: React.FC<{ variant: 'primary' | 'default'; onClick: () => void; children: React.ReactNode }> = ({
|
||||
variant,
|
||||
onClick,
|
||||
children
|
||||
}) => {
|
||||
return (
|
||||
<button className={`button ${variant}`} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const TabItem: React.FC<{ tab: TabInfo; isSelected: boolean; onSelect: () => void }> = ({
|
||||
tab,
|
||||
isSelected,
|
||||
onSelect
|
||||
}) => {
|
||||
const className = `tab-item ${isSelected ? 'selected' : ''}`.trim();
|
||||
|
||||
return (
|
||||
<div className={className} onClick={onSelect}>
|
||||
<input
|
||||
type='radio'
|
||||
className='tab-radio'
|
||||
checked={isSelected}
|
||||
/>
|
||||
<img
|
||||
src={tab.favIconUrl || 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><rect width="16" height="16" fill="%23f6f8fa"/></svg>'}
|
||||
alt=''
|
||||
className='tab-favicon'
|
||||
/>
|
||||
<div className='tab-content'>
|
||||
<div className='tab-title'>{tab.title || 'Untitled'}</div>
|
||||
<div className='tab-url'>{tab.url}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// Initialize the React app
|
||||
const container = document.getElementById('root');
|
||||
if (container) {
|
||||
const root = createRoot(container);
|
||||
root.render(<ConnectApp />);
|
||||
}
|
||||
4964
package-lock.json
generated
73
package.json
@@ -1,73 +1,30 @@
|
||||
{
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.32",
|
||||
"description": "Playwright Tools for MCP",
|
||||
"type": "module",
|
||||
"name": "playwright-mcp-internal",
|
||||
"version": "0.0.60",
|
||||
"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",
|
||||
"lint-fix": "eslint . --fix",
|
||||
"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.16.0",
|
||||
"commander": "^13.1.0",
|
||||
"debug": "^4.4.1",
|
||||
"dotenv": "^17.2.0",
|
||||
"mime": "^4.0.7",
|
||||
"playwright": "1.55.0-alpha-1753913825000",
|
||||
"playwright-core": "1.55.0-alpha-1753913825000",
|
||||
"ws": "^8.18.1",
|
||||
"zod": "^3.24.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": {
|
||||
"@anthropic-ai/sdk": "^0.57.0",
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@playwright/test": "1.55.0-alpha-1753913825000",
|
||||
"@stylistic/eslint-plugin": "^3.0.1",
|
||||
"@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",
|
||||
"esbuild": "^0.20.1",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-notice": "^1.0.0",
|
||||
"openai": "^5.10.2",
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"bin": {
|
||||
"mcp-server-playwright": "cli.js"
|
||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||
"@playwright/test": "1.59.0-alpha-1769452054000",
|
||||
"@types/node": "^24.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
1
packages/extension/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
dist/
|
||||
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.
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 571 B After Width: | Height: | Size: 571 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
@@ -1,26 +1,22 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Playwright MCP Bridge",
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.60",
|
||||
"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.js",
|
||||
"service_worker": "lib/background.mjs",
|
||||
"type": "module"
|
||||
},
|
||||
|
||||
"action": {
|
||||
"default_title": "Playwright MCP Bridge",
|
||||
"default_icon": {
|
||||
@@ -30,7 +26,6 @@
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
},
|
||||
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"name": "@playwright/mcp-extension",
|
||||
"version": "0.0.32",
|
||||
"version": "0.0.60",
|
||||
"description": "Playwright MCP Browser Extension",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -17,9 +16,11 @@
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"build": "tsc --project . && tsc --project tsconfig.ui.json && vite build",
|
||||
"watch": "tsc --watch --project . & tsc --watch --project tsconfig.ui.json & vite build --watch",
|
||||
"clean": "rm -rf lib"
|
||||
"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",
|
||||
@@ -29,6 +30,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^5.0.0"
|
||||
"vite": "^5.4.21",
|
||||
"vite-plugin-static-copy": "^3.1.1"
|
||||
}
|
||||
}
|
||||
@@ -14,16 +14,18 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { z } from 'zod';
|
||||
import type * as mcpServer from '../mcp/server.js';
|
||||
import type { Context } from './context.js';
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
import type { TestOptions } from '../playwright-mcp/tests/fixtures';
|
||||
|
||||
export type Tool<Input extends z.Schema = z.Schema> = {
|
||||
schema: mcpServer.ToolSchema<Input>;
|
||||
handle: (context: Context, params: z.output<Input>) => Promise<mcpServer.ToolResponse>;
|
||||
};
|
||||
|
||||
export function defineTool<Input extends z.Schema>(tool: Tool<Input>): Tool<Input> {
|
||||
return tool;
|
||||
}
|
||||
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
@@ -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();
|
||||
@@ -41,11 +41,18 @@ export class RelayConnection {
|
||||
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;
|
||||
|
||||
constructor(ws: WebSocket, tabId: number) {
|
||||
this._debuggee = { tabId };
|
||||
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);
|
||||
@@ -53,10 +60,27 @@ export class RelayConnection {
|
||||
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);
|
||||
this._ws.close(1000, message);
|
||||
chrome.debugger.detach(this._debuggee).catch(() => {});
|
||||
this.onclose?.();
|
||||
}
|
||||
|
||||
private _onDebuggerEvent(source: chrome.debugger.DebuggerSession, method: string, params: any): void {
|
||||
@@ -111,9 +135,8 @@ export class RelayConnection {
|
||||
}
|
||||
|
||||
private async _handleCommand(message: ProtocolCommand): Promise<any> {
|
||||
if (!this._debuggee.tabId)
|
||||
throw new Error('No tab is connected. Please go to the Playwright MCP extension and select the tab you want to connect to.');
|
||||
if (message.method === 'attachToTab') {
|
||||
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');
|
||||
@@ -121,6 +144,8 @@ export class RelayConnection {
|
||||
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);
|
||||
@@ -147,6 +172,7 @@ export class RelayConnection {
|
||||
}
|
||||
|
||||
private _sendMessage(message: any): void {
|
||||
this._ws.send(JSON.stringify(message));
|
||||
if (this._ws.readyState === WebSocket.OPEN)
|
||||
this._ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,9 @@ body {
|
||||
background-color: #ffffff;
|
||||
color: #1f2328;
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
padding: 16px;
|
||||
min-height: 100vh;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
@@ -36,50 +35,55 @@ body {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.main-title {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: #1f2328;
|
||||
/* Status Banner */
|
||||
.status-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
/* Status Banner */
|
||||
.status-banner {
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid;
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.status-banner.connected {
|
||||
background-color: #dafbe1;
|
||||
border-color: #1a7f37;
|
||||
color: #0d5a23;
|
||||
color: #1f2328;
|
||||
}
|
||||
|
||||
.status-banner.connected::before {
|
||||
content: "\2705";
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.status-banner.error {
|
||||
background-color: #ffebe9;
|
||||
border-color: #da3633;
|
||||
color: #a40e26;
|
||||
color: #1f2328;
|
||||
}
|
||||
|
||||
.status-banner.connecting {
|
||||
background-color: #fff8c5;
|
||||
border-color: #d1b500;
|
||||
color: #7a5c00;
|
||||
.status-banner.error::before {
|
||||
content: "\274C";
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.button-container {
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
@@ -88,46 +92,63 @@ body {
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
margin-right: 8px;
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
background-color: #2da44e;
|
||||
border-color: #2da44e;
|
||||
color: #ffffff;
|
||||
background-color: #f8f9fa;
|
||||
color: #3c4043;
|
||||
border: 1px solid #dadce0;
|
||||
}
|
||||
|
||||
.button.primary:hover {
|
||||
background-color: #2c974b;
|
||||
background-color: #f1f3f4;
|
||||
border-color: #dadce0;
|
||||
box-shadow: 0 1px 2px 0 rgba(60,64,67,.1);
|
||||
}
|
||||
|
||||
.button.default {
|
||||
background-color: #f6f8fa;
|
||||
border-color: #d1d9e0;
|
||||
color: #24292f;
|
||||
}
|
||||
|
||||
.button.default:hover {
|
||||
background-color: #f3f4f6;
|
||||
border-color: #c7d2da;
|
||||
}
|
||||
|
||||
.button.reject {
|
||||
background-color: #da3633;
|
||||
color: #ffffff;
|
||||
border: 1px solid #da3633;
|
||||
}
|
||||
|
||||
.button.reject:hover {
|
||||
background-color: #c73836;
|
||||
border-color: #c73836;
|
||||
}
|
||||
|
||||
/* Tab selection */
|
||||
.tab-section-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: #1f2328;
|
||||
padding-left: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
margin-bottom: 12px;
|
||||
color: #656d76;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border: 1px solid #d1d9e0;
|
||||
border-radius: 6px;
|
||||
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 {
|
||||
@@ -171,4 +192,71 @@ body {
|
||||
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);
|
||||
}
|
||||
@@ -18,10 +18,12 @@
|
||||
<head>
|
||||
<title>Playwright MCP extension</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="src/ui/connect.css">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="../../icons/icon-32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="../../icons/icon-16.png">
|
||||
<link rel="stylesheet" href="connect.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="src/ui/connect.tsx"></script>
|
||||
<script type="module" src="connect.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
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!`),
|
||||
});
|
||||
});
|
||||
@@ -6,10 +6,12 @@
|
||||
"strict": true,
|
||||
"module": "ESNext",
|
||||
"rootDir": "src",
|
||||
"outDir": "./lib",
|
||||
"outDir": "./dist/lib",
|
||||
"resolveJsonModule": true,
|
||||
"types": ["chrome"],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react"
|
||||
"jsxImportSource": "react",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
@@ -17,23 +17,37 @@
|
||||
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()],
|
||||
base: '/lib/ui/',
|
||||
plugins: [
|
||||
react(),
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: '../../icons/*',
|
||||
dest: 'icons'
|
||||
},
|
||||
{
|
||||
src: '../../manifest.json',
|
||||
dest: '.'
|
||||
}
|
||||
]
|
||||
})
|
||||
],
|
||||
root: resolve(__dirname, 'src/ui'),
|
||||
build: {
|
||||
outDir: resolve(__dirname, 'lib/ui'),
|
||||
emptyOutDir: true,
|
||||
outDir: resolve(__dirname, 'dist/'),
|
||||
emptyOutDir: false,
|
||||
minify: false,
|
||||
rollupOptions: {
|
||||
input: resolve(__dirname, 'connect.html'),
|
||||
input: ['src/ui/connect.html', 'src/ui/status.html'],
|
||||
output: {
|
||||
manualChunks: undefined,
|
||||
inlineDynamicImports: true,
|
||||
entryFileNames: '[name].js',
|
||||
chunkFileNames: '[name].js',
|
||||
assetFileNames: '[name].[ext]'
|
||||
entryFileNames: 'lib/ui/[name].js',
|
||||
chunkFileNames: 'lib/ui/[name].js',
|
||||
assetFileNames: 'lib/ui/[name].[ext]'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,18 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import debug from 'debug';
|
||||
import { resolve } from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
const errorsDebug = debug('pw:mcp:errors');
|
||||
|
||||
export function logUnhandledError(error: unknown) {
|
||||
errorsDebug(error);
|
||||
}
|
||||
|
||||
export const testDebug = debug('pw:mcp:test');
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/background.ts'),
|
||||
fileName: 'lib/background',
|
||||
formats: ['es']
|
||||
},
|
||||
outDir: 'dist',
|
||||
emptyOutDir: false,
|
||||
minify: false
|
||||
}
|
||||
});
|
||||
0
packages/playwright-cli/.gitignore
vendored
Normal file
4
packages/playwright-cli/.npmignore
Normal file
@@ -0,0 +1,4 @@
|
||||
**/*
|
||||
!README.md
|
||||
!LICENSE
|
||||
!playwright-cli.js
|
||||
201
packages/playwright-cli/LICENSE
Normal file
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
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.
|
||||
391
packages/playwright-cli/README.md
Normal file
@@ -0,0 +1,391 @@
|
||||
# playwright-cli
|
||||
|
||||
Playwright CLI with SKILLS
|
||||
|
||||
### Playwright CLI vs Playwright MCP
|
||||
|
||||
This package provides CLI interface into Playwright. If you are using **coding agents**, that is the best fit.
|
||||
|
||||
- **CLI**: Modern **coding agents** increasingly favor CLI–based workflows exposed as SKILLs over MCP because CLI invocations are more token-efficient: they avoid loading large tool schemas and verbose accessibility trees into the model context, allowing agents to act through concise, purpose-built commands. This makes CLI + SKILLs better suited for high-throughput coding agents that must balance browser automation with large codebases, tests, and reasoning within limited context windows.
|
||||
|
||||
- **MCP**: MCP remains relevant for specialized agentic loops that benefit from persistent state, rich introspection, and iterative reasoning over page structure, such as exploratory automation, self-healing tests, or long-running autonomous workflows where maintaining continuous browser context outweighs token cost concerns. Learn more about [Playwright MCP](https://github.com/microsoft/playwright-mcp).
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Token-efficient**. Does not force page data into LLM.
|
||||
|
||||
### Requirements
|
||||
- Node.js 18 or newer
|
||||
- Claude Code, GitHub Copilot, or any other coding agent.
|
||||
|
||||
## Getting Started
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install -g @playwright/cli@latest
|
||||
playwright-cli --help
|
||||
```
|
||||
|
||||
## Demo
|
||||
|
||||
```
|
||||
> Use playwright skills to test https://demo.playwright.dev/todomvc/.
|
||||
Take screenshots for all successful and failing scenarios.
|
||||
```
|
||||
|
||||
Your agent will be running commands, but it does not mean you can't play with it manually:
|
||||
|
||||
```
|
||||
playwright-cli open https://demo.playwright.dev/todomvc/ --headed
|
||||
playwright-cli type "Buy groceries"
|
||||
playwright-cli press Enter
|
||||
playwright-cli type "Water flowers"
|
||||
playwright-cli press Enter
|
||||
playwright-cli check e21
|
||||
playwright-cli check e35
|
||||
playwright-cli screenshot
|
||||
```
|
||||
|
||||
### Skills-less operation
|
||||
|
||||
Point your agent at the CLI and let it cook. It'll read the skill off `playwright-cli --help` on its own:
|
||||
|
||||
```
|
||||
Test the "add todo" flow on https://demo.playwright.dev/todomvc using playwright-cli.
|
||||
Check playwright-cli --help for available commands.
|
||||
```
|
||||
|
||||
### Installing skills
|
||||
|
||||
Claude Code, GitHub copilot and others will let you install the Playwright skills into the agentic loop.
|
||||
|
||||
#### plugin (recommended)
|
||||
```bash
|
||||
/plugin marketplace add microsoft/playwright-cli
|
||||
/plugin install playwright-cli
|
||||
```
|
||||
|
||||
#### manual
|
||||
|
||||
```bash
|
||||
mkdir -p .claude/skills/playwright-cli
|
||||
curl -o .claude/skills/playwright-cli/SKILL.md \
|
||||
https://raw.githubusercontent.com/microsoft/playwright-cli/main/skills/playwright-cli/SKILL.md
|
||||
```
|
||||
|
||||
## Headed operation
|
||||
|
||||
Playwright CLI is headless by default. If you'd like to see the browser, pass `--headed` to `open`:
|
||||
|
||||
```bash
|
||||
playwright-cli open https://playwright.dev --headed
|
||||
```
|
||||
|
||||
## Sessions
|
||||
|
||||
Playwright CLI will use a dedicated persistent profile by default. It means that
|
||||
your cookies and other storage state will be preserved between the calls. You can use different
|
||||
instances of the browser for different projects with sessions.
|
||||
|
||||
Following will result in two browsers with separate profiles being available. Pass `--session` to
|
||||
the invocation to talk to a specific browser.
|
||||
|
||||
```bash
|
||||
playwright-cli open https://playwright.dev
|
||||
playwright-cli --session=example open https://example.com
|
||||
playwright-cli session-list
|
||||
```
|
||||
|
||||
You can run your coding agent with the `PLAYWRIGHT_CLI_SESSION` environment variable:
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_CLI_SESSION=todo-app claude .
|
||||
```
|
||||
|
||||
Or instruct it to prepend `--session` to the calls.
|
||||
|
||||
Manage your sessions as follows:
|
||||
|
||||
```bash
|
||||
playwright-cli session-list # list all sessions
|
||||
playwright-cli session-stop [name] # stop session
|
||||
playwright-cli session-stop-all # stop all sessions
|
||||
playwright-cli session-delete [name] # delete session data along with the profiles
|
||||
```
|
||||
|
||||
<!-- BEGIN GENERATED CLI HELP -->
|
||||
|
||||
## Commands
|
||||
|
||||
### Core
|
||||
|
||||
```bash
|
||||
playwright-cli open <url> # open url
|
||||
playwright-cli close # close the page
|
||||
playwright-cli type <text> # type text into editable element
|
||||
playwright-cli click <ref> [button] # perform click on a web page
|
||||
playwright-cli dblclick <ref> [button] # perform double click on a web page
|
||||
playwright-cli fill <ref> <text> # fill text into editable element
|
||||
playwright-cli drag <startRef> <endRef> # perform drag and drop between two elements
|
||||
playwright-cli hover <ref> # hover over element on page
|
||||
playwright-cli select <ref> <val> # select an option in a dropdown
|
||||
playwright-cli upload <file> # upload one or multiple files
|
||||
playwright-cli check <ref> # check a checkbox or radio button
|
||||
playwright-cli uncheck <ref> # uncheck a checkbox or radio button
|
||||
playwright-cli snapshot # capture page snapshot to obtain element ref
|
||||
playwright-cli eval <func> [ref] # evaluate javascript expression on page or element
|
||||
playwright-cli dialog-accept [prompt] # accept a dialog
|
||||
playwright-cli dialog-dismiss # dismiss a dialog
|
||||
playwright-cli resize <w> <h> # resize the browser window
|
||||
```
|
||||
|
||||
### Navigation
|
||||
|
||||
```bash
|
||||
playwright-cli go-back # go back to the previous page
|
||||
playwright-cli go-forward # go forward to the next page
|
||||
playwright-cli reload # reload the current page
|
||||
```
|
||||
|
||||
### Keyboard
|
||||
|
||||
```bash
|
||||
playwright-cli press <key> # press a key on the keyboard, `a`, `arrowleft`
|
||||
playwright-cli keydown <key> # press a key down on the keyboard
|
||||
playwright-cli keyup <key> # press a key up on the keyboard
|
||||
```
|
||||
|
||||
### Mouse
|
||||
|
||||
```bash
|
||||
playwright-cli mousemove <x> <y> # move mouse to a given position
|
||||
playwright-cli mousedown [button] # press mouse down
|
||||
playwright-cli mouseup [button] # press mouse up
|
||||
playwright-cli mousewheel <dx> <dy> # scroll mouse wheel
|
||||
```
|
||||
|
||||
### Save as
|
||||
|
||||
```bash
|
||||
playwright-cli screenshot [ref] # screenshot of the current page or element
|
||||
playwright-cli pdf # save page as pdf
|
||||
```
|
||||
|
||||
### Tabs
|
||||
|
||||
```bash
|
||||
playwright-cli tab-list # list all tabs
|
||||
playwright-cli tab-new [url] # create a new tab
|
||||
playwright-cli tab-close [index] # close a browser tab
|
||||
playwright-cli tab-select <index> # select a browser tab
|
||||
```
|
||||
|
||||
### DevTools
|
||||
|
||||
```bash
|
||||
playwright-cli console [min-level] # list console messages
|
||||
playwright-cli network # list all network requests since loading the page
|
||||
playwright-cli run-code <code> # run playwright code snippet
|
||||
playwright-cli tracing-start # start trace recording
|
||||
playwright-cli tracing-stop # stop trace recording
|
||||
```
|
||||
<!-- END GENERATED CLI HELP -->
|
||||
|
||||
## Configuration file
|
||||
|
||||
The Playwright CLI can be configured using a JSON configuration file. You can specify the configuration file using the `--config` command line option:
|
||||
|
||||
```bash
|
||||
playwright-cli --config path/to/config.json open example.com
|
||||
```
|
||||
|
||||
Playwright CLI will load config from `playwright-cli.json` by default so that you did not need to specify it every time.
|
||||
|
||||
<details>
|
||||
<summary>Configuration file schema</summary>
|
||||
|
||||
```typescript
|
||||
{
|
||||
/**
|
||||
* The browser to use.
|
||||
*/
|
||||
browser?: {
|
||||
/**
|
||||
* The type of browser to use.
|
||||
*/
|
||||
browserName?: 'chromium' | 'firefox' | 'webkit';
|
||||
|
||||
/**
|
||||
* Keep the browser profile in memory, do not save it to disk.
|
||||
*/
|
||||
isolated?: boolean;
|
||||
|
||||
/**
|
||||
* Path to a user data directory for browser profile persistence.
|
||||
* Temporary directory is created by default.
|
||||
*/
|
||||
userDataDir?: string;
|
||||
|
||||
/**
|
||||
* Launch options passed to
|
||||
* @see https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context
|
||||
*
|
||||
* This is useful for settings options like `channel`, `headless`, `executablePath`, etc.
|
||||
*/
|
||||
launchOptions?: playwright.LaunchOptions;
|
||||
|
||||
/**
|
||||
* Context options for the browser context.
|
||||
*
|
||||
* This is useful for settings options like `viewport`.
|
||||
*/
|
||||
contextOptions?: playwright.BrowserContextOptions;
|
||||
|
||||
/**
|
||||
* Chrome DevTools Protocol endpoint to connect to an existing browser instance in case of Chromium family browsers.
|
||||
*/
|
||||
cdpEndpoint?: string;
|
||||
|
||||
/**
|
||||
* CDP headers to send with the connect request.
|
||||
*/
|
||||
cdpHeaders?: Record<string, string>;
|
||||
|
||||
/**
|
||||
* 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[];
|
||||
},
|
||||
|
||||
/**
|
||||
* If specified, saves the Playwright video of the session into the output directory.
|
||||
*/
|
||||
saveVideo?: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
allowedOrigins?: string[];
|
||||
|
||||
/**
|
||||
* List of origins to block the browser to request. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
|
||||
*/
|
||||
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 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';
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Environment
|
||||
|
||||
| Environment |
|
||||
|-------------|
|
||||
| `PLAYWRIGHT_MCP_ALLOWED_HOSTS` comma-separated list of hosts this server is allowed to serve from. Defaults to the host the server is bound to. Pass '*' to disable the host check. |
|
||||
| `PLAYWRIGHT_MCP_ALLOWED_ORIGINS` semicolon-separated list of TRUSTED origins to allow the browser to request. Default is to allow all. Important: *does not* serve as a security boundary and *does not* affect redirects. |
|
||||
| `PLAYWRIGHT_MCP_ALLOW_UNRESTRICTED_FILE_ACCESS` allow access to files outside of the workspace roots. Also allows unrestricted access to file:// URLs. By default access to file system is restricted to workspace root directories (or cwd if no roots are configured) only, and navigation to file:// URLs is blocked. |
|
||||
| `PLAYWRIGHT_MCP_BLOCKED_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. Important: *does not* serve as a security boundary and *does not* affect redirects. |
|
||||
| `PLAYWRIGHT_MCP_BLOCK_SERVICE_WORKERS` block service workers |
|
||||
| `PLAYWRIGHT_MCP_BROWSER` browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge. |
|
||||
| `PLAYWRIGHT_MCP_CAPS` comma-separated list of additional capabilities to enable, possible values: vision, pdf. |
|
||||
| `PLAYWRIGHT_MCP_CDP_ENDPOINT` CDP endpoint to connect to. |
|
||||
| `PLAYWRIGHT_MCP_CDP_HEADER` CDP headers to send with the connect request, multiple can be specified. |
|
||||
| `PLAYWRIGHT_MCP_CODEGEN` specify the language to use for code generation, possible values: "typescript", "none". Default is "typescript". |
|
||||
| `PLAYWRIGHT_MCP_CONFIG` path to the configuration file. |
|
||||
| `PLAYWRIGHT_MCP_CONSOLE_LEVEL` level of console messages to return: "error", "warning", "info", "debug". Each level includes the messages of more severe levels. |
|
||||
| `PLAYWRIGHT_MCP_DEVICE` device to emulate, for example: "iPhone 15" |
|
||||
| `PLAYWRIGHT_MCP_EXECUTABLE_PATH` path to the browser executable. |
|
||||
| `PLAYWRIGHT_MCP_EXTENSION` Connect to a running browser instance (Edge/Chrome only). Requires the "Playwright MCP Bridge" browser extension to be installed. |
|
||||
| `PLAYWRIGHT_MCP_GRANT_PERMISSIONS` List of permissions to grant to the browser context, for example "geolocation", "clipboard-read", "clipboard-write". |
|
||||
| `PLAYWRIGHT_MCP_HEADLESS` run browser in headless mode, headed by default |
|
||||
| `PLAYWRIGHT_MCP_HOST` host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces. |
|
||||
| `PLAYWRIGHT_MCP_IGNORE_HTTPS_ERRORS` ignore https errors |
|
||||
| `PLAYWRIGHT_MCP_INIT_PAGE` path to TypeScript file to evaluate on Playwright page object |
|
||||
| `PLAYWRIGHT_MCP_INIT_SCRIPT` path to JavaScript file to add as an initialization script. The script will be evaluated in every page before any of the page's scripts. Can be specified multiple times. |
|
||||
| `PLAYWRIGHT_MCP_ISOLATED` keep the browser profile in memory, do not save it to disk. |
|
||||
| `PLAYWRIGHT_MCP_IMAGE_RESPONSES` whether to send image responses to the client. Can be "allow" or "omit", Defaults to "allow". |
|
||||
| `PLAYWRIGHT_MCP_NO_SANDBOX` disable the sandbox for all process types that are normally sandboxed. |
|
||||
| `PLAYWRIGHT_MCP_OUTPUT_DIR` path to the directory for output files. |
|
||||
| `PLAYWRIGHT_MCP_OUTPUT_MODE` whether to save snapshots, console messages, network logs to a file or to the standard output. Can be "file" or "stdout". Default is "stdout". |
|
||||
| `PLAYWRIGHT_MCP_PORT` port to listen on for SSE transport. |
|
||||
| `PLAYWRIGHT_MCP_PROXY_BYPASS` comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com" |
|
||||
| `PLAYWRIGHT_MCP_PROXY_SERVER` specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080" |
|
||||
| `PLAYWRIGHT_MCP_SAVE_SESSION` Whether to save the Playwright MCP session into the output directory. |
|
||||
| `PLAYWRIGHT_MCP_SAVE_TRACE` Whether to save the Playwright Trace of the session into the output directory. |
|
||||
| `PLAYWRIGHT_MCP_SAVE_VIDEO` Whether to save the video of the session into the output directory. For example "--save-video=800x600" |
|
||||
| `PLAYWRIGHT_MCP_SECRETS` path to a file containing secrets in the dotenv format |
|
||||
| `PLAYWRIGHT_MCP_SHARED_BROWSER_CONTEXT` reuse the same browser context between all connected HTTP clients. |
|
||||
| `PLAYWRIGHT_MCP_SNAPSHOT_MODE` when taking snapshots for responses, specifies the mode to use. Can be "incremental", "full", or "none". Default is incremental. |
|
||||
| `PLAYWRIGHT_MCP_STORAGE_STATE` path to the storage state file for isolated sessions. |
|
||||
| `PLAYWRIGHT_MCP_TEST_ID_ATTRIBUTE` specify the attribute to use for test ids, defaults to "data-testid" |
|
||||
| `PLAYWRIGHT_MCP_TIMEOUT_ACTION` specify action timeout in milliseconds, defaults to 5000ms |
|
||||
| `PLAYWRIGHT_MCP_TIMEOUT_NAVIGATION` specify navigation timeout in milliseconds, defaults to 60000ms |
|
||||
| `PLAYWRIGHT_MCP_USER_AGENT` specify user agent string |
|
||||
| `PLAYWRIGHT_MCP_USER_DATA_DIR` path to the user data directory. If not specified, a temporary directory will be created. |
|
||||
| `PLAYWRIGHT_MCP_VIEWPORT_SIZE` specify browser viewport size in pixels, for example "1280x720" |
|
||||
30
packages/playwright-cli/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@playwright/cli",
|
||||
"version": "0.0.60",
|
||||
"description": "Playwright CLI",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/microsoft/playwright-cli.git"
|
||||
},
|
||||
"homepage": "https://playwright.dev",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"author": {
|
||||
"name": "Microsoft Corporation"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"lint": "echo OK",
|
||||
"build": "echo OK",
|
||||
"test": "echo OK"
|
||||
},
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.5",
|
||||
"playwright": "1.59.0-alpha-1769452054000",
|
||||
"playwright-core": "1.59.0-alpha-1769452054000"
|
||||
},
|
||||
"bin": {
|
||||
"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);
|
||||
});
|
||||
2
packages/playwright-mcp/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
README.md
|
||||
LICENSE
|
||||
@@ -1,7 +1,6 @@
|
||||
**/*
|
||||
README.md
|
||||
LICENSE
|
||||
!lib/**/*.js
|
||||
!README.md
|
||||
!LICENSE
|
||||
!cli.js
|
||||
!index.*
|
||||
!config.d.ts
|
||||
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 'fs';
|
||||
import path from 'path';
|
||||
import url from 'url';
|
||||
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);
|
||||
106
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[];
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -95,11 +132,43 @@ export type Config = {
|
||||
*/
|
||||
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.
|
||||
@@ -112,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,7 +16,7 @@
|
||||
*/
|
||||
|
||||
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 declare function createConnection(config?: Config, contextGetter?: () => Promise<BrowserContext>): Promise<Server>;
|
||||
@@ -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 };
|
||||
43
packages/playwright-mcp/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.60",
|
||||
"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": {
|
||||
"playwright": "1.59.0-alpha-1769452054000",
|
||||
"playwright-core": "1.59.0-alpha-1769452054000"
|
||||
},
|
||||
"bin": {
|
||||
"playwright-mcp": "cli.js"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
]));
|
||||
@@ -14,34 +14,36 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures.js';
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('browser_network_requests', async ({ client, server }) => {
|
||||
test('browser_click', async ({ client, server }) => {
|
||||
server.setContent('/', `
|
||||
<button onclick="fetch('/json')">Click me</button>
|
||||
<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');
|
||||
|
||||
server.setContent('/json', JSON.stringify({ name: 'John Doe' }), 'application/json');
|
||||
|
||||
await client.callTool({
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: server.PREFIX,
|
||||
},
|
||||
arguments: { url: server.PREFIX },
|
||||
})).toHaveResponse({
|
||||
code: `await page.goto('${server.PREFIX}');`,
|
||||
snapshot: expect.stringContaining(`- button \"Submit\" [ref=e2]`),
|
||||
});
|
||||
|
||||
await client.callTool({
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Click me button',
|
||||
element: 'Submit button',
|
||||
ref: 'e2',
|
||||
},
|
||||
});
|
||||
|
||||
await expect.poll(() => client.callTool({
|
||||
name: 'browser_network_requests',
|
||||
})).toHaveResponse({
|
||||
result: expect.stringContaining(`[GET] ${`${server.PREFIX}`} => [200] OK
|
||||
[GET] ${`${server.PREFIX}json`} => [200] OK`),
|
||||
code: `await page.getByRole('button', { name: 'Submit' }).click();`,
|
||||
snapshot: expect.stringContaining(`button "Submit" [active] [ref=e2]`),
|
||||
});
|
||||
});
|
||||
@@ -14,13 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures.js';
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('browser_install', async ({ client, mcpBrowser }) => {
|
||||
test.skip(mcpBrowser !== 'chromium', 'Test only chromium');
|
||||
test('browser_navigate', async ({ client, server }) => {
|
||||
expect(await client.callTool({
|
||||
name: 'browser_install',
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toHaveResponse({
|
||||
tabs: expect.stringContaining(`No open tabs`),
|
||||
code: `await page.goto('${server.HELLO_WORLD}');`,
|
||||
snapshot: expect.stringContaining(`generic [active] [ref=e1]: Hello, world!`),
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,6 @@
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import url from 'url';
|
||||
import path from 'path';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
@@ -23,7 +22,7 @@ import { test as baseTest, expect as baseExpect } from '@playwright/test';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { TestServer } from './testserver/index.ts';
|
||||
import { TestServer } from './testserver/index';
|
||||
|
||||
import type { Config } from '../config';
|
||||
import type { BrowserContext } from 'playwright';
|
||||
@@ -31,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;
|
||||
};
|
||||
@@ -40,14 +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,
|
||||
roots?: { name: string, uri: string }[],
|
||||
}) => Promise<{ client: Client, stderr: () => string }>;
|
||||
startClient: StartClient;
|
||||
wsEndpoint: string;
|
||||
cdpServer: CDPServer;
|
||||
server: TestServer;
|
||||
@@ -61,17 +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) => {
|
||||
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[] = [];
|
||||
const args: string[] = mcpArgs ?? [];
|
||||
if (process.env.CI && process.platform === 'linux')
|
||||
args.push('--no-sandbox');
|
||||
if (mcpHeadless)
|
||||
@@ -86,27 +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' }, options?.roots ? { capabilities: { roots: {} } } : undefined);
|
||||
const client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }, options?.roots ? { capabilities: { roots: {} } } : undefined);
|
||||
if (options?.roots) {
|
||||
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'));
|
||||
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) => {
|
||||
@@ -123,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,
|
||||
@@ -170,12 +182,10 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
||||
},
|
||||
});
|
||||
|
||||
async function createTransport(args: string[], mcpMode: TestOptions['mcpMode'], profilesDir: string): 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({
|
||||
@@ -190,8 +200,8 @@ 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,
|
||||
@@ -199,6 +209,7 @@ async function createTransport(args: string[], mcpMode: TestOptions['mcpMode'],
|
||||
DEBUG_COLORS: '0',
|
||||
DEBUG_HIDE_DATE: '1',
|
||||
PWMCP_PROFILES_DIR_FOR_TEST: profilesDir,
|
||||
...(extensionToken ? { PLAYWRIGHT_MCP_EXTENSION_TOKEN: extensionToken } : {}),
|
||||
},
|
||||
});
|
||||
return {
|
||||
@@ -218,7 +229,7 @@ export const expect = baseExpect.extend({
|
||||
expect(parsed).not.toEqual(expect.objectContaining(object));
|
||||
else
|
||||
expect(parsed).toEqual(expect.objectContaining(object));
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
return {
|
||||
pass: isNot,
|
||||
message: () => e.message,
|
||||
@@ -239,10 +250,12 @@ 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');
|
||||
@@ -251,10 +264,12 @@ function parseResponse(response: any) {
|
||||
const attachments = response.content.slice(1);
|
||||
|
||||
return {
|
||||
error,
|
||||
result,
|
||||
code: codeNoFrame,
|
||||
tabs,
|
||||
pageState,
|
||||
snapshot,
|
||||
consoleMessages,
|
||||
modalState,
|
||||
downloads,
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
import child_process from 'child_process';
|
||||
import fs from 'fs/promises';
|
||||
import { test, expect } from './fixtures.js';
|
||||
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 => {
|
||||
172
src/actions.d.ts
vendored
@@ -1,172 +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.
|
||||
*/
|
||||
|
||||
type Point = { x: number, y: number };
|
||||
|
||||
export type ActionName =
|
||||
'check' |
|
||||
'click' |
|
||||
'closePage' |
|
||||
'fill' |
|
||||
'navigate' |
|
||||
'openPage' |
|
||||
'press' |
|
||||
'select' |
|
||||
'uncheck' |
|
||||
'setInputFiles' |
|
||||
'assertText' |
|
||||
'assertValue' |
|
||||
'assertChecked' |
|
||||
'assertVisible' |
|
||||
'assertSnapshot';
|
||||
|
||||
export type ActionBase = {
|
||||
name: ActionName,
|
||||
signals: Signal[],
|
||||
ariaSnapshot?: string,
|
||||
};
|
||||
|
||||
export type ActionWithSelector = ActionBase & {
|
||||
selector: string,
|
||||
ref?: string,
|
||||
};
|
||||
|
||||
export type ClickAction = ActionWithSelector & {
|
||||
name: 'click',
|
||||
button: 'left' | 'middle' | 'right',
|
||||
modifiers: number,
|
||||
clickCount: number,
|
||||
position?: Point,
|
||||
};
|
||||
|
||||
export type CheckAction = ActionWithSelector & {
|
||||
name: 'check',
|
||||
};
|
||||
|
||||
export type UncheckAction = ActionWithSelector & {
|
||||
name: 'uncheck',
|
||||
};
|
||||
|
||||
export type FillAction = ActionWithSelector & {
|
||||
name: 'fill',
|
||||
text: string,
|
||||
};
|
||||
|
||||
export type NavigateAction = ActionBase & {
|
||||
name: 'navigate',
|
||||
url: string,
|
||||
};
|
||||
|
||||
export type OpenPageAction = ActionBase & {
|
||||
name: 'openPage',
|
||||
url: string,
|
||||
};
|
||||
|
||||
export type ClosesPageAction = ActionBase & {
|
||||
name: 'closePage',
|
||||
};
|
||||
|
||||
export type PressAction = ActionWithSelector & {
|
||||
name: 'press',
|
||||
key: string,
|
||||
modifiers: number,
|
||||
};
|
||||
|
||||
export type SelectAction = ActionWithSelector & {
|
||||
name: 'select',
|
||||
options: string[],
|
||||
};
|
||||
|
||||
export type SetInputFilesAction = ActionWithSelector & {
|
||||
name: 'setInputFiles',
|
||||
files: string[],
|
||||
};
|
||||
|
||||
export type AssertTextAction = ActionWithSelector & {
|
||||
name: 'assertText',
|
||||
text: string,
|
||||
substring: boolean,
|
||||
};
|
||||
|
||||
export type AssertValueAction = ActionWithSelector & {
|
||||
name: 'assertValue',
|
||||
value: string,
|
||||
};
|
||||
|
||||
export type AssertCheckedAction = ActionWithSelector & {
|
||||
name: 'assertChecked',
|
||||
checked: boolean,
|
||||
};
|
||||
|
||||
export type AssertVisibleAction = ActionWithSelector & {
|
||||
name: 'assertVisible',
|
||||
};
|
||||
|
||||
export type AssertSnapshotAction = ActionWithSelector & {
|
||||
name: 'assertSnapshot',
|
||||
ariaSnapshot: string,
|
||||
};
|
||||
|
||||
export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction | AssertTextAction | AssertValueAction | AssertCheckedAction | AssertVisibleAction | AssertSnapshotAction;
|
||||
export type AssertAction = AssertCheckedAction | AssertValueAction | AssertTextAction | AssertVisibleAction | AssertSnapshotAction;
|
||||
export type PerformOnRecordAction = ClickAction | CheckAction | UncheckAction | PressAction | SelectAction;
|
||||
|
||||
// Signals.
|
||||
|
||||
export type BaseSignal = {
|
||||
};
|
||||
|
||||
export type NavigationSignal = BaseSignal & {
|
||||
name: 'navigation',
|
||||
url: string,
|
||||
};
|
||||
|
||||
export type PopupSignal = BaseSignal & {
|
||||
name: 'popup',
|
||||
popupAlias: string,
|
||||
};
|
||||
|
||||
export type DownloadSignal = BaseSignal & {
|
||||
name: 'download',
|
||||
downloadAlias: string,
|
||||
};
|
||||
|
||||
export type DialogSignal = BaseSignal & {
|
||||
name: 'dialog',
|
||||
dialogAlias: string,
|
||||
};
|
||||
|
||||
export type Signal = NavigationSignal | PopupSignal | DownloadSignal | DialogSignal;
|
||||
|
||||
export type FrameDescription = {
|
||||
pageGuid: string;
|
||||
pageAlias: string;
|
||||
framePath: string[];
|
||||
};
|
||||
|
||||
export type ActionInContext = {
|
||||
frame: FrameDescription;
|
||||
description?: string;
|
||||
action: Action;
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
};
|
||||
|
||||
export type SignalInContext = {
|
||||
frame: FrameDescription;
|
||||
signal: Signal;
|
||||
timestamp: number;
|
||||
};
|
||||
@@ -1,244 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import net from 'net';
|
||||
import path from 'path';
|
||||
|
||||
import * as playwright from 'playwright';
|
||||
// @ts-ignore
|
||||
import { registryDirectory } from 'playwright-core/lib/server/registry/index';
|
||||
import { logUnhandledError, testDebug } from './log.js';
|
||||
import { createHash } from './utils.js';
|
||||
import { outputFile } from './config.js';
|
||||
|
||||
import type { FullConfig } from './config.js';
|
||||
|
||||
export function contextFactory(config: FullConfig): BrowserContextFactory {
|
||||
if (config.browser.remoteEndpoint)
|
||||
return new RemoteContextFactory(config);
|
||||
if (config.browser.cdpEndpoint)
|
||||
return new CdpContextFactory(config);
|
||||
if (config.browser.isolated)
|
||||
return new IsolatedContextFactory(config);
|
||||
return new PersistentContextFactory(config);
|
||||
}
|
||||
|
||||
export type ClientInfo = { name?: string, version?: string, rootPath?: string };
|
||||
|
||||
export interface BrowserContextFactory {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
|
||||
}
|
||||
|
||||
class BaseContextFactory implements BrowserContextFactory {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly config: FullConfig;
|
||||
protected _browserPromise: Promise<playwright.Browser> | undefined;
|
||||
protected _tracesDir: string | undefined;
|
||||
|
||||
constructor(name: string, description: string, config: FullConfig) {
|
||||
this.name = name;
|
||||
this.description = description;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
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(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||
if (this.config.saveTrace)
|
||||
this._tracesDir = await outputFile(this.config, clientInfo.rootPath, `traces-${Date.now()}`);
|
||||
|
||||
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(config: FullConfig) {
|
||||
super('isolated', 'Create a new isolated browser context', config);
|
||||
}
|
||||
|
||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||
await injectCdpPort(this.config.browser);
|
||||
const browserType = playwright[this.config.browser.browserName];
|
||||
return browserType.launch({
|
||||
tracesDir: this._tracesDir,
|
||||
...this.config.browser.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.config.browser.contextOptions);
|
||||
}
|
||||
}
|
||||
|
||||
class CdpContextFactory extends BaseContextFactory {
|
||||
constructor(config: FullConfig) {
|
||||
super('cdp', 'Connect to a browser over CDP', config);
|
||||
}
|
||||
|
||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||
return playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint!);
|
||||
}
|
||||
|
||||
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||
return this.config.browser.isolated ? await browser.newContext(this.config.browser.contextOptions) : browser.contexts()[0];
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteContextFactory extends BaseContextFactory {
|
||||
constructor(config: FullConfig) {
|
||||
super('remote', 'Connect to a browser using a remote endpoint', config);
|
||||
}
|
||||
|
||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||
const url = new URL(this.config.browser.remoteEndpoint!);
|
||||
url.searchParams.set('browser', this.config.browser.browserName);
|
||||
if (this.config.browser.launchOptions)
|
||||
url.searchParams.set('launch-options', JSON.stringify(this.config.browser.launchOptions));
|
||||
return playwright[this.config.browser.browserName].connect(String(url));
|
||||
}
|
||||
|
||||
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||
return browser.newContext(this.config.browser.contextOptions);
|
||||
}
|
||||
}
|
||||
|
||||
class PersistentContextFactory implements BrowserContextFactory {
|
||||
readonly config: FullConfig;
|
||||
readonly name = 'persistent';
|
||||
readonly description = 'Create a new persistent browser context';
|
||||
|
||||
private _userDataDirs = new Set<string>();
|
||||
|
||||
constructor(config: FullConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||
await injectCdpPort(this.config.browser);
|
||||
testDebug('create browser context (persistent)');
|
||||
const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo.rootPath);
|
||||
let tracesDir: string | undefined;
|
||||
if (this.config.saveTrace)
|
||||
tracesDir = await outputFile(this.config, clientInfo.rootPath, `traces-${Date.now()}`);
|
||||
|
||||
this._userDataDirs.add(userDataDir);
|
||||
testDebug('lock user data dir', userDataDir);
|
||||
|
||||
const browserType = playwright[this.config.browser.browserName];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
try {
|
||||
const browserContext = await browserType.launchPersistentContext(userDataDir, {
|
||||
tracesDir,
|
||||
...this.config.browser.launchOptions,
|
||||
...this.config.browser.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(rootPath: string | undefined) {
|
||||
const dir = process.env.PWMCP_PROFILES_DIR_FOR_TEST ?? registryDirectory;
|
||||
const browserToken = this.config.browser.launchOptions?.channel ?? this.config.browser?.browserName;
|
||||
// Hesitant putting hundreds of files into the user's workspace, so using it for hashing instead.
|
||||
const rootPathToken = rootPath ? `-${createHash(rootPath)}` : '';
|
||||
const result = path.join(dir, `mcp-${browserToken}${rootPathToken}`);
|
||||
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(): Promise<number> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -1,140 +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 { fileURLToPath } from 'url';
|
||||
import { z } from 'zod';
|
||||
import { FullConfig } from './config.js';
|
||||
import { Context } from './context.js';
|
||||
import { logUnhandledError } from './log.js';
|
||||
import { Response } from './response.js';
|
||||
import { SessionLog } from './sessionLog.js';
|
||||
import { filteredTools } from './tools.js';
|
||||
import { packageJSON } from './package.js';
|
||||
import { defineTool } from './tools/tool.js';
|
||||
|
||||
import type { Tool } from './tools/tool.js';
|
||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||
import type * as mcpServer from './mcp/server.js';
|
||||
import type { ServerBackend } from './mcp/server.js';
|
||||
|
||||
type NonEmptyArray<T> = [T, ...T[]];
|
||||
|
||||
export type FactoryList = NonEmptyArray<BrowserContextFactory>;
|
||||
|
||||
export class BrowserServerBackend implements ServerBackend {
|
||||
name = 'Playwright';
|
||||
version = packageJSON.version;
|
||||
|
||||
private _tools: Tool[];
|
||||
private _context: Context | undefined;
|
||||
private _sessionLog: SessionLog | undefined;
|
||||
private _config: FullConfig;
|
||||
private _browserContextFactory: BrowserContextFactory;
|
||||
|
||||
constructor(config: FullConfig, factories: FactoryList) {
|
||||
this._config = config;
|
||||
this._browserContextFactory = factories[0];
|
||||
this._tools = filteredTools(config);
|
||||
if (factories.length > 1)
|
||||
this._tools.push(this._defineContextSwitchTool(factories));
|
||||
}
|
||||
|
||||
async initialize(server: mcpServer.Server): Promise<void> {
|
||||
const capabilities = server.getClientCapabilities() as mcpServer.ClientCapabilities;
|
||||
let rootPath: string | undefined;
|
||||
if (capabilities.roots) {
|
||||
const { roots } = await server.listRoots();
|
||||
const firstRootUri = roots[0]?.uri;
|
||||
const url = firstRootUri ? new URL(firstRootUri) : undefined;
|
||||
rootPath = url ? fileURLToPath(url) : undefined;
|
||||
}
|
||||
this._sessionLog = this._config.saveSession ? await SessionLog.create(this._config, rootPath) : undefined;
|
||||
this._context = new Context({
|
||||
tools: this._tools,
|
||||
config: this._config,
|
||||
browserContextFactory: this._browserContextFactory,
|
||||
sessionLog: this._sessionLog,
|
||||
clientInfo: { ...server.getClientVersion(), rootPath },
|
||||
});
|
||||
}
|
||||
|
||||
tools(): mcpServer.ToolSchema<any>[] {
|
||||
return this._tools.map(tool => tool.schema);
|
||||
}
|
||||
|
||||
async callTool(schema: mcpServer.ToolSchema<any>, parsedArguments: any) {
|
||||
const context = this._context!;
|
||||
const response = new Response(context, schema.name, parsedArguments);
|
||||
const tool = this._tools.find(tool => tool.schema.name === schema.name)!;
|
||||
context.setRunningTool(true);
|
||||
try {
|
||||
await tool.handle(context, parsedArguments, response);
|
||||
await response.finish();
|
||||
this._sessionLog?.logResponse(response);
|
||||
} catch (error: any) {
|
||||
response.addError(String(error));
|
||||
} finally {
|
||||
context.setRunningTool(false);
|
||||
}
|
||||
return response.serialize();
|
||||
}
|
||||
|
||||
serverClosed() {
|
||||
void this._context!.dispose().catch(logUnhandledError);
|
||||
}
|
||||
|
||||
private _defineContextSwitchTool(factories: FactoryList): Tool<any> {
|
||||
const self = this;
|
||||
return defineTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_connect',
|
||||
title: 'Connect to a browser context',
|
||||
description: [
|
||||
'Connect to a browser using one of the available methods:',
|
||||
...factories.map(factory => `- "${factory.name}": ${factory.description}`),
|
||||
].join('\n'),
|
||||
inputSchema: z.object({
|
||||
method: z.enum(factories.map(factory => factory.name) as [string, ...string[]]).default(factories[0].name).describe('The method to use to connect to the browser'),
|
||||
}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
async handle(context, params, response) {
|
||||
const factory = factories.find(factory => factory.name === params.method);
|
||||
if (!factory) {
|
||||
response.addError('Unknown connection method: ' + params.method);
|
||||
return;
|
||||
}
|
||||
await self._setContextFactory(factory);
|
||||
response.addResult('Successfully changed connection method.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async _setContextFactory(newFactory: BrowserContextFactory) {
|
||||
if (this._context) {
|
||||
const options = {
|
||||
...this._context.options,
|
||||
browserContextFactory: newFactory,
|
||||
};
|
||||
await this._context.dispose();
|
||||
this._context = new Context(options);
|
||||
}
|
||||
this._browserContextFactory = newFactory;
|
||||
}
|
||||
}
|
||||
325
src/config.ts
@@ -1,325 +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 { sanitizeForFilePath } from './utils.js';
|
||||
|
||||
import type { Config, ToolCapability } from '../config.js';
|
||||
import type { BrowserContextOptions, LaunchOptions } from 'playwright';
|
||||
|
||||
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;
|
||||
permissions?: string[];
|
||||
port?: number;
|
||||
proxyBypass?: string;
|
||||
proxyServer?: string;
|
||||
saveSession?: boolean;
|
||||
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: {},
|
||||
saveTrace: false,
|
||||
};
|
||||
|
||||
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']>,
|
||||
saveTrace: boolean;
|
||||
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);
|
||||
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 === false)
|
||||
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';
|
||||
|
||||
if (cliOptions.permissions)
|
||||
contextOptions.permissions = cliOptions.permissions;
|
||||
|
||||
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,
|
||||
},
|
||||
saveSession: cliOptions.saveSession,
|
||||
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.permissions = commaSeparatedList(process.env.PLAYWRIGHT_MCP_PERMISSIONS);
|
||||
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, rootPath: string | undefined, name: string): Promise<string> {
|
||||
const outputDir = config.outputDir
|
||||
?? (rootPath ? path.join(rootPath, '.playwright-mcp') : undefined)
|
||||
?? path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString()));
|
||||
|
||||
await fs.promises.mkdir(outputDir, { recursive: true });
|
||||
const fileName = sanitizeForFilePath(name);
|
||||
return path.join(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;
|
||||
}
|
||||
276
src/context.ts
@@ -1,276 +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 { logUnhandledError } from './log.js';
|
||||
import { Tab } from './tab.js';
|
||||
import { outputFile } from './config.js';
|
||||
|
||||
import type { FullConfig } from './config.js';
|
||||
import type { Tool } from './tools/tool.js';
|
||||
import type { BrowserContextFactory, ClientInfo } from './browserContextFactory.js';
|
||||
import type * as actions from './actions.js';
|
||||
import type { SessionLog } from './sessionLog.js';
|
||||
|
||||
const testDebug = debug('pw:mcp:test');
|
||||
|
||||
type ContextOptions = {
|
||||
tools: Tool[];
|
||||
config: FullConfig;
|
||||
browserContextFactory: BrowserContextFactory;
|
||||
sessionLog: SessionLog | undefined;
|
||||
clientInfo: ClientInfo;
|
||||
};
|
||||
|
||||
export class Context {
|
||||
readonly tools: Tool[];
|
||||
readonly config: FullConfig;
|
||||
readonly sessionLog: SessionLog | undefined;
|
||||
readonly options: ContextOptions;
|
||||
private _browserContextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> | undefined;
|
||||
private _browserContextFactory: BrowserContextFactory;
|
||||
private _tabs: Tab[] = [];
|
||||
private _currentTab: Tab | undefined;
|
||||
private _clientInfo: ClientInfo;
|
||||
|
||||
private static _allContexts: Set<Context> = new Set();
|
||||
private _closeBrowserContextPromise: Promise<void> | undefined;
|
||||
private _isRunningTool: boolean = false;
|
||||
private _abortController = new AbortController();
|
||||
|
||||
constructor(options: ContextOptions) {
|
||||
this.tools = options.tools;
|
||||
this.config = options.config;
|
||||
this.sessionLog = options.sessionLog;
|
||||
this.options = options;
|
||||
this._browserContextFactory = options.browserContextFactory;
|
||||
this._clientInfo = options.clientInfo;
|
||||
testDebug('create context');
|
||||
Context._allContexts.add(this);
|
||||
}
|
||||
|
||||
static async disposeAll() {
|
||||
await Promise.all([...Context._allContexts].map(context => context.dispose()));
|
||||
}
|
||||
|
||||
tabs(): Tab[] {
|
||||
return this._tabs;
|
||||
}
|
||||
|
||||
currentTab(): Tab | undefined {
|
||||
return this._currentTab;
|
||||
}
|
||||
|
||||
currentTabOrDie(): Tab {
|
||||
if (!this._currentTab)
|
||||
throw new Error('No open pages available. Use the "browser_navigate" tool to navigate to a page 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) {
|
||||
const tab = this._tabs[index];
|
||||
if (!tab)
|
||||
throw new Error(`Tab ${index} not found`);
|
||||
await tab.page.bringToFront();
|
||||
this._currentTab = tab;
|
||||
return tab;
|
||||
}
|
||||
|
||||
async ensureTab(): Promise<Tab> {
|
||||
const { browserContext } = await this._ensureBrowserContext();
|
||||
if (!this._currentTab)
|
||||
await browserContext.newPage();
|
||||
return this._currentTab!;
|
||||
}
|
||||
|
||||
async closeTab(index: number | undefined): Promise<string> {
|
||||
const tab = index === undefined ? this._currentTab : this._tabs[index];
|
||||
if (!tab)
|
||||
throw new Error(`Tab ${index} not found`);
|
||||
const url = tab.page.url();
|
||||
await tab.page.close();
|
||||
return url;
|
||||
}
|
||||
|
||||
async outputFile(name: string): Promise<string> {
|
||||
return outputFile(this.config, this._clientInfo.rootPath, name);
|
||||
}
|
||||
|
||||
private _onPageCreated(page: playwright.Page) {
|
||||
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
|
||||
this._tabs.push(tab);
|
||||
if (!this._currentTab)
|
||||
this._currentTab = tab;
|
||||
}
|
||||
|
||||
private _onPageClosed(tab: Tab) {
|
||||
const index = this._tabs.indexOf(tab);
|
||||
if (index === -1)
|
||||
return;
|
||||
this._tabs.splice(index, 1);
|
||||
|
||||
if (this._currentTab === tab)
|
||||
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
|
||||
if (!this._tabs.length)
|
||||
void this.closeBrowserContext();
|
||||
}
|
||||
|
||||
async closeBrowserContext() {
|
||||
if (!this._closeBrowserContextPromise)
|
||||
this._closeBrowserContextPromise = this._closeBrowserContextImpl().catch(logUnhandledError);
|
||||
await this._closeBrowserContextPromise;
|
||||
this._closeBrowserContextPromise = undefined;
|
||||
}
|
||||
|
||||
isRunningTool() {
|
||||
return this._isRunningTool;
|
||||
}
|
||||
|
||||
setRunningTool(isRunningTool: boolean) {
|
||||
this._isRunningTool = isRunningTool;
|
||||
}
|
||||
|
||||
private async _closeBrowserContextImpl() {
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
this._abortController.abort('MCP context disposed');
|
||||
await this.closeBrowserContext();
|
||||
Context._allContexts.delete(this);
|
||||
}
|
||||
|
||||
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> }> {
|
||||
if (this._closeBrowserContextPromise)
|
||||
throw new Error('Another browser context is being closed.');
|
||||
// TODO: move to the browser context factory to make it based on isolation mode.
|
||||
const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal);
|
||||
const { browserContext } = result;
|
||||
await this._setupRequestInterception(browserContext);
|
||||
if (this.sessionLog)
|
||||
await InputRecorder.create(this, 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;
|
||||
}
|
||||
}
|
||||
|
||||
export class InputRecorder {
|
||||
private _context: Context;
|
||||
private _browserContext: playwright.BrowserContext;
|
||||
|
||||
private constructor(context: Context, browserContext: playwright.BrowserContext) {
|
||||
this._context = context;
|
||||
this._browserContext = browserContext;
|
||||
}
|
||||
|
||||
static async create(context: Context, browserContext: playwright.BrowserContext) {
|
||||
const recorder = new InputRecorder(context, browserContext);
|
||||
await recorder._initialize();
|
||||
return recorder;
|
||||
}
|
||||
|
||||
private async _initialize() {
|
||||
const sessionLog = this._context.sessionLog!;
|
||||
await (this._browserContext as any)._enableRecorder({
|
||||
mode: 'recording',
|
||||
recorderMode: 'api',
|
||||
}, {
|
||||
actionAdded: (page: playwright.Page, data: actions.ActionInContext, code: string) => {
|
||||
if (this._context.isRunningTool())
|
||||
return;
|
||||
const tab = Tab.forPage(page);
|
||||
if (tab)
|
||||
sessionLog.logUserAction(data.action, tab, code, false);
|
||||
},
|
||||
actionUpdated: (page: playwright.Page, data: actions.ActionInContext, code: string) => {
|
||||
if (this._context.isRunningTool())
|
||||
return;
|
||||
const tab = Tab.forPage(page);
|
||||
if (tab)
|
||||
sessionLog.logUserAction(data.action, tab, code, true);
|
||||
},
|
||||
signalAdded: (page: playwright.Page, data: actions.SignalInContext) => {
|
||||
if (this._context.isRunningTool())
|
||||
return;
|
||||
if (data.signal.name !== 'navigation')
|
||||
return;
|
||||
const tab = Tab.forPage(page);
|
||||
const navigateAction: actions.Action = {
|
||||
name: 'navigate',
|
||||
url: data.signal.url,
|
||||
signals: [],
|
||||
};
|
||||
if (tab)
|
||||
sessionLog.logUserAction(navigateAction, tab, `await page.goto('${data.signal.url}');`, false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,398 +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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* WebSocket server that bridges Playwright MCP and Chrome Extension
|
||||
*
|
||||
* Endpoints:
|
||||
* - /cdp/guid - Full CDP interface for Playwright MCP
|
||||
* - /extension/guid - Extension connection for chrome.debugger forwarding
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import http from 'http';
|
||||
import debug from 'debug';
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
import { httpAddressToString } from '../httpServer.js';
|
||||
import { logUnhandledError } from '../log.js';
|
||||
import { ManualPromise } from '../manualPromise.js';
|
||||
import type websocket from 'ws';
|
||||
import type { ClientInfo } from '../browserContextFactory.js';
|
||||
|
||||
// @ts-ignore
|
||||
const { registry } = await import('playwright-core/lib/server/registry/index');
|
||||
|
||||
const debugLogger = debug('pw:mcp:relay');
|
||||
|
||||
type CDPCommand = {
|
||||
id: number;
|
||||
sessionId?: string;
|
||||
method: string;
|
||||
params?: any;
|
||||
};
|
||||
|
||||
type CDPResponse = {
|
||||
id?: number;
|
||||
sessionId?: string;
|
||||
method?: string;
|
||||
params?: any;
|
||||
result?: any;
|
||||
error?: { code?: number; message: string };
|
||||
};
|
||||
|
||||
export class CDPRelayServer {
|
||||
private _wsHost: string;
|
||||
private _browserChannel: string;
|
||||
private _cdpPath: string;
|
||||
private _extensionPath: string;
|
||||
private _wss: WebSocketServer;
|
||||
private _playwrightConnection: WebSocket | null = null;
|
||||
private _extensionConnection: ExtensionConnection | null = null;
|
||||
private _connectedTabInfo: {
|
||||
targetInfo: any;
|
||||
// Page sessionId that should be used by this connection.
|
||||
sessionId: string;
|
||||
} | undefined;
|
||||
private _nextSessionId: number = 1;
|
||||
private _extensionConnectionPromise!: ManualPromise<void>;
|
||||
|
||||
constructor(server: http.Server, browserChannel: string) {
|
||||
this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
|
||||
this._browserChannel = browserChannel;
|
||||
|
||||
const uuid = crypto.randomUUID();
|
||||
this._cdpPath = `/cdp/${uuid}`;
|
||||
this._extensionPath = `/extension/${uuid}`;
|
||||
|
||||
this._resetExtensionConnection();
|
||||
this._wss = new WebSocketServer({ server });
|
||||
this._wss.on('connection', this._onConnection.bind(this));
|
||||
}
|
||||
|
||||
cdpEndpoint() {
|
||||
return `${this._wsHost}${this._cdpPath}`;
|
||||
}
|
||||
|
||||
extensionEndpoint() {
|
||||
return `${this._wsHost}${this._extensionPath}`;
|
||||
}
|
||||
|
||||
async ensureExtensionConnectionForMCPContext(clientInfo: ClientInfo, abortSignal: AbortSignal) {
|
||||
debugLogger('Ensuring extension connection for MCP context');
|
||||
if (this._extensionConnection)
|
||||
return;
|
||||
this._connectBrowser(clientInfo);
|
||||
debugLogger('Waiting for incoming extension connection');
|
||||
await Promise.race([
|
||||
this._extensionConnectionPromise,
|
||||
new Promise((_, reject) => abortSignal.addEventListener('abort', reject))
|
||||
]);
|
||||
debugLogger('Extension connection established');
|
||||
}
|
||||
|
||||
private _connectBrowser(clientInfo: ClientInfo) {
|
||||
const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
|
||||
// Need to specify "key" in the manifest.json to make the id stable when loading from file.
|
||||
const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/lib/ui/connect.html');
|
||||
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
|
||||
url.searchParams.set('client', JSON.stringify(clientInfo));
|
||||
const href = url.toString();
|
||||
const executableInfo = registry.findExecutable(this._browserChannel);
|
||||
if (!executableInfo)
|
||||
throw new Error(`Unsupported channel: "${this._browserChannel}"`);
|
||||
const executablePath = executableInfo.executablePath();
|
||||
if (!executablePath)
|
||||
throw new Error(`"${this._browserChannel}" executable not found. Make sure it is installed at a standard location.`);
|
||||
|
||||
spawn(executablePath, [href], {
|
||||
windowsHide: true,
|
||||
detached: true,
|
||||
shell: false,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.closeConnections('Server stopped');
|
||||
this._wss.close();
|
||||
}
|
||||
|
||||
closeConnections(reason: string) {
|
||||
this._closePlaywrightConnection(reason);
|
||||
this._closeExtensionConnection(reason);
|
||||
}
|
||||
|
||||
private _onConnection(ws: WebSocket, request: http.IncomingMessage): void {
|
||||
const url = new URL(`http://localhost${request.url}`);
|
||||
debugLogger(`New connection to ${url.pathname}`);
|
||||
if (url.pathname === this._cdpPath) {
|
||||
this._handlePlaywrightConnection(ws);
|
||||
} else if (url.pathname === this._extensionPath) {
|
||||
this._handleExtensionConnection(ws);
|
||||
} else {
|
||||
debugLogger(`Invalid path: ${url.pathname}`);
|
||||
ws.close(4004, 'Invalid path');
|
||||
}
|
||||
}
|
||||
|
||||
private _handlePlaywrightConnection(ws: WebSocket): void {
|
||||
if (this._playwrightConnection) {
|
||||
debugLogger('Rejecting second Playwright connection');
|
||||
ws.close(1000, 'Another CDP client already connected');
|
||||
return;
|
||||
}
|
||||
this._playwrightConnection = ws;
|
||||
ws.on('message', async data => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
await this._handlePlaywrightMessage(message);
|
||||
} catch (error: any) {
|
||||
debugLogger(`Error while handling Playwright message\n${data.toString()}\n`, error);
|
||||
}
|
||||
});
|
||||
ws.on('close', () => {
|
||||
if (this._playwrightConnection !== ws)
|
||||
return;
|
||||
this._playwrightConnection = null;
|
||||
this._closeExtensionConnection('Playwright client disconnected');
|
||||
debugLogger('Playwright WebSocket closed');
|
||||
});
|
||||
ws.on('error', error => {
|
||||
debugLogger('Playwright WebSocket error:', error);
|
||||
});
|
||||
debugLogger('Playwright MCP connected');
|
||||
}
|
||||
|
||||
private _closeExtensionConnection(reason: string) {
|
||||
this._extensionConnection?.close(reason);
|
||||
this._extensionConnectionPromise.reject(new Error(reason));
|
||||
this._resetExtensionConnection();
|
||||
}
|
||||
|
||||
private _resetExtensionConnection() {
|
||||
this._connectedTabInfo = undefined;
|
||||
this._extensionConnection = null;
|
||||
this._extensionConnectionPromise = new ManualPromise();
|
||||
void this._extensionConnectionPromise.catch(logUnhandledError);
|
||||
}
|
||||
|
||||
private _closePlaywrightConnection(reason: string) {
|
||||
if (this._playwrightConnection?.readyState === WebSocket.OPEN)
|
||||
this._playwrightConnection.close(1000, reason);
|
||||
this._playwrightConnection = null;
|
||||
}
|
||||
|
||||
private _handleExtensionConnection(ws: WebSocket): void {
|
||||
if (this._extensionConnection) {
|
||||
ws.close(1000, 'Another extension connection already established');
|
||||
return;
|
||||
}
|
||||
this._extensionConnection = new ExtensionConnection(ws);
|
||||
this._extensionConnection.onclose = (c, reason) => {
|
||||
debugLogger('Extension WebSocket closed:', reason, c === this._extensionConnection);
|
||||
if (this._extensionConnection !== c)
|
||||
return;
|
||||
this._resetExtensionConnection();
|
||||
this._closePlaywrightConnection(`Extension disconnected: ${reason}`);
|
||||
};
|
||||
this._extensionConnection.onmessage = this._handleExtensionMessage.bind(this);
|
||||
this._extensionConnectionPromise.resolve();
|
||||
}
|
||||
|
||||
private _handleExtensionMessage(method: string, params: any) {
|
||||
switch (method) {
|
||||
case 'forwardCDPEvent':
|
||||
const sessionId = params.sessionId || this._connectedTabInfo?.sessionId;
|
||||
this._sendToPlaywright({
|
||||
sessionId,
|
||||
method: params.method,
|
||||
params: params.params
|
||||
});
|
||||
break;
|
||||
case 'detachedFromTab':
|
||||
debugLogger('← Debugger detached from tab:', params);
|
||||
this._connectedTabInfo = undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async _handlePlaywrightMessage(message: CDPCommand): Promise<void> {
|
||||
debugLogger('← Playwright:', `${message.method} (id=${message.id})`);
|
||||
const { id, sessionId, method, params } = message;
|
||||
try {
|
||||
const result = await this._handleCDPCommand(method, params, sessionId);
|
||||
this._sendToPlaywright({ id, sessionId, result });
|
||||
} catch (e) {
|
||||
debugLogger('Error in the extension:', e);
|
||||
this._sendToPlaywright({
|
||||
id,
|
||||
sessionId,
|
||||
error: { message: (e as Error).message }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async _handleCDPCommand(method: string, params: any, sessionId: string | undefined): Promise<any> {
|
||||
switch (method) {
|
||||
case 'Browser.getVersion': {
|
||||
return {
|
||||
protocolVersion: '1.3',
|
||||
product: 'Chrome/Extension-Bridge',
|
||||
userAgent: 'CDP-Bridge-Server/1.0.0',
|
||||
};
|
||||
}
|
||||
case 'Browser.setDownloadBehavior': {
|
||||
return { };
|
||||
}
|
||||
case 'Target.setAutoAttach': {
|
||||
// Forward child session handling.
|
||||
if (sessionId)
|
||||
break;
|
||||
// Simulate auto-attach behavior with real target info
|
||||
const { targetInfo } = await this._extensionConnection!.send('attachToTab');
|
||||
this._connectedTabInfo = {
|
||||
targetInfo,
|
||||
sessionId: `pw-tab-${this._nextSessionId++}`,
|
||||
};
|
||||
debugLogger('Simulating auto-attach');
|
||||
this._sendToPlaywright({
|
||||
method: 'Target.attachedToTarget',
|
||||
params: {
|
||||
sessionId: this._connectedTabInfo.sessionId,
|
||||
targetInfo: {
|
||||
...this._connectedTabInfo.targetInfo,
|
||||
attached: true,
|
||||
},
|
||||
waitingForDebugger: false
|
||||
}
|
||||
});
|
||||
return { };
|
||||
}
|
||||
case 'Target.getTargetInfo': {
|
||||
return this._connectedTabInfo?.targetInfo;
|
||||
}
|
||||
}
|
||||
return await this._forwardToExtension(method, params, sessionId);
|
||||
}
|
||||
|
||||
private async _forwardToExtension(method: string, params: any, sessionId: string | undefined): Promise<any> {
|
||||
if (!this._extensionConnection)
|
||||
throw new Error('Extension not connected');
|
||||
// Top level sessionId is only passed between the relay and the client.
|
||||
if (this._connectedTabInfo?.sessionId === sessionId)
|
||||
sessionId = undefined;
|
||||
return await this._extensionConnection.send('forwardCDPCommand', { sessionId, method, params });
|
||||
}
|
||||
|
||||
private _sendToPlaywright(message: CDPResponse): void {
|
||||
debugLogger('→ Playwright:', `${message.method ?? `response(id=${message.id})`}`);
|
||||
this._playwrightConnection?.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
type ExtensionResponse = {
|
||||
id?: number;
|
||||
method?: string;
|
||||
params?: any;
|
||||
result?: any;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
class ExtensionConnection {
|
||||
private readonly _ws: WebSocket;
|
||||
private readonly _callbacks = new Map<number, { resolve: (o: any) => void, reject: (e: Error) => void, error: Error }>();
|
||||
private _lastId = 0;
|
||||
|
||||
onmessage?: (method: string, params: any) => void;
|
||||
onclose?: (self: ExtensionConnection, reason: string) => void;
|
||||
|
||||
constructor(ws: WebSocket) {
|
||||
this._ws = ws;
|
||||
this._ws.on('message', this._onMessage.bind(this));
|
||||
this._ws.on('close', this._onClose.bind(this));
|
||||
this._ws.on('error', this._onError.bind(this));
|
||||
}
|
||||
|
||||
async send(method: string, params?: any, sessionId?: string): Promise<any> {
|
||||
if (this._ws.readyState !== WebSocket.OPEN)
|
||||
throw new Error(`Unexpected WebSocket state: ${this._ws.readyState}`);
|
||||
const id = ++this._lastId;
|
||||
this._ws.send(JSON.stringify({ id, method, params, sessionId }));
|
||||
const error = new Error(`Protocol error: ${method}`);
|
||||
return new Promise((resolve, reject) => {
|
||||
this._callbacks.set(id, { resolve, reject, error });
|
||||
});
|
||||
}
|
||||
|
||||
close(message: string) {
|
||||
debugLogger('closing extension connection:', message);
|
||||
if (this._ws.readyState === WebSocket.OPEN)
|
||||
this._ws.close(1000, message);
|
||||
}
|
||||
|
||||
private _onMessage(event: websocket.RawData) {
|
||||
const eventData = event.toString();
|
||||
let parsedJson;
|
||||
try {
|
||||
parsedJson = JSON.parse(eventData);
|
||||
} catch (e: any) {
|
||||
debugLogger(`<closing ws> Closing websocket due to malformed JSON. eventData=${eventData} e=${e?.message}`);
|
||||
this._ws.close();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this._handleParsedMessage(parsedJson);
|
||||
} catch (e: any) {
|
||||
debugLogger(`<closing ws> Closing websocket due to failed onmessage callback. eventData=${eventData} e=${e?.message}`);
|
||||
this._ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
private _handleParsedMessage(object: ExtensionResponse) {
|
||||
if (object.id && this._callbacks.has(object.id)) {
|
||||
const callback = this._callbacks.get(object.id)!;
|
||||
this._callbacks.delete(object.id);
|
||||
if (object.error) {
|
||||
const error = callback.error;
|
||||
error.message = object.error;
|
||||
callback.reject(error);
|
||||
} else {
|
||||
callback.resolve(object.result);
|
||||
}
|
||||
} else if (object.id) {
|
||||
debugLogger('← Extension: unexpected response', object);
|
||||
} else {
|
||||
this.onmessage?.(object.method!, object.params);
|
||||
}
|
||||
}
|
||||
|
||||
private _onClose(event: websocket.CloseEvent) {
|
||||
debugLogger(`<ws closed> code=${event.code} reason=${event.reason}`);
|
||||
this._dispose();
|
||||
this.onclose?.(this, event.reason);
|
||||
}
|
||||
|
||||
private _onError(event: websocket.ErrorEvent) {
|
||||
debugLogger(`<ws error> message=${event.message} type=${event.type} target=${event.target}`);
|
||||
this._dispose();
|
||||
}
|
||||
|
||||
private _dispose() {
|
||||
for (const callback of this._callbacks.values())
|
||||
callback.reject(new Error('WebSocket closed'));
|
||||
this._callbacks.clear();
|
||||
}
|
||||
}
|
||||
@@ -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 debug from 'debug';
|
||||
import * as playwright from 'playwright';
|
||||
import { startHttpServer } from '../httpServer.js';
|
||||
import { CDPRelayServer } from './cdpRelay.js';
|
||||
|
||||
import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js';
|
||||
|
||||
const debugLogger = debug('pw:mcp:relay');
|
||||
|
||||
export class ExtensionContextFactory implements BrowserContextFactory {
|
||||
name = 'extension';
|
||||
description = 'Connect to a browser using the Playwright MCP extension';
|
||||
|
||||
private _browserChannel: string;
|
||||
private _relayPromise: Promise<CDPRelayServer> | undefined;
|
||||
private _browserPromise: Promise<playwright.Browser> | undefined;
|
||||
|
||||
constructor(browserChannel: string) {
|
||||
this._browserChannel = browserChannel;
|
||||
}
|
||||
|
||||
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||
// First call will establish the connection to the extension.
|
||||
if (!this._browserPromise)
|
||||
this._browserPromise = this._obtainBrowser(clientInfo, abortSignal);
|
||||
const browser = await this._browserPromise;
|
||||
return {
|
||||
browserContext: browser.contexts()[0],
|
||||
close: async () => {
|
||||
debugLogger('close() called for browser context');
|
||||
await browser.close();
|
||||
this._browserPromise = undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async _obtainBrowser(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<playwright.Browser> {
|
||||
if (!this._relayPromise)
|
||||
this._relayPromise = this._startRelay(abortSignal);
|
||||
const relay = await this._relayPromise;
|
||||
|
||||
abortSignal.throwIfAborted();
|
||||
await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal);
|
||||
const browser = await playwright.chromium.connectOverCDP(relay.cdpEndpoint());
|
||||
browser.on('disconnected', () => {
|
||||
this._browserPromise = undefined;
|
||||
debugLogger('Browser disconnected');
|
||||
});
|
||||
return browser;
|
||||
}
|
||||
|
||||
private async _startRelay(abortSignal: AbortSignal) {
|
||||
const httpServer = await startHttpServer({});
|
||||
const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel);
|
||||
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
|
||||
if (abortSignal.aborted)
|
||||
cdpRelayServer.stop();
|
||||
else
|
||||
abortSignal.addEventListener('abort', () => cdpRelayServer.stop());
|
||||
return cdpRelayServer;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ExtensionContextFactory } from './extensionContextFactory.js';
|
||||
import { BrowserServerBackend } from '../browserServerBackend.js';
|
||||
import * as mcpTransport from '../mcp/transport.js';
|
||||
|
||||
import type { FullConfig } from '../config.js';
|
||||
|
||||
export async function runWithExtension(config: FullConfig) {
|
||||
const contextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome');
|
||||
const serverBackendFactory = () => new BrowserServerBackend(config, [contextFactory]);
|
||||
await mcpTransport.start(serverBackendFactory, config.server);
|
||||
}
|
||||
|
||||
export function createExtensionContextFactory(config: FullConfig) {
|
||||
return new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome');
|
||||
}
|
||||
@@ -1,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,44 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import assert from 'assert';
|
||||
import http from 'http';
|
||||
|
||||
import type * as net from 'net';
|
||||
|
||||
export async function startHttpServer(config: { host?: string, port?: number }): Promise<http.Server> {
|
||||
const { host, port } = config;
|
||||
const httpServer = http.createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
httpServer.on('error', reject);
|
||||
httpServer.listen(port, host, () => {
|
||||
resolve();
|
||||
httpServer.removeListener('error', reject);
|
||||
});
|
||||
});
|
||||
return httpServer;
|
||||
}
|
||||
|
||||
export function httpAddressToString(address: string | net.AddressInfo | null): string {
|
||||
assert(address, 'Could not bind server socket');
|
||||
if (typeof address === 'string')
|
||||
return address;
|
||||
const resolvedPort = address.port;
|
||||
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
|
||||
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
|
||||
resolvedHost = 'localhost';
|
||||
return `http://${resolvedHost}:${resolvedPort}`;
|
||||
}
|
||||
50
src/index.ts
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { BrowserServerBackend } from './browserServerBackend.js';
|
||||
import { resolveConfig } from './config.js';
|
||||
import { contextFactory } from './browserContextFactory.js';
|
||||
import * as mcpServer from './mcp/server.js';
|
||||
|
||||
import type { Config } from '../config.js';
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
|
||||
export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Server> {
|
||||
const config = await resolveConfig(userConfig);
|
||||
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config);
|
||||
return mcpServer.createServer(new BrowserServerBackend(config, [factory]), false);
|
||||
}
|
||||
|
||||
class SimpleBrowserContextFactory implements BrowserContextFactory {
|
||||
name = 'custom';
|
||||
description = 'Connect to a browser using a custom context getter';
|
||||
|
||||
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);
|
||||
}
|
||||
108
src/loop/loop.ts
@@ -1,108 +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 type { Tool, ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
|
||||
export type LLMToolCall = {
|
||||
name: string;
|
||||
arguments: any;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type LLMTool = {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: any;
|
||||
};
|
||||
|
||||
export type LLMMessage =
|
||||
| { role: 'user'; content: string }
|
||||
| { role: 'assistant'; content: string; toolCalls?: LLMToolCall[] }
|
||||
| { role: 'tool'; toolCallId: string; content: string; isError?: boolean };
|
||||
|
||||
export type LLMConversation = {
|
||||
messages: LLMMessage[];
|
||||
tools: LLMTool[];
|
||||
};
|
||||
|
||||
export interface LLMDelegate {
|
||||
createConversation(task: string, tools: Tool[], oneShot: boolean): LLMConversation;
|
||||
makeApiCall(conversation: LLMConversation): Promise<LLMToolCall[]>;
|
||||
addToolResults(conversation: LLMConversation, results: Array<{ toolCallId: string; content: string; isError?: boolean }>): void;
|
||||
checkDoneToolCall(toolCall: LLMToolCall): string | null;
|
||||
}
|
||||
|
||||
export async function runTask(delegate: LLMDelegate, client: Client, task: string, oneShot: boolean = false): Promise<LLMMessage[]> {
|
||||
const { tools } = await client.listTools();
|
||||
const taskContent = oneShot ? `Perform following task: ${task}.` : `Perform following task: ${task}. Once the task is complete, call the "done" tool.`;
|
||||
const conversation = delegate.createConversation(taskContent, tools, oneShot);
|
||||
|
||||
for (let iteration = 0; iteration < 5; ++iteration) {
|
||||
debug('history')('Making API call for iteration', iteration);
|
||||
const toolCalls = await delegate.makeApiCall(conversation);
|
||||
if (toolCalls.length === 0)
|
||||
throw new Error('Call the "done" tool when the task is complete.');
|
||||
|
||||
const toolResults: Array<{ toolCallId: string; content: string; isError?: boolean }> = [];
|
||||
for (const toolCall of toolCalls) {
|
||||
const doneResult = delegate.checkDoneToolCall(toolCall);
|
||||
if (doneResult !== null)
|
||||
return conversation.messages;
|
||||
|
||||
const { name, arguments: args, id } = toolCall;
|
||||
try {
|
||||
debug('tool')(name, args);
|
||||
const response = await client.callTool({
|
||||
name,
|
||||
arguments: args,
|
||||
});
|
||||
const responseContent = (response.content || []) as (TextContent | ImageContent)[];
|
||||
debug('tool')(responseContent);
|
||||
const text = responseContent.filter(part => part.type === 'text').map(part => part.text).join('\n');
|
||||
|
||||
toolResults.push({
|
||||
toolCallId: id,
|
||||
content: text,
|
||||
});
|
||||
} catch (error) {
|
||||
debug('tool')(error);
|
||||
toolResults.push({
|
||||
toolCallId: id,
|
||||
content: `Error while executing tool "${name}": ${error instanceof Error ? error.message : String(error)}\n\nPlease try to recover and complete the task.`,
|
||||
isError: true,
|
||||
});
|
||||
|
||||
// Skip remaining tool calls for this iteration
|
||||
for (const remainingToolCall of toolCalls.slice(toolCalls.indexOf(toolCall) + 1)) {
|
||||
toolResults.push({
|
||||
toolCallId: remainingToolCall.id,
|
||||
content: `This tool call is skipped due to previous error.`,
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
delegate.addToolResults(conversation, toolResults);
|
||||
if (oneShot)
|
||||
return conversation.messages;
|
||||
}
|
||||
|
||||
throw new Error('Failed to perform step, max attempts reached');
|
||||
}
|
||||
@@ -1,177 +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 Anthropic from '@anthropic-ai/sdk';
|
||||
import type { LLMDelegate, LLMConversation, LLMToolCall, LLMTool } from './loop.js';
|
||||
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
const model = 'claude-sonnet-4-20250514';
|
||||
|
||||
export class ClaudeDelegate implements LLMDelegate {
|
||||
private _anthropic: Anthropic | undefined;
|
||||
|
||||
async anthropic(): Promise<Anthropic> {
|
||||
if (!this._anthropic) {
|
||||
const anthropic = await import('@anthropic-ai/sdk');
|
||||
this._anthropic = new anthropic.Anthropic();
|
||||
}
|
||||
return this._anthropic;
|
||||
}
|
||||
|
||||
createConversation(task: string, tools: Tool[], oneShot: boolean): LLMConversation {
|
||||
const llmTools: LLMTool[] = tools.map(tool => ({
|
||||
name: tool.name,
|
||||
description: tool.description || '',
|
||||
inputSchema: tool.inputSchema,
|
||||
}));
|
||||
|
||||
if (!oneShot) {
|
||||
llmTools.push({
|
||||
name: 'done',
|
||||
description: 'Call this tool when the task is complete.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: task
|
||||
}],
|
||||
tools: llmTools,
|
||||
};
|
||||
}
|
||||
|
||||
async makeApiCall(conversation: LLMConversation): Promise<LLMToolCall[]> {
|
||||
// Convert generic messages to Claude format
|
||||
const claudeMessages: Anthropic.Messages.MessageParam[] = [];
|
||||
|
||||
for (const message of conversation.messages) {
|
||||
if (message.role === 'user') {
|
||||
claudeMessages.push({
|
||||
role: 'user',
|
||||
content: message.content
|
||||
});
|
||||
} else if (message.role === 'assistant') {
|
||||
const content: Anthropic.Messages.ContentBlock[] = [];
|
||||
|
||||
// Add text content
|
||||
if (message.content) {
|
||||
content.push({
|
||||
type: 'text',
|
||||
text: message.content,
|
||||
citations: []
|
||||
});
|
||||
}
|
||||
|
||||
// Add tool calls
|
||||
if (message.toolCalls) {
|
||||
for (const toolCall of message.toolCalls) {
|
||||
content.push({
|
||||
type: 'tool_use',
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
input: toolCall.arguments
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
claudeMessages.push({
|
||||
role: 'assistant',
|
||||
content
|
||||
});
|
||||
} else if (message.role === 'tool') {
|
||||
// Tool results are added differently - we need to find if there's already a user message with tool results
|
||||
const lastMessage = claudeMessages[claudeMessages.length - 1];
|
||||
const toolResult: Anthropic.Messages.ToolResultBlockParam = {
|
||||
type: 'tool_result',
|
||||
tool_use_id: message.toolCallId,
|
||||
content: message.content,
|
||||
is_error: message.isError,
|
||||
};
|
||||
|
||||
if (lastMessage && lastMessage.role === 'user' && Array.isArray(lastMessage.content)) {
|
||||
// Add to existing tool results message
|
||||
(lastMessage.content as Anthropic.Messages.ToolResultBlockParam[]).push(toolResult);
|
||||
} else {
|
||||
// Create new tool results message
|
||||
claudeMessages.push({
|
||||
role: 'user',
|
||||
content: [toolResult]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert generic tools to Claude format
|
||||
const claudeTools: Anthropic.Messages.Tool[] = conversation.tools.map(tool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
input_schema: tool.inputSchema,
|
||||
}));
|
||||
|
||||
const anthropic = await this.anthropic();
|
||||
const response = await anthropic.messages.create({
|
||||
model,
|
||||
max_tokens: 10000,
|
||||
messages: claudeMessages,
|
||||
tools: claudeTools,
|
||||
});
|
||||
|
||||
// Extract tool calls and add assistant message to generic conversation
|
||||
const toolCalls = response.content.filter(block => block.type === 'tool_use') as Anthropic.Messages.ToolUseBlock[];
|
||||
const textContent = response.content.filter(block => block.type === 'text').map(block => (block as Anthropic.Messages.TextBlock).text).join('');
|
||||
|
||||
const llmToolCalls: LLMToolCall[] = toolCalls.map(toolCall => ({
|
||||
name: toolCall.name,
|
||||
arguments: toolCall.input as any,
|
||||
id: toolCall.id,
|
||||
}));
|
||||
|
||||
// Add assistant message to generic conversation
|
||||
conversation.messages.push({
|
||||
role: 'assistant',
|
||||
content: textContent,
|
||||
toolCalls: llmToolCalls.length > 0 ? llmToolCalls : undefined
|
||||
});
|
||||
|
||||
return llmToolCalls;
|
||||
}
|
||||
|
||||
addToolResults(
|
||||
conversation: LLMConversation,
|
||||
results: Array<{ toolCallId: string; content: string; isError?: boolean }>
|
||||
): void {
|
||||
for (const result of results) {
|
||||
conversation.messages.push({
|
||||
role: 'tool',
|
||||
toolCallId: result.toolCallId,
|
||||
content: result.content,
|
||||
isError: result.isError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
checkDoneToolCall(toolCall: LLMToolCall): string | null {
|
||||
if (toolCall.name === 'done')
|
||||
return (toolCall.arguments as { result: string }).result;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,168 +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 OpenAI from 'openai';
|
||||
import type { LLMDelegate, LLMConversation, LLMToolCall, LLMTool } from './loop.js';
|
||||
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
const model = 'gpt-4.1';
|
||||
|
||||
export class OpenAIDelegate implements LLMDelegate {
|
||||
private _openai: OpenAI | undefined;
|
||||
|
||||
async openai(): Promise<OpenAI> {
|
||||
if (!this._openai) {
|
||||
const oai = await import('openai');
|
||||
this._openai = new oai.OpenAI();
|
||||
}
|
||||
return this._openai;
|
||||
}
|
||||
|
||||
createConversation(task: string, tools: Tool[], oneShot: boolean): LLMConversation {
|
||||
const genericTools: LLMTool[] = tools.map(tool => ({
|
||||
name: tool.name,
|
||||
description: tool.description || '',
|
||||
inputSchema: tool.inputSchema,
|
||||
}));
|
||||
|
||||
if (!oneShot) {
|
||||
genericTools.push({
|
||||
name: 'done',
|
||||
description: 'Call this tool when the task is complete.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: task
|
||||
}],
|
||||
tools: genericTools,
|
||||
};
|
||||
}
|
||||
|
||||
async makeApiCall(conversation: LLMConversation): Promise<LLMToolCall[]> {
|
||||
// Convert generic messages to OpenAI format
|
||||
const openaiMessages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [];
|
||||
|
||||
for (const message of conversation.messages) {
|
||||
if (message.role === 'user') {
|
||||
openaiMessages.push({
|
||||
role: 'user',
|
||||
content: message.content
|
||||
});
|
||||
} else if (message.role === 'assistant') {
|
||||
const toolCalls: OpenAI.Chat.Completions.ChatCompletionMessageToolCall[] = [];
|
||||
|
||||
if (message.toolCalls) {
|
||||
for (const toolCall of message.toolCalls) {
|
||||
toolCalls.push({
|
||||
id: toolCall.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: toolCall.name,
|
||||
arguments: JSON.stringify(toolCall.arguments)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const assistantMessage: OpenAI.Chat.Completions.ChatCompletionAssistantMessageParam = {
|
||||
role: 'assistant'
|
||||
};
|
||||
|
||||
if (message.content)
|
||||
assistantMessage.content = message.content;
|
||||
|
||||
if (toolCalls.length > 0)
|
||||
assistantMessage.tool_calls = toolCalls;
|
||||
|
||||
openaiMessages.push(assistantMessage);
|
||||
} else if (message.role === 'tool') {
|
||||
openaiMessages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: message.toolCallId,
|
||||
content: message.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Convert generic tools to OpenAI format
|
||||
const openaiTools: OpenAI.Chat.Completions.ChatCompletionTool[] = conversation.tools.map(tool => ({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema,
|
||||
},
|
||||
}));
|
||||
|
||||
const openai = await this.openai();
|
||||
const response = await openai.chat.completions.create({
|
||||
model,
|
||||
messages: openaiMessages,
|
||||
tools: openaiTools,
|
||||
tool_choice: 'auto'
|
||||
});
|
||||
|
||||
const message = response.choices[0].message;
|
||||
|
||||
// Extract tool calls and add assistant message to generic conversation
|
||||
const toolCalls = message.tool_calls || [];
|
||||
const genericToolCalls: LLMToolCall[] = toolCalls.map(toolCall => {
|
||||
const functionCall = toolCall.function;
|
||||
return {
|
||||
name: functionCall.name,
|
||||
arguments: JSON.parse(functionCall.arguments),
|
||||
id: toolCall.id,
|
||||
};
|
||||
});
|
||||
|
||||
// Add assistant message to generic conversation
|
||||
conversation.messages.push({
|
||||
role: 'assistant',
|
||||
content: message.content || '',
|
||||
toolCalls: genericToolCalls.length > 0 ? genericToolCalls : undefined
|
||||
});
|
||||
|
||||
return genericToolCalls;
|
||||
}
|
||||
|
||||
addToolResults(
|
||||
conversation: LLMConversation,
|
||||
results: Array<{ toolCallId: string; content: string; isError?: boolean }>
|
||||
): void {
|
||||
for (const result of results) {
|
||||
conversation.messages.push({
|
||||
role: 'tool',
|
||||
toolCallId: result.toolCallId,
|
||||
content: result.content,
|
||||
isError: result.isError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
checkDoneToolCall(toolCall: LLMToolCall): string | null {
|
||||
if (toolCall.name === 'done')
|
||||
return toolCall.arguments.result;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import path from 'path';
|
||||
import url from 'url';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { program } from 'commander';
|
||||
import { OpenAIDelegate } from './loopOpenAI.js';
|
||||
import { ClaudeDelegate } from './loopClaude.js';
|
||||
import { runTask } from './loop.js';
|
||||
|
||||
import type { LLMDelegate } from './loop.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const __filename = url.fileURLToPath(import.meta.url);
|
||||
|
||||
async function run(delegate: LLMDelegate) {
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
args: [
|
||||
path.resolve(__filename, '../../../cli.js'),
|
||||
'--save-session',
|
||||
'--output-dir', path.resolve(__filename, '../../../sessions')
|
||||
],
|
||||
stderr: 'inherit',
|
||||
env: process.env as Record<string, string>,
|
||||
});
|
||||
|
||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
await client.ping();
|
||||
|
||||
for (const task of tasks) {
|
||||
const messages = await runTask(delegate, client, task);
|
||||
for (const message of messages)
|
||||
console.log(`${message.role}: ${message.content}`);
|
||||
}
|
||||
await client.close();
|
||||
}
|
||||
|
||||
const tasks = [
|
||||
'Open https://playwright.dev/',
|
||||
];
|
||||
|
||||
program
|
||||
.option('--model <model>', 'model to use')
|
||||
.action(async options => {
|
||||
if (options.model === 'claude')
|
||||
await run(new ClaudeDelegate());
|
||||
else
|
||||
await run(new OpenAIDelegate());
|
||||
});
|
||||
void program.parseAsync(process.argv);
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { contextFactory } from '../browserContextFactory.js';
|
||||
import { BrowserServerBackend } from '../browserServerBackend.js';
|
||||
import { Context as BrowserContext } from '../context.js';
|
||||
import { runTask } from '../loop/loop.js';
|
||||
import { OpenAIDelegate } from '../loop/loopOpenAI.js';
|
||||
import { ClaudeDelegate } from '../loop/loopClaude.js';
|
||||
import { InProcessTransport } from '../mcp/inProcessTransport.js';
|
||||
import * as mcpServer from '../mcp/server.js';
|
||||
|
||||
import type { LLMDelegate } from '../loop/loop.js';
|
||||
import type { FullConfig } from '../config.js';
|
||||
|
||||
export class Context {
|
||||
readonly config: FullConfig;
|
||||
private _client: Client;
|
||||
private _delegate: LLMDelegate;
|
||||
|
||||
constructor(config: FullConfig, client: Client) {
|
||||
this.config = config;
|
||||
this._client = client;
|
||||
if (process.env.OPENAI_API_KEY)
|
||||
this._delegate = new OpenAIDelegate();
|
||||
else if (process.env.ANTHROPIC_API_KEY)
|
||||
this._delegate = new ClaudeDelegate();
|
||||
else
|
||||
throw new Error('No LLM API key found. Please set OPENAI_API_KEY or ANTHROPIC_API_KEY environment variable.');
|
||||
}
|
||||
|
||||
static async create(config: FullConfig) {
|
||||
const client = new Client({ name: 'Playwright Proxy', version: '1.0.0' });
|
||||
const browserContextFactory = contextFactory(config);
|
||||
const server = mcpServer.createServer(new BrowserServerBackend(config, [browserContextFactory]), false);
|
||||
await client.connect(new InProcessTransport(server));
|
||||
await client.ping();
|
||||
return new Context(config, client);
|
||||
}
|
||||
|
||||
async runTask(task: string, oneShot: boolean = false): Promise<mcpServer.ToolResponse> {
|
||||
const messages = await runTask(this._delegate, this._client!, task, oneShot);
|
||||
const lines: string[] = [];
|
||||
|
||||
// Skip the first message, which is the user's task.
|
||||
for (const message of messages.slice(1)) {
|
||||
// Trim out all page snapshots.
|
||||
if (!message.content.trim())
|
||||
continue;
|
||||
const index = oneShot ? -1 : message.content.indexOf('### Page state');
|
||||
const trimmedContent = index === -1 ? message.content : message.content.substring(0, index);
|
||||
lines.push(`[${message.role}]:`, trimmedContent);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: lines.join('\n') }],
|
||||
};
|
||||
}
|
||||
|
||||
async close() {
|
||||
await BrowserContext.disposeAll();
|
||||
}
|
||||
}
|
||||
@@ -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 dotenv from 'dotenv';
|
||||
|
||||
import * as mcpServer from '../mcp/server.js';
|
||||
import * as mcpTransport from '../mcp/transport.js';
|
||||
import { packageJSON } from '../package.js';
|
||||
import { Context } from './context.js';
|
||||
import { perform } from './perform.js';
|
||||
import { snapshot } from './snapshot.js';
|
||||
|
||||
import type { FullConfig } from '../config.js';
|
||||
import type { ServerBackend } from '../mcp/server.js';
|
||||
import type { Tool } from './tool.js';
|
||||
|
||||
export async function runLoopTools(config: FullConfig) {
|
||||
dotenv.config();
|
||||
const serverBackendFactory = () => new LoopToolsServerBackend(config);
|
||||
await mcpTransport.start(serverBackendFactory, config.server);
|
||||
}
|
||||
|
||||
class LoopToolsServerBackend implements ServerBackend {
|
||||
readonly name = 'Playwright';
|
||||
readonly version = packageJSON.version;
|
||||
private _config: FullConfig;
|
||||
private _context: Context | undefined;
|
||||
private _tools: Tool<any>[] = [perform, snapshot];
|
||||
|
||||
constructor(config: FullConfig) {
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this._context = await Context.create(this._config);
|
||||
}
|
||||
|
||||
tools(): mcpServer.ToolSchema<any>[] {
|
||||
return this._tools.map(tool => tool.schema);
|
||||
}
|
||||
|
||||
async callTool(schema: mcpServer.ToolSchema<any>, parsedArguments: any): Promise<mcpServer.ToolResponse> {
|
||||
const tool = this._tools.find(tool => tool.schema.name === schema.name)!;
|
||||
return await tool.handle(this._context!, parsedArguments);
|
||||
}
|
||||
|
||||
serverClosed() {
|
||||
void this._context!.close();
|
||||
}
|
||||
}
|
||||
@@ -1,36 +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 performSchema = z.object({
|
||||
task: z.string().describe('The task to perform with the browser'),
|
||||
});
|
||||
|
||||
export const perform = defineTool({
|
||||
schema: {
|
||||
name: 'browser_perform',
|
||||
title: 'Perform a task with the browser',
|
||||
description: 'Perform a task with the browser. It can click, type, export, capture screenshot, drag, hover, select options, etc.',
|
||||
inputSchema: performSchema,
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
return await context.runTask(params.task);
|
||||
},
|
||||
});
|
||||
@@ -1,32 +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';
|
||||
|
||||
export const snapshot = defineTool({
|
||||
schema: {
|
||||
name: 'browser_snapshot',
|
||||
title: 'Take a snapshot of the browser',
|
||||
description: 'Take a snapshot of the browser to read what is on the page.',
|
||||
inputSchema: z.object({}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
return await context.runTask('Capture browser snapshot', true);
|
||||
},
|
||||
});
|
||||
@@ -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 +0,0 @@
|
||||
- Generic MCP utils, no dependencies on Playwright here.
|
||||
@@ -1,92 +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 { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import type { Transport, TransportSendOptions } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import type { JSONRPCMessage, MessageExtraInfo } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
export class InProcessTransport implements Transport {
|
||||
private _server: Server;
|
||||
private _serverTransport: InProcessServerTransport;
|
||||
private _connected: boolean = false;
|
||||
|
||||
constructor(server: Server) {
|
||||
this._server = server;
|
||||
this._serverTransport = new InProcessServerTransport(this);
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this._connected)
|
||||
throw new Error('InprocessTransport already started!');
|
||||
|
||||
await this._server.connect(this._serverTransport);
|
||||
this._connected = true;
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void> {
|
||||
if (!this._connected)
|
||||
throw new Error('Transport not connected');
|
||||
|
||||
|
||||
this._serverTransport._receiveFromClient(message);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this._connected) {
|
||||
this._connected = false;
|
||||
this.onclose?.();
|
||||
this._serverTransport.onclose?.();
|
||||
}
|
||||
}
|
||||
|
||||
onclose?: (() => void) | undefined;
|
||||
onerror?: ((error: Error) => void) | undefined;
|
||||
onmessage?: ((message: JSONRPCMessage, extra?: MessageExtraInfo) => void) | undefined;
|
||||
sessionId?: string | undefined;
|
||||
setProtocolVersion?: ((version: string) => void) | undefined;
|
||||
|
||||
_receiveFromServer(message: JSONRPCMessage, extra?: MessageExtraInfo): void {
|
||||
this.onmessage?.(message, extra);
|
||||
}
|
||||
}
|
||||
|
||||
class InProcessServerTransport implements Transport {
|
||||
private _clientTransport: InProcessTransport;
|
||||
|
||||
constructor(clientTransport: InProcessTransport) {
|
||||
this._clientTransport = clientTransport;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void> {
|
||||
this._clientTransport._receiveFromServer(message);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.onclose?.();
|
||||
}
|
||||
|
||||
onclose?: (() => void) | undefined;
|
||||
onerror?: ((error: Error) => void) | undefined;
|
||||
onmessage?: ((message: JSONRPCMessage, extra?: MessageExtraInfo) => void) | undefined;
|
||||
sessionId?: string | undefined;
|
||||
setProtocolVersion?: ((version: string) => void) | undefined;
|
||||
_receiveFromClient(message: JSONRPCMessage): void {
|
||||
this.onmessage?.(message);
|
||||
}
|
||||
}
|
||||
@@ -1,140 +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 { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { ManualPromise } from '../manualPromise.js';
|
||||
import { logUnhandledError } from '../log.js';
|
||||
|
||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
export type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
|
||||
export type ClientCapabilities = {
|
||||
roots?: {
|
||||
listRoots?: boolean
|
||||
};
|
||||
};
|
||||
|
||||
export type ToolResponse = {
|
||||
content: (TextContent | ImageContent)[];
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
export type ToolSchema<Input extends z.Schema> = {
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
inputSchema: Input;
|
||||
type: 'readOnly' | 'destructive';
|
||||
};
|
||||
|
||||
export type ToolHandler = (toolName: string, params: any) => Promise<ToolResponse>;
|
||||
|
||||
export interface ServerBackend {
|
||||
name: string;
|
||||
version: string;
|
||||
initialize?(server: Server): Promise<void>;
|
||||
tools(): ToolSchema<any>[];
|
||||
callTool(schema: ToolSchema<any>, parsedArguments: any): Promise<ToolResponse>;
|
||||
serverClosed?(): void;
|
||||
}
|
||||
|
||||
export type ServerBackendFactory = () => ServerBackend;
|
||||
|
||||
export async function connect(serverBackendFactory: ServerBackendFactory, transport: Transport, runHeartbeat: boolean) {
|
||||
const backend = serverBackendFactory();
|
||||
const server = createServer(backend, runHeartbeat);
|
||||
await server.connect(transport);
|
||||
}
|
||||
|
||||
export function createServer(backend: ServerBackend, runHeartbeat: boolean): Server {
|
||||
const initializedPromise = new ManualPromise<void>();
|
||||
const server = new Server({ name: backend.name, version: backend.version }, {
|
||||
capabilities: {
|
||||
tools: {},
|
||||
}
|
||||
});
|
||||
|
||||
const tools = backend.tools();
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return { tools: tools.map(tool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: zodToJsonSchema(tool.inputSchema),
|
||||
annotations: {
|
||||
title: tool.title,
|
||||
readOnlyHint: tool.type === 'readOnly',
|
||||
destructiveHint: tool.type === 'destructive',
|
||||
openWorldHint: true,
|
||||
},
|
||||
})) };
|
||||
});
|
||||
|
||||
let heartbeatRunning = false;
|
||||
server.setRequestHandler(CallToolRequestSchema, async request => {
|
||||
await initializedPromise;
|
||||
|
||||
if (runHeartbeat && !heartbeatRunning) {
|
||||
heartbeatRunning = true;
|
||||
startHeartbeat(server);
|
||||
}
|
||||
|
||||
const errorResult = (...messages: string[]) => ({
|
||||
content: [{ type: 'text', text: '### Result\n' + messages.join('\n') }],
|
||||
isError: true,
|
||||
});
|
||||
const tool = tools.find(tool => tool.name === request.params.name) as ToolSchema<any>;
|
||||
if (!tool)
|
||||
return errorResult(`Error: Tool "${request.params.name}" not found`);
|
||||
|
||||
try {
|
||||
return await backend.callTool(tool, tool.inputSchema.parse(request.params.arguments || {}));
|
||||
} catch (error) {
|
||||
return errorResult(String(error));
|
||||
}
|
||||
});
|
||||
addServerListener(server, 'initialized', () => {
|
||||
backend.initialize?.(server).then(() => initializedPromise.resolve()).catch(logUnhandledError);
|
||||
});
|
||||
addServerListener(server, 'close', () => backend.serverClosed?.());
|
||||
return server;
|
||||
}
|
||||
|
||||
const startHeartbeat = (server: Server) => {
|
||||
const beat = () => {
|
||||
Promise.race([
|
||||
server.ping(),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('ping timeout')), 5000)),
|
||||
]).then(() => {
|
||||
setTimeout(beat, 3000);
|
||||
}).catch(() => {
|
||||
void server.close();
|
||||
});
|
||||
};
|
||||
|
||||
beat();
|
||||
};
|
||||
|
||||
function addServerListener(server: Server, event: 'close' | 'initialized', listener: () => void) {
|
||||
const oldListener = server[`on${event}`];
|
||||
server[`on${event}`] = () => {
|
||||
oldListener?.();
|
||||
listener();
|
||||
};
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import crypto from '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 { httpAddressToString, startHttpServer } from '../httpServer.js';
|
||||
import * as mcpServer from './server.js';
|
||||
|
||||
import type { ServerBackendFactory } from './server.js';
|
||||
|
||||
export async function start(serverBackendFactory: ServerBackendFactory, options: { host?: string; port?: number }) {
|
||||
if (options.port !== undefined) {
|
||||
const httpServer = await startHttpServer(options);
|
||||
startHttpTransport(httpServer, serverBackendFactory);
|
||||
} else {
|
||||
await startStdioTransport(serverBackendFactory);
|
||||
}
|
||||
}
|
||||
|
||||
async function startStdioTransport(serverBackendFactory: ServerBackendFactory) {
|
||||
await mcpServer.connect(serverBackendFactory, new StdioServerTransport(), false);
|
||||
}
|
||||
|
||||
const testDebug = debug('pw:mcp:test');
|
||||
|
||||
async function handleSSE(serverBackendFactory: ServerBackendFactory, 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}`);
|
||||
await mcpServer.connect(serverBackendFactory, transport, false);
|
||||
res.on('close', () => {
|
||||
testDebug(`delete SSE session: ${transport.sessionId}`);
|
||||
sessions.delete(transport.sessionId);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 405;
|
||||
res.end('Method not allowed');
|
||||
}
|
||||
|
||||
async function handleStreamable(serverBackendFactory: ServerBackendFactory, 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: async sessionId => {
|
||||
testDebug(`create http session: ${transport.sessionId}`);
|
||||
await mcpServer.connect(serverBackendFactory, transport, true);
|
||||
sessions.set(sessionId, transport);
|
||||
}
|
||||
});
|
||||
|
||||
transport.onclose = () => {
|
||||
if (!transport.sessionId)
|
||||
return;
|
||||
sessions.delete(transport.sessionId);
|
||||
testDebug(`delete http session: ${transport.sessionId}`);
|
||||
};
|
||||
|
||||
await transport.handleRequest(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 400;
|
||||
res.end('Invalid request');
|
||||
}
|
||||
|
||||
function startHttpTransport(httpServer: http.Server, serverBackendFactory: ServerBackendFactory) {
|
||||
const sseSessions = new Map();
|
||||
const streamableSessions = new Map();
|
||||
httpServer.on('request', async (req, res) => {
|
||||
const url = new URL(`http://localhost${req.url}`);
|
||||
if (url.pathname.startsWith('/sse'))
|
||||
await handleSSE(serverBackendFactory, req, res, url, sseSessions);
|
||||
else
|
||||
await handleStreamable(serverBackendFactory, req, res, streamableSessions);
|
||||
});
|
||||
const url = httpAddressToString(httpServer.address());
|
||||
const message = [
|
||||
`Listening on ${url}`,
|
||||
'Put this in your client config:',
|
||||
JSON.stringify({
|
||||
'mcpServers': {
|
||||
'playwright': {
|
||||
'url': `${url}/mcp`
|
||||
}
|
||||
}
|
||||
}, undefined, 2),
|
||||
'For legacy SSE transport support, you can use the /sse endpoint instead.',
|
||||
].join('\n');
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(message);
|
||||
}
|
||||
114
src/program.ts
@@ -1,114 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { program, Option } from 'commander';
|
||||
// @ts-ignore
|
||||
import { startTraceViewerServer } from 'playwright-core/lib/server';
|
||||
|
||||
import * as mcpTransport from './mcp/transport.js';
|
||||
import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js';
|
||||
import { packageJSON } from './package.js';
|
||||
import { createExtensionContextFactory, runWithExtension } from './extension/main.js';
|
||||
import { BrowserServerBackend, FactoryList } from './browserServerBackend.js';
|
||||
import { Context } from './context.js';
|
||||
import { contextFactory } from './browserContextFactory.js';
|
||||
import { runLoopTools } from './loopTools/main.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('--permissions <permissions>', 'comma-separated list of permissions to grant, for example "clipboard-read,clipboard-write"', commaSeparatedList)
|
||||
.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-session', 'Whether to save the Playwright MCP session into the output directory.')
|
||||
.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('--extension', 'Connect to a running browser instance (Edge/Chrome only). Requires the "Playwright MCP Bridge" browser extension to be installed.').hideHelp())
|
||||
.addOption(new Option('--connect-tool', 'Allow to switch between different browser connection methods.').hideHelp())
|
||||
.addOption(new Option('--loop-tools', 'Run loop tools').hideHelp())
|
||||
.addOption(new Option('--vision', 'Legacy option, use --caps=vision instead').hideHelp())
|
||||
.action(async options => {
|
||||
setupExitWatchdog();
|
||||
|
||||
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);
|
||||
|
||||
if (options.extension) {
|
||||
await runWithExtension(config);
|
||||
return;
|
||||
}
|
||||
if (options.loopTools) {
|
||||
await runLoopTools(config);
|
||||
return;
|
||||
}
|
||||
|
||||
const browserContextFactory = contextFactory(config);
|
||||
const factories: FactoryList = [browserContextFactory];
|
||||
if (options.connectTool)
|
||||
factories.push(createExtensionContextFactory(config));
|
||||
const serverBackendFactory = () => new BrowserServerBackend(config, factories);
|
||||
await mcpTransport.start(serverBackendFactory, config.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);
|
||||
}
|
||||
});
|
||||
|
||||
function setupExitWatchdog() {
|
||||
let isExiting = false;
|
||||
const handleExit = async () => {
|
||||
if (isExiting)
|
||||
return;
|
||||
isExiting = true;
|
||||
setTimeout(() => process.exit(0), 15000);
|
||||
await Context.disposeAll();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.stdin.on('close', handleExit);
|
||||
process.on('SIGINT', handleExit);
|
||||
process.on('SIGTERM', handleExit);
|
||||
}
|
||||
|
||||
void program.parseAsync(process.argv);
|
||||
201
src/response.ts
@@ -1,201 +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 { renderModalStates } from './tab.js';
|
||||
|
||||
import type { Tab, TabSnapshot } from './tab.js';
|
||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { Context } from './context.js';
|
||||
|
||||
export class Response {
|
||||
private _result: string[] = [];
|
||||
private _code: string[] = [];
|
||||
private _images: { contentType: string, data: Buffer }[] = [];
|
||||
private _context: Context;
|
||||
private _includeSnapshot = false;
|
||||
private _includeTabs = false;
|
||||
private _tabSnapshot: TabSnapshot | undefined;
|
||||
|
||||
readonly toolName: string;
|
||||
readonly toolArgs: Record<string, any>;
|
||||
private _isError: boolean | undefined;
|
||||
|
||||
constructor(context: Context, toolName: string, toolArgs: Record<string, any>) {
|
||||
this._context = context;
|
||||
this.toolName = toolName;
|
||||
this.toolArgs = toolArgs;
|
||||
}
|
||||
|
||||
addResult(result: string) {
|
||||
this._result.push(result);
|
||||
}
|
||||
|
||||
addError(error: string) {
|
||||
this._result.push(error);
|
||||
this._isError = true;
|
||||
}
|
||||
|
||||
isError() {
|
||||
return this._isError;
|
||||
}
|
||||
|
||||
result() {
|
||||
return this._result.join('\n');
|
||||
}
|
||||
|
||||
addCode(code: string) {
|
||||
this._code.push(code);
|
||||
}
|
||||
|
||||
code() {
|
||||
return this._code.join('\n');
|
||||
}
|
||||
|
||||
addImage(image: { contentType: string, data: Buffer }) {
|
||||
this._images.push(image);
|
||||
}
|
||||
|
||||
images() {
|
||||
return this._images;
|
||||
}
|
||||
|
||||
setIncludeSnapshot() {
|
||||
this._includeSnapshot = true;
|
||||
}
|
||||
|
||||
setIncludeTabs() {
|
||||
this._includeTabs = true;
|
||||
}
|
||||
|
||||
async finish() {
|
||||
// All the async snapshotting post-action is happening here.
|
||||
// Everything below should race against modal states.
|
||||
if (this._includeSnapshot && this._context.currentTab())
|
||||
this._tabSnapshot = await this._context.currentTabOrDie().captureSnapshot();
|
||||
for (const tab of this._context.tabs())
|
||||
await tab.updateTitle();
|
||||
}
|
||||
|
||||
tabSnapshot(): TabSnapshot | undefined {
|
||||
return this._tabSnapshot;
|
||||
}
|
||||
|
||||
serialize(): { content: (TextContent | ImageContent)[], isError?: boolean } {
|
||||
const response: string[] = [];
|
||||
|
||||
// Start with command result.
|
||||
if (this._result.length) {
|
||||
response.push('### Result');
|
||||
response.push(this._result.join('\n'));
|
||||
response.push('');
|
||||
}
|
||||
|
||||
// Add code if it exists.
|
||||
if (this._code.length) {
|
||||
response.push(`### Ran Playwright code
|
||||
\`\`\`js
|
||||
${this._code.join('\n')}
|
||||
\`\`\``);
|
||||
response.push('');
|
||||
}
|
||||
|
||||
// List browser tabs.
|
||||
if (this._includeSnapshot || this._includeTabs)
|
||||
response.push(...renderTabsMarkdown(this._context.tabs(), this._includeTabs));
|
||||
|
||||
// Add snapshot if provided.
|
||||
if (this._tabSnapshot?.modalStates.length) {
|
||||
response.push(...renderModalStates(this._context, this._tabSnapshot.modalStates));
|
||||
response.push('');
|
||||
} else if (this._tabSnapshot) {
|
||||
response.push(renderTabSnapshot(this._tabSnapshot));
|
||||
response.push('');
|
||||
}
|
||||
|
||||
// Main response part
|
||||
const content: (TextContent | ImageContent)[] = [
|
||||
{ type: 'text', text: response.join('\n') },
|
||||
];
|
||||
|
||||
// Image attachments.
|
||||
if (this._context.config.imageResponses !== 'omit') {
|
||||
for (const image of this._images)
|
||||
content.push({ type: 'image', data: image.data.toString('base64'), mimeType: image.contentType });
|
||||
}
|
||||
|
||||
return { content, isError: this._isError };
|
||||
}
|
||||
}
|
||||
|
||||
function renderTabSnapshot(tabSnapshot: TabSnapshot): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (tabSnapshot.consoleMessages.length) {
|
||||
lines.push(`### New console messages`);
|
||||
for (const message of tabSnapshot.consoleMessages)
|
||||
lines.push(`- ${trim(message.toString(), 100)}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (tabSnapshot.downloads.length) {
|
||||
lines.push(`### Downloads`);
|
||||
for (const entry of tabSnapshot.downloads) {
|
||||
if (entry.finished)
|
||||
lines.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
|
||||
else
|
||||
lines.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push(`### Page state`);
|
||||
lines.push(`- Page URL: ${tabSnapshot.url}`);
|
||||
lines.push(`- Page Title: ${tabSnapshot.title}`);
|
||||
lines.push(`- Page Snapshot:`);
|
||||
lines.push('```yaml');
|
||||
lines.push(tabSnapshot.ariaSnapshot);
|
||||
lines.push('```');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderTabsMarkdown(tabs: Tab[], force: boolean = false): string[] {
|
||||
if (tabs.length === 1 && !force)
|
||||
return [];
|
||||
|
||||
if (!tabs.length) {
|
||||
return [
|
||||
'### Open tabs',
|
||||
'No open tabs. Use the "browser_navigate" tool to navigate to a page first.',
|
||||
'',
|
||||
];
|
||||
}
|
||||
|
||||
const lines: string[] = ['### Open tabs'];
|
||||
for (let i = 0; i < tabs.length; i++) {
|
||||
const tab = tabs[i];
|
||||
const current = tab.isCurrentTab() ? ' (current)' : '';
|
||||
lines.push(`- ${i}:${current} [${tab.lastTitle()}] (${tab.page.url()})`);
|
||||
}
|
||||
lines.push('');
|
||||
return lines;
|
||||
}
|
||||
|
||||
function trim(text: string, maxLength: number) {
|
||||
if (text.length <= maxLength)
|
||||
return text;
|
||||
return text.slice(0, maxLength) + '...';
|
||||
}
|
||||
@@ -1,176 +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 { Response } from './response.js';
|
||||
import { logUnhandledError } from './log.js';
|
||||
import { outputFile } from './config.js';
|
||||
|
||||
import type { FullConfig } from './config.js';
|
||||
import type * as actions from './actions.js';
|
||||
import type { Tab, TabSnapshot } from './tab.js';
|
||||
|
||||
type LogEntry = {
|
||||
timestamp: number;
|
||||
toolCall?: {
|
||||
toolName: string;
|
||||
toolArgs: Record<string, any>;
|
||||
result: string;
|
||||
isError?: boolean;
|
||||
};
|
||||
userAction?: actions.Action;
|
||||
code: string;
|
||||
tabSnapshot?: TabSnapshot;
|
||||
};
|
||||
|
||||
export class SessionLog {
|
||||
private _folder: string;
|
||||
private _file: string;
|
||||
private _ordinal = 0;
|
||||
private _pendingEntries: LogEntry[] = [];
|
||||
private _sessionFileQueue = Promise.resolve();
|
||||
private _flushEntriesTimeout: NodeJS.Timeout | undefined;
|
||||
|
||||
constructor(sessionFolder: string) {
|
||||
this._folder = sessionFolder;
|
||||
this._file = path.join(this._folder, 'session.md');
|
||||
}
|
||||
|
||||
static async create(config: FullConfig, rootPath: string | undefined): Promise<SessionLog> {
|
||||
const sessionFolder = await outputFile(config, rootPath, `session-${Date.now()}`);
|
||||
await fs.promises.mkdir(sessionFolder, { recursive: true });
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Session: ${sessionFolder}`);
|
||||
return new SessionLog(sessionFolder);
|
||||
}
|
||||
|
||||
logResponse(response: Response) {
|
||||
const entry: LogEntry = {
|
||||
timestamp: performance.now(),
|
||||
toolCall: {
|
||||
toolName: response.toolName,
|
||||
toolArgs: response.toolArgs,
|
||||
result: response.result(),
|
||||
isError: response.isError(),
|
||||
},
|
||||
code: response.code(),
|
||||
tabSnapshot: response.tabSnapshot(),
|
||||
};
|
||||
this._appendEntry(entry);
|
||||
}
|
||||
|
||||
logUserAction(action: actions.Action, tab: Tab, code: string, isUpdate: boolean) {
|
||||
code = code.trim();
|
||||
if (isUpdate) {
|
||||
const lastEntry = this._pendingEntries[this._pendingEntries.length - 1];
|
||||
if (lastEntry.userAction?.name === action.name) {
|
||||
lastEntry.userAction = action;
|
||||
lastEntry.code = code;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (action.name === 'navigate') {
|
||||
// Already logged at this location.
|
||||
const lastEntry = this._pendingEntries[this._pendingEntries.length - 1];
|
||||
if (lastEntry?.tabSnapshot?.url === action.url)
|
||||
return;
|
||||
}
|
||||
const entry: LogEntry = {
|
||||
timestamp: performance.now(),
|
||||
userAction: action,
|
||||
code,
|
||||
tabSnapshot: {
|
||||
url: tab.page.url(),
|
||||
title: '',
|
||||
ariaSnapshot: action.ariaSnapshot || '',
|
||||
modalStates: [],
|
||||
consoleMessages: [],
|
||||
downloads: [],
|
||||
},
|
||||
};
|
||||
this._appendEntry(entry);
|
||||
}
|
||||
|
||||
private _appendEntry(entry: LogEntry) {
|
||||
this._pendingEntries.push(entry);
|
||||
if (this._flushEntriesTimeout)
|
||||
clearTimeout(this._flushEntriesTimeout);
|
||||
this._flushEntriesTimeout = setTimeout(() => this._flushEntries(), 1000);
|
||||
}
|
||||
|
||||
private async _flushEntries() {
|
||||
clearTimeout(this._flushEntriesTimeout);
|
||||
const entries = this._pendingEntries;
|
||||
this._pendingEntries = [];
|
||||
const lines: string[] = [''];
|
||||
|
||||
for (const entry of entries) {
|
||||
const ordinal = (++this._ordinal).toString().padStart(3, '0');
|
||||
if (entry.toolCall) {
|
||||
lines.push(
|
||||
`### Tool call: ${entry.toolCall.toolName}`,
|
||||
`- Args`,
|
||||
'```json',
|
||||
JSON.stringify(entry.toolCall.toolArgs, null, 2),
|
||||
'```',
|
||||
);
|
||||
if (entry.toolCall.result) {
|
||||
lines.push(
|
||||
entry.toolCall.isError ? `- Error` : `- Result`,
|
||||
'```',
|
||||
entry.toolCall.result,
|
||||
'```',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.userAction) {
|
||||
const actionData = { ...entry.userAction } as any;
|
||||
delete actionData.ariaSnapshot;
|
||||
delete actionData.selector;
|
||||
delete actionData.signals;
|
||||
|
||||
lines.push(
|
||||
`### User action: ${entry.userAction.name}`,
|
||||
`- Args`,
|
||||
'```json',
|
||||
JSON.stringify(actionData, null, 2),
|
||||
'```',
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.code) {
|
||||
lines.push(
|
||||
`- Code`,
|
||||
'```js',
|
||||
entry.code,
|
||||
'```');
|
||||
}
|
||||
|
||||
if (entry.tabSnapshot) {
|
||||
const fileName = `${ordinal}.snapshot.yml`;
|
||||
fs.promises.writeFile(path.join(this._folder, fileName), entry.tabSnapshot.ariaSnapshot).catch(logUnhandledError);
|
||||
lines.push(`- Snapshot: ${fileName}`);
|
||||
}
|
||||
|
||||
lines.push('', '');
|
||||
}
|
||||
|
||||
this._sessionFileQueue = this._sessionFileQueue.then(() => fs.promises.appendFile(this._file, lines.join('\n')));
|
||||
}
|
||||
}
|
||||
313
src/tab.ts
@@ -1,313 +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 { EventEmitter } from 'events';
|
||||
import * as playwright from 'playwright';
|
||||
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
|
||||
import { logUnhandledError } from './log.js';
|
||||
import { ManualPromise } from './manualPromise.js';
|
||||
import { ModalState } from './tools/tool.js';
|
||||
|
||||
import type { Context } from './context.js';
|
||||
|
||||
type PageEx = playwright.Page & {
|
||||
_snapshotForAI: () => Promise<string>;
|
||||
};
|
||||
|
||||
export const TabEvents = {
|
||||
modalState: 'modalState'
|
||||
};
|
||||
|
||||
export type TabEventsInterface = {
|
||||
[TabEvents.modalState]: [modalState: ModalState];
|
||||
};
|
||||
|
||||
export type TabSnapshot = {
|
||||
url: string;
|
||||
title: string;
|
||||
ariaSnapshot: string;
|
||||
modalStates: ModalState[];
|
||||
consoleMessages: ConsoleMessage[];
|
||||
downloads: { download: playwright.Download, finished: boolean, outputFile: string }[];
|
||||
};
|
||||
|
||||
export class Tab extends EventEmitter<TabEventsInterface> {
|
||||
readonly context: Context;
|
||||
readonly page: playwright.Page;
|
||||
private _lastTitle = 'about:blank';
|
||||
private _consoleMessages: ConsoleMessage[] = [];
|
||||
private _recentConsoleMessages: ConsoleMessage[] = [];
|
||||
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
|
||||
private _onPageClose: (tab: Tab) => void;
|
||||
private _modalStates: ModalState[] = [];
|
||||
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
|
||||
|
||||
constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) {
|
||||
super();
|
||||
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.setModalState({
|
||||
type: 'fileChooser',
|
||||
description: 'File chooser',
|
||||
fileChooser: chooser,
|
||||
});
|
||||
});
|
||||
page.on('dialog', dialog => this._dialogShown(dialog));
|
||||
page.on('download', download => {
|
||||
void this._downloadStarted(download);
|
||||
});
|
||||
page.setDefaultNavigationTimeout(60000);
|
||||
page.setDefaultTimeout(5000);
|
||||
(page as any)[tabSymbol] = this;
|
||||
}
|
||||
|
||||
static forPage(page: playwright.Page): Tab | undefined {
|
||||
return (page as any)[tabSymbol];
|
||||
}
|
||||
|
||||
modalStates(): ModalState[] {
|
||||
return this._modalStates;
|
||||
}
|
||||
|
||||
setModalState(modalState: ModalState) {
|
||||
this._modalStates.push(modalState);
|
||||
this.emit(TabEvents.modalState, modalState);
|
||||
}
|
||||
|
||||
clearModalState(modalState: ModalState) {
|
||||
this._modalStates = this._modalStates.filter(state => state !== modalState);
|
||||
}
|
||||
|
||||
modalStatesMarkdown(): string[] {
|
||||
return renderModalStates(this.context, this.modalStates());
|
||||
}
|
||||
|
||||
private _dialogShown(dialog: playwright.Dialog) {
|
||||
this.setModalState({
|
||||
type: 'dialog',
|
||||
description: `"${dialog.type()}" dialog with message "${dialog.message()}"`,
|
||||
dialog,
|
||||
});
|
||||
}
|
||||
|
||||
private async _downloadStarted(download: playwright.Download) {
|
||||
const entry = {
|
||||
download,
|
||||
finished: false,
|
||||
outputFile: await this.context.outputFile(download.suggestedFilename())
|
||||
};
|
||||
this._downloads.push(entry);
|
||||
await download.saveAs(entry.outputFile);
|
||||
entry.finished = true;
|
||||
}
|
||||
|
||||
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 updateTitle() {
|
||||
await this._raceAgainstModalStates(async () => {
|
||||
this._lastTitle = await callOnPageNoTrace(this.page, page => page.title());
|
||||
});
|
||||
}
|
||||
|
||||
lastTitle(): string {
|
||||
return this._lastTitle;
|
||||
}
|
||||
|
||||
isCurrentTab(): boolean {
|
||||
return this === this.context.currentTab();
|
||||
}
|
||||
|
||||
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, 3000)),
|
||||
]);
|
||||
if (!download)
|
||||
throw e;
|
||||
// Make sure other "download" listeners are notified first.
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
return;
|
||||
}
|
||||
|
||||
// Cap load event to 5 seconds, the page is operational at this point.
|
||||
await this.waitForLoadState('load', { timeout: 5000 });
|
||||
}
|
||||
|
||||
consoleMessages(): ConsoleMessage[] {
|
||||
return this._consoleMessages;
|
||||
}
|
||||
|
||||
requests(): Map<playwright.Request, playwright.Response | null> {
|
||||
return this._requests;
|
||||
}
|
||||
|
||||
async captureSnapshot(): Promise<TabSnapshot> {
|
||||
let tabSnapshot: TabSnapshot | undefined;
|
||||
const modalStates = await this._raceAgainstModalStates(async () => {
|
||||
const snapshot = await (this.page as PageEx)._snapshotForAI();
|
||||
tabSnapshot = {
|
||||
url: this.page.url(),
|
||||
title: await this.page.title(),
|
||||
ariaSnapshot: snapshot,
|
||||
modalStates: [],
|
||||
consoleMessages: [],
|
||||
downloads: this._downloads,
|
||||
};
|
||||
});
|
||||
if (tabSnapshot) {
|
||||
// Assign console message late so that we did not lose any to modal state.
|
||||
tabSnapshot.consoleMessages = this._recentConsoleMessages;
|
||||
this._recentConsoleMessages = [];
|
||||
}
|
||||
return tabSnapshot ?? {
|
||||
url: this.page.url(),
|
||||
title: '',
|
||||
ariaSnapshot: '',
|
||||
modalStates,
|
||||
consoleMessages: [],
|
||||
downloads: [],
|
||||
};
|
||||
}
|
||||
|
||||
private _javaScriptBlocked(): boolean {
|
||||
return this._modalStates.some(state => state.type === 'dialog');
|
||||
}
|
||||
|
||||
private async _raceAgainstModalStates(action: () => Promise<void>): Promise<ModalState[]> {
|
||||
if (this.modalStates().length)
|
||||
return this.modalStates();
|
||||
|
||||
const promise = new ManualPromise<ModalState[]>();
|
||||
const listener = (modalState: ModalState) => promise.resolve([modalState]);
|
||||
this.once(TabEvents.modalState, listener);
|
||||
|
||||
return await Promise.race([
|
||||
action().then(() => {
|
||||
this.off(TabEvents.modalState, listener);
|
||||
return [];
|
||||
}),
|
||||
promise,
|
||||
]);
|
||||
}
|
||||
|
||||
async waitForCompletion(callback: () => Promise<void>) {
|
||||
await this._raceAgainstModalStates(() => waitForCompletion(this, callback));
|
||||
}
|
||||
|
||||
async refLocator(params: { element: string, ref: string }): Promise<playwright.Locator> {
|
||||
return (await this.refLocators([params]))[0];
|
||||
}
|
||||
|
||||
async refLocators(params: { element: string, ref: string }[]): Promise<playwright.Locator[]> {
|
||||
const snapshot = await (this.page as PageEx)._snapshotForAI();
|
||||
return params.map(param => {
|
||||
if (!snapshot.includes(`[ref=${param.ref}]`))
|
||||
throw new Error(`Ref ${param.ref} not found in the current page snapshot. Try capturing new snapshot.`);
|
||||
return this.page.locator(`aria-ref=${param.ref}`).describe(param.element);
|
||||
});
|
||||
}
|
||||
|
||||
async waitForTimeout(time: number) {
|
||||
if (this._javaScriptBlocked()) {
|
||||
await new Promise(f => setTimeout(f, time));
|
||||
return;
|
||||
}
|
||||
|
||||
await callOnPageNoTrace(this.page, page => {
|
||||
return page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
export function renderModalStates(context: Context, modalStates: ModalState[]): string[] {
|
||||
const result: string[] = ['### Modal state'];
|
||||
if (modalStates.length === 0)
|
||||
result.push('- There is no modal state present');
|
||||
for (const state of modalStates) {
|
||||
const tool = context.tools.filter(tool => 'clearsModalState' in tool).find(tool => tool.clearsModalState === state.type);
|
||||
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const tabSymbol = Symbol('tabSymbol');
|
||||
56
src/tools.ts
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 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';
|
||||
import type { FullConfig } from './config.js';
|
||||
|
||||
export const allTools: Tool<any>[] = [
|
||||
...common,
|
||||
...console,
|
||||
...dialogs,
|
||||
...evaluate,
|
||||
...files,
|
||||
...install,
|
||||
...keyboard,
|
||||
...navigate,
|
||||
...network,
|
||||
...mouse,
|
||||
...pdf,
|
||||
...screenshot,
|
||||
...snapshot,
|
||||
...tabs,
|
||||
...wait,
|
||||
];
|
||||
|
||||
export function filteredTools(config: FullConfig) {
|
||||
return allTools.filter(tool => tool.capability.startsWith('core') || config.capabilities?.includes(tool.capability));
|
||||
}
|
||||