Compare commits
25 Commits
v0.0.57
...
vscode-cli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76ba7f7bb6 | ||
|
|
dc149c19c0 | ||
|
|
74e3ab5267 | ||
|
|
d12b5aab18 | ||
|
|
922002e435 | ||
|
|
21e03968c5 | ||
|
|
ee59735f42 | ||
|
|
da5b0c6fdd | ||
|
|
35c464ef5b | ||
|
|
fcd953c097 | ||
|
|
14b931d25d | ||
|
|
bcbc2fecb8 | ||
|
|
5a0cfb9e65 | ||
|
|
1ff80f8761 | ||
|
|
98fef06b3b | ||
|
|
affe1d7ed9 | ||
|
|
cc61b67c14 | ||
|
|
7a814d5cd4 | ||
|
|
39c384850f | ||
|
|
f8a61de332 | ||
|
|
9d17572403 | ||
|
|
0741b8bee8 | ||
|
|
0d0783be07 | ||
|
|
001fa6f2fb | ||
|
|
e884b3aacb |
38
.github/workflows/ci.yml
vendored
@@ -16,12 +16,15 @@ jobs:
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- run: npm run build
|
||||
- name: Run ESLint
|
||||
run: npm run lint
|
||||
- name: Ensure no changes
|
||||
run: git diff --exit-code
|
||||
|
||||
test_mcp:
|
||||
test:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -38,11 +41,16 @@ jobs:
|
||||
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 run test
|
||||
working-directory: ./packages/playwright-mcp
|
||||
run: npm test
|
||||
|
||||
test_mcp_docker:
|
||||
test_docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -55,6 +63,8 @@ jobs:
|
||||
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
|
||||
@@ -70,12 +80,16 @@ 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:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
runs-on: macos-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./extension
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 20
|
||||
@@ -85,17 +99,20 @@ jobs:
|
||||
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: Install and build MCP server
|
||||
run: |
|
||||
cd ..
|
||||
npm ci
|
||||
npm run build
|
||||
npx playwright install chromium
|
||||
- name: Run tests
|
||||
run: |
|
||||
if [[ "$(uname)" == "Linux" ]]; then
|
||||
@@ -104,4 +121,3 @@ jobs:
|
||||
npm run test
|
||||
fi
|
||||
shell: bash
|
||||
working-directory: ./packages/extension
|
||||
|
||||
44
.github/workflows/copilot-setup-steps.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
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
|
||||
96
.github/workflows/publish.yml
vendored
@@ -1,87 +1,36 @@
|
||||
name: Publish
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 8 * * *'
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
publish-mcp-canary-npm:
|
||||
if: github.event.schedule || github.event_name == 'workflow_dispatch'
|
||||
publish-npm:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # Required for OIDC npm publishing
|
||||
id-token: write # Needed for npm provenance
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 18
|
||||
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
|
||||
working-directory: ./packages/playwright-mcp
|
||||
- run: npm publish --provenance
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish to npm with next tag
|
||||
run: npm publish --tag next
|
||||
working-directory: ./packages/playwright-mcp
|
||||
|
||||
publish-mcp-release-npm:
|
||||
if: github.event_name == 'release'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # Required for OIDC npm publishing
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: https://registry.npmjs.org/
|
||||
# Ensure npm 11.5.1 or later is installed (for OIDC npm publishing)
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
- run: npm ci
|
||||
- run: npx playwright install --with-deps
|
||||
- run: npm run lint
|
||||
- run: npm run ctest
|
||||
working-directory: ./packages/playwright-mcp
|
||||
- run: npm publish
|
||||
working-directory: ./packages/playwright-mcp
|
||||
|
||||
publish-mcp-release-docker:
|
||||
if: github.event_name == 'release'
|
||||
publish-docker:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # Needed for OIDC login to Azure
|
||||
environment: allow-publishing-docker-to-acr
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up QEMU # Needed for multi-platform builds (e.g., arm64 on amd64 runner)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx # Needed for multi-platform builds
|
||||
@@ -98,7 +47,8 @@ jobs:
|
||||
id: build-push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: ./Dockerfile
|
||||
context: .
|
||||
file: ./Dockerfile # Adjust path if your Dockerfile is elsewhere
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
@@ -119,34 +69,30 @@ jobs:
|
||||
attach_eol_manifest $tag
|
||||
done
|
||||
|
||||
package-release-extension:
|
||||
if: github.event_name == 'release'
|
||||
package-extension:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # Needed to upload release assets
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
- name: Install extension dependencies
|
||||
working-directory: ./extension
|
||||
run: npm ci
|
||||
- name: Build extension
|
||||
working-directory: ./packages/extension
|
||||
working-directory: ./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
|
||||
working-directory: ./extension
|
||||
run: |
|
||||
cd dist
|
||||
zip -r ../playwright-mcp-extension-${{ steps.get-version.outputs.version }}.zip .
|
||||
zip -r ../playwright-mcp-extension-${{ github.event.release.tag_name }}.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
|
||||
gh release upload ${{github.event.release.tag_name}} ./extension/playwright-mcp-extension-${{ github.event.release.tag_name }}.zip
|
||||
|
||||
2
.gitignore
vendored
@@ -1,3 +1,5 @@
|
||||
lib/
|
||||
dist/
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
7
.npmignore
Normal file
@@ -0,0 +1,7 @@
|
||||
**/*
|
||||
README.md
|
||||
LICENSE
|
||||
!lib/**/*.js
|
||||
!cli.js
|
||||
!index.*
|
||||
!config.d.ts
|
||||
137
CONTRIBUTING.md
@@ -1,137 +0,0 @@
|
||||
# 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,7 +16,6 @@ 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
|
||||
@@ -29,11 +28,14 @@ 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 packages/playwright-mcp/*.json packages/playwright-mcp/*.js packages/playwright-mcp/*.ts .
|
||||
COPY *.json *.js *.ts .
|
||||
COPY src src/
|
||||
|
||||
# Build the app
|
||||
RUN npm run build
|
||||
|
||||
# ------------------------------
|
||||
# Browser
|
||||
@@ -53,7 +55,6 @@ 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
|
||||
@@ -61,7 +62,8 @@ 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} packages/playwright-mcp/cli.js packages/playwright-mcp/package.json ./
|
||||
COPY --chown=${USERNAME}:${USERNAME} cli.js package.json ./
|
||||
COPY --from=builder --chown=${USERNAME}:${USERNAME} /app/lib /app/lib
|
||||
|
||||
# Run in headless and only with chromium (other browsers need more dependencies not included in this image)
|
||||
ENTRYPOINT ["node", "cli.js", "--headless", "--browser", "chromium", "--no-sandbox"]
|
||||
|
||||
@@ -15,9 +15,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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);
|
||||
});
|
||||
import './lib/program.js';
|
||||
106
packages/playwright-mcp/config.d.ts → config.d.ts
vendored
@@ -16,17 +16,7 @@
|
||||
|
||||
import type * as playwright from 'playwright';
|
||||
|
||||
export type ToolCapability =
|
||||
'core' |
|
||||
'core-input' |
|
||||
'core-navigation' |
|
||||
'core-tabs' |
|
||||
'core-install' |
|
||||
'core-input' |
|
||||
'vision' |
|
||||
'pdf' |
|
||||
'testing' |
|
||||
'tracing';
|
||||
export type ToolCapability = 'core' | 'core-tabs' | 'core-install' | 'vision' | 'pdf';
|
||||
|
||||
export type Config = {
|
||||
/**
|
||||
@@ -69,31 +59,10 @@ 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?: {
|
||||
@@ -106,12 +75,6 @@ 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[];
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -132,43 +95,11 @@ 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.
|
||||
@@ -181,43 +112,8 @@ 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';
|
||||
};
|
||||
235
eslint.config.mjs
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
},
|
||||
];
|
||||
10
examples/generate-test.md
Normal file
@@ -0,0 +1,10 @@
|
||||
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.
|
||||
@@ -45,33 +45,4 @@ Configure Playwright MCP server to connect to the browser using the extension by
|
||||
|
||||
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,22 +1,26 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Playwright MCP Bridge",
|
||||
"version": "0.0.57",
|
||||
"version": "0.0.34",
|
||||
"description": "Share browser tabs with Playwright MCP server",
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9nMS2b0WCohjVHPGb8D9qAdkbIngDqoAjTeSccHJijgcONejge+OJxOQOMLu7b0ovt1c9BiEJa5JcpM+EHFVGL1vluBxK71zmBy1m2f9vZF3HG0LSCp7YRkum9rAIEthDwbkxx6XTvpmAY5rjFa/NON6b9Hlbo+8peUSkoOK7HTwYnnI36asZ9eUTiveIf+DMPLojW2UX33vDWG2UKvMVDewzclb4+uLxAYshY7Mx8we/b44xu+Anb/EBLKjOPk9Yh541xJ5Ozc8EiP/5yxOp9c/lRiYUHaRW+4r0HKZyFt0eZ52ti2iM4Nfk7jRXR7an3JPsUIf5deC/1cVM/+1ZQIDAQAB",
|
||||
|
||||
"permissions": [
|
||||
"debugger",
|
||||
"activeTab",
|
||||
"tabs",
|
||||
"storage"
|
||||
],
|
||||
|
||||
"host_permissions": [
|
||||
"<all_urls>"
|
||||
],
|
||||
|
||||
"background": {
|
||||
"service_worker": "lib/background.mjs",
|
||||
"service_worker": "lib/background.js",
|
||||
"type": "module"
|
||||
},
|
||||
|
||||
"action": {
|
||||
"default_title": "Playwright MCP Bridge",
|
||||
"default_icon": {
|
||||
@@ -26,6 +30,7 @@
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
},
|
||||
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
1884
extension/package-lock.json
generated
Normal file
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "@playwright/mcp-extension",
|
||||
"version": "0.0.57",
|
||||
"version": "0.0.34",
|
||||
"description": "Playwright MCP Browser Extension",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -16,10 +17,9 @@
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"build": "tsc --project . && tsc --project tsconfig.ui.json && vite build && vite build --config vite.sw.config.mts",
|
||||
"watch": "tsc --watch --project . & tsc --watch --project tsconfig.ui.json & vite build --watch & vite build --watch --config vite.sw.config.mts",
|
||||
"build": "tsc --project . && tsc --project tsconfig.ui.json && vite build",
|
||||
"watch": "tsc --watch --project . & tsc --watch --project tsconfig.ui.json & vite build --watch",
|
||||
"test": "playwright test",
|
||||
"lint": "tsc --project .",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -30,7 +30,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^5.4.21",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-static-copy": "^3.1.1"
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
import type { TestOptions } from '../playwright-mcp/tests/fixtures';
|
||||
import type { TestOptions } from '../tests/fixtures.js';
|
||||
|
||||
export default defineConfig<TestOptions>({
|
||||
testDir: './tests',
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { RelayConnection, debugLog } from './relayConnection';
|
||||
import { RelayConnection, debugLog } from './relayConnection.js';
|
||||
|
||||
type PageMessage = {
|
||||
type: 'connectToMCPRelay';
|
||||
@@ -23,8 +23,8 @@ type PageMessage = {
|
||||
type: 'getTabs';
|
||||
} | {
|
||||
type: 'connectToTab';
|
||||
tabId?: number;
|
||||
windowId?: number;
|
||||
tabId: number;
|
||||
windowId: number;
|
||||
mcpRelayUrl: string;
|
||||
} | {
|
||||
type: 'getConnectionStatus';
|
||||
@@ -59,9 +59,7 @@ class TabShareExtension {
|
||||
(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(
|
||||
this._connectTab(sender.tab!.id!, 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
|
||||
@@ -192,71 +192,4 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -14,20 +14,16 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Button, TabItem } from './tabItem';
|
||||
import { AuthTokenSection, getOrCreateAuthToken } from './authToken';
|
||||
|
||||
import type { TabInfo } from './tabItem';
|
||||
import { Button, TabItem } from './tabItem.js';
|
||||
import type { TabInfo } from './tabItem.js';
|
||||
|
||||
type Status =
|
||||
| { type: 'connecting'; message: string }
|
||||
| { type: 'connected'; message: string }
|
||||
| { type: 'error'; message: string }
|
||||
| { type: 'error'; versionMismatch: { extensionVersion: string; } };
|
||||
|
||||
const SUPPORTED_PROTOCOL_VERSION = 1;
|
||||
| { type: 'error'; versionMismatch: { pwMcpVersion: string; extensionVersion: string } };
|
||||
|
||||
const ConnectApp: React.FC = () => {
|
||||
const [tabs, setTabs] = useState<TabInfo[]>([]);
|
||||
@@ -36,82 +32,49 @@ const ConnectApp: React.FC = () => {
|
||||
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');
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const relayUrl = params.get('mcpRelayUrl');
|
||||
|
||||
if (!relayUrl) {
|
||||
handleReject('Missing mcpRelayUrl parameter in URL.');
|
||||
return;
|
||||
}
|
||||
if (!relayUrl) {
|
||||
setShowButtons(false);
|
||||
setStatus({ type: 'error', message: '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;
|
||||
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 pwMcpVersion = params.get('pwMcpVersion');
|
||||
const extensionVersion = chrome.runtime.getManifest().version;
|
||||
if (pwMcpVersion !== extensionVersion) {
|
||||
setShowButtons(false);
|
||||
setShowTabList(false);
|
||||
setStatus({
|
||||
type: 'error',
|
||||
versionMismatch: {
|
||||
pwMcpVersion: pwMcpVersion || 'unknown',
|
||||
extensionVersion
|
||||
}
|
||||
} catch (e) {
|
||||
handleReject(`Invalid mcpRelayUrl parameter in URL: ${relayUrl}. ${e}`);
|
||||
return;
|
||||
}
|
||||
});
|
||||
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();
|
||||
void connectToMCPRelay(relayUrl);
|
||||
void loadTabs();
|
||||
}, []);
|
||||
|
||||
const handleReject = useCallback((message: string) => {
|
||||
@@ -121,6 +84,7 @@ const ConnectApp: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
const connectToMCPRelay = useCallback(async (mcpRelayUrl: string) => {
|
||||
|
||||
const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl });
|
||||
if (!response.success)
|
||||
handleReject(response.error);
|
||||
@@ -134,7 +98,7 @@ const ConnectApp: React.FC = () => {
|
||||
setStatus({ type: 'error', message: 'Failed to load tabs: ' + response.error });
|
||||
}, []);
|
||||
|
||||
const handleConnectToTab = useCallback(async (tab?: TabInfo) => {
|
||||
const handleConnectToTab = useCallback(async (tab: TabInfo) => {
|
||||
setShowButtons(false);
|
||||
setShowTabList(false);
|
||||
|
||||
@@ -142,8 +106,8 @@ const ConnectApp: React.FC = () => {
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'connectToTab',
|
||||
mcpRelayUrl,
|
||||
tabId: tab?.id,
|
||||
windowId: tab?.windowId,
|
||||
tabId: tab.id,
|
||||
windowId: tab.windowId,
|
||||
});
|
||||
|
||||
if (response?.success) {
|
||||
@@ -180,30 +144,13 @@ const ConnectApp: React.FC = () => {
|
||||
<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>
|
||||
<Button variant='reject' onClick={() => handleReject('Connection rejected. This tab can be closed.')}>
|
||||
Reject
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status?.type === 'connecting' && (
|
||||
<AuthTokenSection />
|
||||
)}
|
||||
|
||||
{showTabList && (
|
||||
<div>
|
||||
<div className='tab-section-title'>
|
||||
@@ -229,14 +176,13 @@ const ConnectApp: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const VersionMismatchError: React.FC<{ extensionVersion: string }> = ({ extensionVersion }) => {
|
||||
const VersionMismatchError: React.FC<{ pwMcpVersion: string; extensionVersion: string }> = ({ pwMcpVersion, 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.
|
||||
Incompatible Playwright MCP version: {pwMcpVersion} (extension version: {extensionVersion}).
|
||||
Please install the latest version of the extension.{' '}
|
||||
See <a href={readmeUrl} target='_blank' rel='noopener noreferrer'>installation instructions</a>.
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -245,9 +191,7 @@ const StatusBanner: React.FC<{ status: Status }> = ({ status }) => {
|
||||
return (
|
||||
<div className={`status-banner ${status.type}`}>
|
||||
{'versionMismatch' in status ? (
|
||||
<VersionMismatchError
|
||||
extensionVersion={status.versionMismatch.extensionVersion}
|
||||
/>
|
||||
<VersionMismatchError pwMcpVersion={status.versionMismatch.pwMcpVersion} extensionVersion={status.versionMismatch.extensionVersion} />
|
||||
) : (
|
||||
status.message
|
||||
)}
|
||||
@@ -16,10 +16,9 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Button, TabItem } from './tabItem';
|
||||
import { Button, TabItem } from './tabItem.js';
|
||||
|
||||
import type { TabInfo } from './tabItem';
|
||||
import { AuthTokenSection } from './authToken';
|
||||
import type { TabInfo } from './tabItem.js';
|
||||
|
||||
interface ConnectionStatus {
|
||||
isConnected: boolean;
|
||||
@@ -98,7 +97,6 @@ const StatusApp: React.FC = () => {
|
||||
No MCP clients are currently connected.
|
||||
</div>
|
||||
)}
|
||||
<AuthTokenSection />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
244
extension/tests/extension.spec.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* 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 { fileURLToPath } from 'url';
|
||||
import { chromium } from 'playwright';
|
||||
import packageJSON from '../../package.json' assert { type: 'json' };
|
||||
import { test as base, expect } from '../../tests/fixtures.js';
|
||||
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import type { StartClient } from '../../tests/fixtures.js';
|
||||
|
||||
type BrowserWithExtension = {
|
||||
userDataDir: string;
|
||||
launch: (mode?: 'disable-extension') => Promise<BrowserContext>;
|
||||
};
|
||||
|
||||
type TestFixtures = {
|
||||
browserWithExtension: BrowserWithExtension,
|
||||
pathToExtension: string,
|
||||
useShortConnectionTimeout: (timeoutMs: number) => void
|
||||
};
|
||||
|
||||
const test = base.extend<TestFixtures>({
|
||||
pathToExtension: async ({}, use) => {
|
||||
await use(fileURLToPath(new URL('../dist', import.meta.url)));
|
||||
},
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
async function startAndCallConnectTool(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> {
|
||||
const { client } = await startClient({
|
||||
args: [`--connect-tool`],
|
||||
config: {
|
||||
browser: {
|
||||
userDataDir: browserWithExtension.userDataDir,
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_connect',
|
||||
arguments: {
|
||||
name: 'extension'
|
||||
}
|
||||
})).toHaveResponse({
|
||||
result: 'Successfully changed connection method.',
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
async function startWithExtensionFlag(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> {
|
||||
const { client } = await startClient({
|
||||
args: [`--extension`],
|
||||
config: {
|
||||
browser: {
|
||||
userDataDir: browserWithExtension.userDataDir,
|
||||
}
|
||||
},
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
const testWithOldVersion = test.extend({
|
||||
pathToExtension: async ({}, use, testInfo) => {
|
||||
const extensionDir = testInfo.outputPath('extension');
|
||||
const oldPath = fileURLToPath(new URL('../dist', import.meta.url));
|
||||
|
||||
await fs.promises.cp(oldPath, extensionDir, { recursive: true });
|
||||
const manifestPath = path.join(extensionDir, 'manifest.json');
|
||||
const manifest = JSON.parse(await fs.promises.readFile(manifestPath, 'utf8'));
|
||||
manifest.version = '0.0.1';
|
||||
await fs.promises.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
||||
|
||||
await use(extensionDir);
|
||||
},
|
||||
});
|
||||
|
||||
for (const [mode, startClientMethod] of [
|
||||
['connect-tool', startAndCallConnectTool],
|
||||
['extension-flag', startWithExtensionFlag],
|
||||
] as const) {
|
||||
|
||||
test(`navigate with extension (${mode})`, async ({ browserWithExtension, startClient, server }) => {
|
||||
const browserContext = await browserWithExtension.launch();
|
||||
|
||||
const client = await startClientMethod(browserWithExtension, startClient);
|
||||
|
||||
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
|
||||
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
||||
});
|
||||
|
||||
const navigateResponse = client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
|
||||
const selectorPage = await confirmationPagePromise;
|
||||
await selectorPage.locator('.tab-item', { hasText: 'Playwright MCP Extension' }).getByRole('button', { name: 'Connect' }).click();
|
||||
|
||||
expect(await navigateResponse).toHaveResponse({
|
||||
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||
});
|
||||
});
|
||||
|
||||
test(`snapshot of an existing page (${mode})`, async ({ browserWithExtension, startClient, server }) => {
|
||||
const browserContext = await browserWithExtension.launch();
|
||||
|
||||
const page = await browserContext.newPage();
|
||||
await page.goto(server.HELLO_WORLD);
|
||||
|
||||
// Another empty page.
|
||||
await browserContext.newPage();
|
||||
expect(browserContext.pages()).toHaveLength(3);
|
||||
|
||||
const client = await startClientMethod(browserWithExtension, startClient);
|
||||
expect(browserContext.pages()).toHaveLength(3);
|
||||
|
||||
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
|
||||
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
||||
});
|
||||
|
||||
const navigateResponse = client.callTool({
|
||||
name: 'browser_snapshot',
|
||||
arguments: { },
|
||||
});
|
||||
|
||||
const selectorPage = await confirmationPagePromise;
|
||||
expect(browserContext.pages()).toHaveLength(4);
|
||||
|
||||
await selectorPage.locator('.tab-item', { hasText: 'Title' }).getByRole('button', { name: 'Connect' }).click();
|
||||
|
||||
expect(await navigateResponse).toHaveResponse({
|
||||
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||
});
|
||||
|
||||
expect(browserContext.pages()).toHaveLength(4);
|
||||
});
|
||||
|
||||
test(`extension not installed timeout (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
|
||||
useShortConnectionTimeout(100);
|
||||
|
||||
const browserContext = await browserWithExtension.launch();
|
||||
|
||||
const client = await startClientMethod(browserWithExtension, startClient);
|
||||
|
||||
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
|
||||
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
})).toHaveResponse({
|
||||
result: expect.stringContaining('Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed.'),
|
||||
isError: true,
|
||||
});
|
||||
|
||||
await confirmationPagePromise;
|
||||
});
|
||||
|
||||
testWithOldVersion(`extension version mismatch (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
|
||||
useShortConnectionTimeout(500);
|
||||
|
||||
// Prelaunch the browser, so that it is properly closed after the test.
|
||||
const browserContext = await browserWithExtension.launch();
|
||||
|
||||
const client = await startClientMethod(browserWithExtension, startClient);
|
||||
|
||||
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
|
||||
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
||||
});
|
||||
|
||||
const navigateResponse = client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: { url: server.HELLO_WORLD },
|
||||
});
|
||||
|
||||
const confirmationPage = await confirmationPagePromise;
|
||||
await expect(confirmationPage.locator('.status-banner')).toHaveText(`Incompatible Playwright MCP version: ${packageJSON.version} (extension version: 0.0.1). Please install the latest version of the extension. See installation instructions.`);
|
||||
|
||||
expect(await navigateResponse).toHaveResponse({
|
||||
result: expect.stringContaining('Extension connection timeout.'),
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
@@ -10,8 +10,7 @@
|
||||
"resolveJsonModule": true,
|
||||
"types": ["chrome"],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react",
|
||||
"noEmit": true
|
||||
"jsxImportSource": "react"
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import type { Config } from './config';
|
||||
import type { Config } from './config.js';
|
||||
import type { BrowserContext } from 'playwright';
|
||||
|
||||
export declare function createConnection(config?: Config, contextGetter?: () => Promise<BrowserContext>): Promise<Server>;
|
||||
@@ -15,5 +15,5 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const { createConnection } = require('playwright/lib/mcp/index');
|
||||
module.exports = { createConnection };
|
||||
import { createConnection } from './lib/index.js';
|
||||
export { createConnection };
|
||||
5359
package-lock.json
generated
74
package.json
@@ -1,30 +1,74 @@
|
||||
{
|
||||
"name": "playwright-mcp-internal",
|
||||
"version": "0.0.57",
|
||||
"private": true,
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.34",
|
||||
"description": "Playwright Tools for MCP",
|
||||
"type": "module",
|
||||
"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": {
|
||||
"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"
|
||||
"build": "tsc",
|
||||
"lint": "npm run update-readme && npm run check-deps && eslint . && tsc --noEmit",
|
||||
"lint-fix": "eslint . --fix",
|
||||
"check-deps": "node utils/check-deps.js",
|
||||
"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-2025-08-12",
|
||||
"playwright-core": "1.55.0-alpha-2025-08-12",
|
||||
"ws": "^8.18.1",
|
||||
"zod": "^3.24.1",
|
||||
"zod-to-json-schema": "^3.24.4"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||
"@playwright/test": "1.59.0-alpha-1769208470000",
|
||||
"@types/node": "^24.3.0"
|
||||
"@anthropic-ai/sdk": "^0.57.0",
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@playwright/test": "1.55.0-alpha-2025-08-12",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
1
packages/extension/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
dist/
|
||||
@@ -1,142 +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.
|
||||
*/
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -1,118 +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, { 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;
|
||||
}
|
||||
@@ -1,891 +0,0 @@
|
||||
/* 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
|
||||
}
|
||||
}
|
||||
@@ -1,39 +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.
|
||||
*/
|
||||
|
||||
.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);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * 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>;
|
||||
};
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/*
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import './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>;
|
||||
};
|
||||
@@ -1,304 +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 { 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!`),
|
||||
});
|
||||
});
|
||||
2
packages/playwright-mcp/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
README.md
|
||||
LICENSE
|
||||
@@ -1,7 +0,0 @@
|
||||
**/*
|
||||
!README.md
|
||||
!LICENSE
|
||||
!cli.js
|
||||
!playwright-cli.js
|
||||
!index.*
|
||||
!config.d.ts
|
||||
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.57",
|
||||
"description": "Playwright Tools for MCP",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/microsoft/playwright-mcp.git"
|
||||
},
|
||||
"homepage": "https://playwright.dev",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"author": {
|
||||
"name": "Microsoft Corporation"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"lint": "node update-readme.js",
|
||||
"test": "playwright test",
|
||||
"ctest": "playwright test --project=chrome",
|
||||
"ftest": "playwright test --project=firefox",
|
||||
"wtest": "playwright test --project=webkit",
|
||||
"dtest": "MCP_IN_DOCKER=1 playwright test --project=chromium-docker",
|
||||
"build": "echo OK",
|
||||
"npm-publish": "npm run lint && npm run test && npm publish",
|
||||
"copy-config": "cp ../../../playwright/packages/playwright/src/mcp/config.d.ts . && perl -pi -e \"s|import type \\* as playwright from 'playwright-core';|import type * as playwright from 'playwright';|\" ./config.d.ts",
|
||||
"roll": "npm run copy-config && npm run lint"
|
||||
},
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"types": "./index.d.ts",
|
||||
"default": "./index.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.5",
|
||||
"playwright": "1.59.0-alpha-1769208470000",
|
||||
"playwright-core": "1.59.0-alpha-1769208470000"
|
||||
},
|
||||
"bin": {
|
||||
"mcp": "cli.js",
|
||||
"playwright-cli": "./playwright-cli.js"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# 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.
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
import type { TestOptions } from './tests/fixtures';
|
||||
import type { TestOptions } from './tests/fixtures.js';
|
||||
|
||||
export default defineConfig<TestOptions>({
|
||||
testDir: './tests',
|
||||
@@ -26,6 +26,7 @@ export default defineConfig<TestOptions>({
|
||||
reporter: 'list',
|
||||
projects: [
|
||||
{ name: 'chrome' },
|
||||
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
||||
...process.env.MCP_IN_DOCKER ? [{
|
||||
name: 'chromium-docker',
|
||||
grep: /browser_navigate|browser_click/,
|
||||
@@ -34,5 +35,8 @@ export default defineConfig<TestOptions>({
|
||||
mcpMode: 'docker' as const
|
||||
}
|
||||
}] : [],
|
||||
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
||||
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
||||
... process.platform === 'win32' ? [{ name: 'msedge', use: { mcpBrowser: 'msedge' } }] : [],
|
||||
],
|
||||
});
|
||||
7
src/DEPS.list
Normal file
@@ -0,0 +1,7 @@
|
||||
[*]
|
||||
./tools/
|
||||
./mcp/
|
||||
./utils/
|
||||
|
||||
[program.ts]
|
||||
***
|
||||
172
src/actions.d.ts
vendored
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
249
src/browserContextFactory.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* 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';
|
||||
// @ts-ignore
|
||||
import { startTraceViewerServer } from 'playwright-core/lib/server';
|
||||
import { logUnhandledError, testDebug } from './utils/log.js';
|
||||
import { createHash } from './utils/guid.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 {
|
||||
createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
|
||||
}
|
||||
|
||||
class BaseContextFactory implements BrowserContextFactory {
|
||||
readonly config: FullConfig;
|
||||
private _logName: string;
|
||||
protected _browserPromise: Promise<playwright.Browser> | undefined;
|
||||
|
||||
constructor(name: string, config: FullConfig) {
|
||||
this._logName = name;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
protected async _obtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
|
||||
if (this._browserPromise)
|
||||
return this._browserPromise;
|
||||
testDebug(`obtain browser (${this._logName})`);
|
||||
this._browserPromise = this._doObtainBrowser(clientInfo);
|
||||
void this._browserPromise.then(browser => {
|
||||
browser.on('disconnected', () => {
|
||||
this._browserPromise = undefined;
|
||||
});
|
||||
}).catch(() => {
|
||||
this._browserPromise = undefined;
|
||||
});
|
||||
return this._browserPromise;
|
||||
}
|
||||
|
||||
protected async _doObtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||
testDebug(`create browser context (${this._logName})`);
|
||||
const browser = await this._obtainBrowser(clientInfo);
|
||||
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._logName})`);
|
||||
if (browser.contexts().length === 1)
|
||||
this._browserPromise = undefined;
|
||||
await browserContext.close().catch(logUnhandledError);
|
||||
if (browser.contexts().length === 0) {
|
||||
testDebug(`close browser (${this._logName})`);
|
||||
await browser.close().catch(logUnhandledError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class IsolatedContextFactory extends BaseContextFactory {
|
||||
constructor(config: FullConfig) {
|
||||
super('isolated', config);
|
||||
}
|
||||
|
||||
protected override async _doObtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
|
||||
await injectCdpPort(this.config.browser);
|
||||
const browserType = playwright[this.config.browser.browserName];
|
||||
return browserType.launch({
|
||||
tracesDir: await startTraceServer(this.config, clientInfo.rootPath),
|
||||
...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', 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() : browser.contexts()[0];
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteContextFactory extends BaseContextFactory {
|
||||
constructor(config: FullConfig) {
|
||||
super('remote', 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();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
const tracesDir = await startTraceServer(this.config, clientInfo.rootPath);
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
async function startTraceServer(config: FullConfig, rootPath: string | undefined): Promise<string | undefined> {
|
||||
if (!config.saveTrace)
|
||||
return undefined;
|
||||
|
||||
const tracesDir = await outputFile(config, rootPath, `traces-${Date.now()}`);
|
||||
const server = await startTraceViewerServer();
|
||||
const urlPrefix = server.urlPrefix('human-readable');
|
||||
const url = urlPrefix + '/trace/index.html?trace=' + tracesDir + '/trace.json';
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('\nTrace viewer listening on ' + url);
|
||||
return tracesDir;
|
||||
}
|
||||
88
src/browserServerBackend.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 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 { FullConfig } from './config.js';
|
||||
import { Context } from './context.js';
|
||||
import { logUnhandledError } from './utils/log.js';
|
||||
import { Response } from './response.js';
|
||||
import { SessionLog } from './sessionLog.js';
|
||||
import { filteredTools } from './tools.js';
|
||||
import { toMcpTool } from './mcp/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';
|
||||
|
||||
export class BrowserServerBackend implements ServerBackend {
|
||||
private _tools: Tool[];
|
||||
private _context: Context | undefined;
|
||||
private _sessionLog: SessionLog | undefined;
|
||||
private _config: FullConfig;
|
||||
private _browserContextFactory: BrowserContextFactory;
|
||||
|
||||
constructor(config: FullConfig, factory: BrowserContextFactory) {
|
||||
this._config = config;
|
||||
this._browserContextFactory = factory;
|
||||
this._tools = filteredTools(config);
|
||||
}
|
||||
|
||||
async initialize(clientVersion: mcpServer.ClientVersion, roots: mcpServer.Root[]): Promise<void> {
|
||||
let rootPath: string | undefined;
|
||||
if (roots.length > 0) {
|
||||
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: { ...clientVersion, rootPath },
|
||||
});
|
||||
}
|
||||
|
||||
async listTools(): Promise<mcpServer.Tool[]> {
|
||||
return this._tools.map(tool => toMcpTool(tool.schema));
|
||||
}
|
||||
|
||||
async callTool(name: string, rawArguments: mcpServer.CallToolRequest['params']['arguments']) {
|
||||
const tool = this._tools.find(tool => tool.schema.name === name)!;
|
||||
if (!tool)
|
||||
throw new Error(`Tool "${name}" not found`);
|
||||
const parsedArguments = tool.schema.inputSchema.parse(rawArguments || {});
|
||||
const context = this._context!;
|
||||
const response = new Response(context, name, parsedArguments);
|
||||
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);
|
||||
}
|
||||
}
|
||||
320
src/config.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* 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/fileUtils.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;
|
||||
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';
|
||||
|
||||
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.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
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* 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 './utils/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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
3
src/extension/DEPS.list
Normal file
@@ -0,0 +1,3 @@
|
||||
[*]
|
||||
../mcp/
|
||||
../utils/
|
||||
415
src/extension/cdpRelay.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* 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 '../mcp/http.js';
|
||||
import { logUnhandledError } from '../utils/log.js';
|
||||
import { ManualPromise } from '../utils/manualPromise.js';
|
||||
import { packageJSON } from '../utils/package.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 _userDataDir?: 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, userDataDir?: string) {
|
||||
this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
|
||||
this._browserChannel = browserChannel;
|
||||
this._userDataDir = userDataDir;
|
||||
|
||||
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) => setTimeout(() => {
|
||||
reject(new Error(`Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed. See https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md for installation instructions.`));
|
||||
}, process.env.PWMCP_TEST_CONNECTION_TIMEOUT ? parseInt(process.env.PWMCP_TEST_CONNECTION_TIMEOUT, 10) : 5_000)),
|
||||
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/connect.html');
|
||||
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
|
||||
const client = {
|
||||
name: clientInfo.name,
|
||||
version: clientInfo.version,
|
||||
};
|
||||
url.searchParams.set('client', JSON.stringify(client));
|
||||
url.searchParams.set('pwMcpVersion', packageJSON.version);
|
||||
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.`);
|
||||
|
||||
const args: string[] = [];
|
||||
if (this._userDataDir)
|
||||
args.push(`--user-data-dir=${this._userDataDir}`);
|
||||
args.push(href);
|
||||
|
||||
spawn(executablePath, args, {
|
||||
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();
|
||||
}
|
||||
}
|
||||
63
src/extension/extensionContextFactory.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 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 '../mcp/http.js';
|
||||
import { CDPRelayServer } from './cdpRelay.js';
|
||||
|
||||
import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js';
|
||||
|
||||
const debugLogger = debug('pw:mcp:relay');
|
||||
|
||||
export class ExtensionContextFactory implements BrowserContextFactory {
|
||||
private _browserChannel: string;
|
||||
private _userDataDir?: string;
|
||||
|
||||
constructor(browserChannel: string, userDataDir: string | undefined) {
|
||||
this._browserChannel = browserChannel;
|
||||
this._userDataDir = userDataDir;
|
||||
}
|
||||
|
||||
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||
const browser = await this._obtainBrowser(clientInfo, abortSignal);
|
||||
return {
|
||||
browserContext: browser.contexts()[0],
|
||||
close: async () => {
|
||||
debugLogger('close() called for browser context');
|
||||
await browser.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async _obtainBrowser(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<playwright.Browser> {
|
||||
const relay = await this._startRelay(abortSignal);
|
||||
await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal);
|
||||
return await playwright.chromium.connectOverCDP(relay.cdpEndpoint());
|
||||
}
|
||||
|
||||
private async _startRelay(abortSignal: AbortSignal) {
|
||||
const httpServer = await startHttpServer({});
|
||||
if (abortSignal.aborted) {
|
||||
httpServer.close();
|
||||
throw new Error(abortSignal.reason);
|
||||
}
|
||||
const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel, this._userDataDir);
|
||||
abortSignal.addEventListener('abort', () => cdpRelayServer.stop());
|
||||
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
|
||||
return cdpRelayServer;
|
||||
}
|
||||
}
|
||||
51
src/index.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 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 { packageJSON } from './utils/package.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('Playwright', packageJSON.version, 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()
|
||||
};
|
||||
}
|
||||
}
|
||||
108
src/loop/loop.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
177
src/loop/loopClaude.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
168
src/loop/loopOpenAI.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
72
src/loop/main.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 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);
|
||||
5
src/loopTools/DEPS.list
Normal file
@@ -0,0 +1,5 @@
|
||||
[*]
|
||||
../
|
||||
../loop/
|
||||
../mcp/
|
||||
../utils/
|
||||
78
src/loopTools/context.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 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 { packageJSON } from '../utils/package.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: packageJSON.version });
|
||||
const browserContextFactory = contextFactory(config);
|
||||
const server = mcpServer.createServer('Playwright Subagent', packageJSON.version, 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.CallToolResult> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
67
src/loopTools/main.ts
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 dotenv from 'dotenv';
|
||||
|
||||
import * as mcpServer from '../mcp/server.js';
|
||||
import { packageJSON } from '../utils/package.js';
|
||||
import { Context } from './context.js';
|
||||
import { perform } from './perform.js';
|
||||
import { snapshot } from './snapshot.js';
|
||||
import { toMcpTool } from '../mcp/tool.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 = {
|
||||
name: 'Playwright',
|
||||
nameInConfig: 'playwright-loop',
|
||||
version: packageJSON.version,
|
||||
create: () => new LoopToolsServerBackend(config)
|
||||
};
|
||||
await mcpServer.start(serverBackendFactory, config.server);
|
||||
}
|
||||
|
||||
class LoopToolsServerBackend implements ServerBackend {
|
||||
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);
|
||||
}
|
||||
|
||||
async listTools(): Promise<mcpServer.Tool[]> {
|
||||
return this._tools.map(tool => toMcpTool(tool.schema));
|
||||
}
|
||||
|
||||
async callTool(name: string, args: mcpServer.CallToolRequest['params']['arguments']): Promise<mcpServer.CallToolResult> {
|
||||
const tool = this._tools.find(tool => tool.schema.name === name)!;
|
||||
const parsedArguments = tool.schema.inputSchema.parse(args || {});
|
||||
return await tool.handle(this._context!, parsedArguments);
|
||||
}
|
||||
|
||||
serverClosed() {
|
||||
void this._context!.close();
|
||||
}
|
||||
}
|
||||
@@ -14,19 +14,23 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import childProcess from 'child_process';
|
||||
import { z } from 'zod';
|
||||
import { defineTool } from './tool.js';
|
||||
|
||||
function runCLI(args: string[]) {
|
||||
return childProcess.spawnSync(process.execPath, [require.resolve('../playwright-cli'), ...args], { encoding: 'utf-8' });
|
||||
}
|
||||
|
||||
test('prints help', async () => {
|
||||
const { stdout } = runCLI(['--help']);
|
||||
expect(stdout).toContain('Usage: playwright-cli <command>');
|
||||
const performSchema = z.object({
|
||||
task: z.string().describe('The task to perform with the browser'),
|
||||
});
|
||||
|
||||
test('prints version', async () => {
|
||||
const { stdout } = runCLI(['--version']);
|
||||
expect(stdout).toContain(require('../package.json').version);
|
||||
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);
|
||||
},
|
||||
});
|
||||
32
src/loopTools/snapshot.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { 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);
|
||||
},
|
||||
});
|
||||
30
src/loopTools/tool.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 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 { z } from 'zod';
|
||||
import type * as mcpServer from '../mcp/server.js';
|
||||
import type { Context } from './context.js';
|
||||
import type { ToolSchema } from '../mcp/tool.js';
|
||||
|
||||
|
||||
export type Tool<Input extends z.Schema = z.Schema> = {
|
||||
schema: ToolSchema<Input>;
|
||||
handle: (context: Context, params: z.output<Input>) => Promise<mcpServer.CallToolResult>;
|
||||
};
|
||||
|
||||
export function defineTool<Input extends z.Schema>(tool: Tool<Input>): Tool<Input> {
|
||||
return tool;
|
||||
}
|
||||
1
src/mcp/DEPS.list
Normal file
@@ -0,0 +1 @@
|
||||
[*]
|
||||
1
src/mcp/README.md
Normal file
@@ -0,0 +1 @@
|
||||
- Generic MCP utils, no dependencies on anything.
|
||||
138
src/mcp/http.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 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 net from 'net';
|
||||
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 * as mcpServer from './server.js';
|
||||
|
||||
import type { ServerBackendFactory } from './server.js';
|
||||
|
||||
const testDebug = debug('pw:mcp:test');
|
||||
|
||||
export async function startHttpServer(config: { host?: string, port?: number }, abortSignal?: AbortSignal): Promise<http.Server> {
|
||||
const { host, port } = config;
|
||||
const httpServer = http.createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
httpServer.on('error', reject);
|
||||
abortSignal?.addEventListener('abort', () => {
|
||||
httpServer.close();
|
||||
reject(new Error('Aborted'));
|
||||
});
|
||||
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}`;
|
||||
}
|
||||
|
||||
export async function installHttpTransport(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);
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
92
src/mcp/inProcessTransport.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
128
src/mcp/proxyBackend.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 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 { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { ListRootsRequestSchema, PingRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
import type { ServerBackend, ClientVersion, Root } from './server.js';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import type { Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
export type MCPProvider = {
|
||||
name: string;
|
||||
description: string;
|
||||
connect(): Promise<Transport>;
|
||||
};
|
||||
|
||||
const errorsDebug = debug('pw:mcp:errors');
|
||||
|
||||
export class ProxyBackend implements ServerBackend {
|
||||
private _mcpProviders: MCPProvider[];
|
||||
private _currentClient: Client | undefined;
|
||||
private _contextSwitchTool: Tool;
|
||||
private _roots: Root[] = [];
|
||||
|
||||
constructor(mcpProviders: MCPProvider[]) {
|
||||
this._mcpProviders = mcpProviders;
|
||||
this._contextSwitchTool = this._defineContextSwitchTool();
|
||||
}
|
||||
|
||||
async initialize(clientVersion: ClientVersion, roots: Root[]): Promise<void> {
|
||||
this._roots = roots;
|
||||
await this._setCurrentClient(this._mcpProviders[0]);
|
||||
}
|
||||
|
||||
async listTools(): Promise<Tool[]> {
|
||||
const response = await this._currentClient!.listTools();
|
||||
if (this._mcpProviders.length === 1)
|
||||
return response.tools;
|
||||
return [
|
||||
...response.tools,
|
||||
this._contextSwitchTool,
|
||||
];
|
||||
}
|
||||
|
||||
async callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult> {
|
||||
if (name === this._contextSwitchTool.name)
|
||||
return this._callContextSwitchTool(args);
|
||||
return await this._currentClient!.callTool({
|
||||
name,
|
||||
arguments: args,
|
||||
}) as CallToolResult;
|
||||
}
|
||||
|
||||
serverClosed?(): void {
|
||||
void this._currentClient?.close().catch(errorsDebug);
|
||||
}
|
||||
|
||||
private async _callContextSwitchTool(params: any): Promise<CallToolResult> {
|
||||
try {
|
||||
const factory = this._mcpProviders.find(factory => factory.name === params.name);
|
||||
if (!factory)
|
||||
throw new Error('Unknown connection method: ' + params.name);
|
||||
|
||||
await this._setCurrentClient(factory);
|
||||
return {
|
||||
content: [{ type: 'text', text: '### Result\nSuccessfully changed connection method.\n' }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `### Result\nError: ${error}\n` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private _defineContextSwitchTool(): Tool {
|
||||
return {
|
||||
name: 'browser_connect',
|
||||
description: [
|
||||
'Connect to a browser using one of the available methods:',
|
||||
...this._mcpProviders.map(factory => `- "${factory.name}": ${factory.description}`),
|
||||
].join('\n'),
|
||||
inputSchema: zodToJsonSchema(z.object({
|
||||
name: z.enum(this._mcpProviders.map(factory => factory.name) as [string, ...string[]]).default(this._mcpProviders[0].name).describe('The method to use to connect to the browser'),
|
||||
}), { strictUnions: true }) as Tool['inputSchema'],
|
||||
annotations: {
|
||||
title: 'Connect to a browser context',
|
||||
readOnlyHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async _setCurrentClient(factory: MCPProvider) {
|
||||
await this._currentClient?.close();
|
||||
this._currentClient = undefined;
|
||||
|
||||
const client = new Client({ name: 'Playwright MCP Proxy', version: '0.0.0' });
|
||||
client.registerCapabilities({
|
||||
roots: {
|
||||
listRoots: true,
|
||||
},
|
||||
});
|
||||
client.setRequestHandler(ListRootsRequestSchema, () => ({ roots: this._roots }));
|
||||
client.setRequestHandler(PingRequestSchema, () => ({}));
|
||||
|
||||
const transport = await factory.connect();
|
||||
await client.connect(transport);
|
||||
this._currentClient = client;
|
||||
}
|
||||
}
|
||||
157
src/mcp/server.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 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 { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { httpAddressToString, installHttpTransport, startHttpServer } from './http.js';
|
||||
import { InProcessTransport } from './inProcessTransport.js';
|
||||
|
||||
import type { Tool, CallToolResult, CallToolRequest, Root } 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 { Tool, CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
const serverDebug = debug('pw:mcp:server');
|
||||
const errorsDebug = debug('pw:mcp:errors');
|
||||
|
||||
export type ClientVersion = { name: string, version: string };
|
||||
export interface ServerBackend {
|
||||
initialize?(clientVersion: ClientVersion, roots: Root[]): Promise<void>;
|
||||
listTools(): Promise<Tool[]>;
|
||||
callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult>;
|
||||
serverClosed?(): void;
|
||||
}
|
||||
|
||||
export type ServerBackendFactory = {
|
||||
name: string;
|
||||
nameInConfig: string;
|
||||
version: string;
|
||||
create: () => ServerBackend;
|
||||
};
|
||||
|
||||
export async function connect(factory: ServerBackendFactory, transport: Transport, runHeartbeat: boolean) {
|
||||
const server = createServer(factory.name, factory.version, factory.create(), runHeartbeat);
|
||||
await server.connect(transport);
|
||||
}
|
||||
|
||||
export async function wrapInProcess(backend: ServerBackend): Promise<Transport> {
|
||||
const server = createServer('Internal', '0.0.0', backend, false);
|
||||
return new InProcessTransport(server);
|
||||
}
|
||||
|
||||
export function createServer(name: string, version: string, backend: ServerBackend, runHeartbeat: boolean): Server {
|
||||
let initializedPromiseResolve = () => {};
|
||||
const initializedPromise = new Promise<void>(resolve => initializedPromiseResolve = resolve);
|
||||
const server = new Server({ name, version }, {
|
||||
capabilities: {
|
||||
tools: {},
|
||||
}
|
||||
});
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
serverDebug('listTools');
|
||||
await initializedPromise;
|
||||
const tools = await backend.listTools();
|
||||
return { tools };
|
||||
});
|
||||
|
||||
let heartbeatRunning = false;
|
||||
server.setRequestHandler(CallToolRequestSchema, async request => {
|
||||
serverDebug('callTool', request);
|
||||
await initializedPromise;
|
||||
|
||||
if (runHeartbeat && !heartbeatRunning) {
|
||||
heartbeatRunning = true;
|
||||
startHeartbeat(server);
|
||||
}
|
||||
|
||||
try {
|
||||
return await backend.callTool(request.params.name, request.params.arguments || {});
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: '### Result\n' + String(error) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
addServerListener(server, 'initialized', async () => {
|
||||
try {
|
||||
const capabilities = server.getClientCapabilities();
|
||||
let clientRoots: Root[] = [];
|
||||
if (capabilities?.roots) {
|
||||
const { roots } = await server.listRoots(undefined, { timeout: 2_000 }).catch(() => ({ roots: [] }));
|
||||
clientRoots = roots;
|
||||
}
|
||||
const clientVersion = server.getClientVersion() ?? { name: 'unknown', version: 'unknown' };
|
||||
await backend.initialize?.(clientVersion, clientRoots);
|
||||
initializedPromiseResolve();
|
||||
} catch (e) {
|
||||
errorsDebug(e);
|
||||
}
|
||||
});
|
||||
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();
|
||||
};
|
||||
}
|
||||
|
||||
export async function start(serverBackendFactory: ServerBackendFactory, options: { host?: string; port?: number }) {
|
||||
if (options.port === undefined) {
|
||||
await connect(serverBackendFactory, new StdioServerTransport(), false);
|
||||
return;
|
||||
}
|
||||
|
||||
const httpServer = await startHttpServer(options);
|
||||
await installHttpTransport(httpServer, serverBackendFactory);
|
||||
const url = httpAddressToString(httpServer.address());
|
||||
|
||||
const mcpConfig: any = { mcpServers: { } };
|
||||
mcpConfig.mcpServers[serverBackendFactory.nameInConfig] = {
|
||||
url: `${url}/mcp`
|
||||
};
|
||||
const message = [
|
||||
`Listening on ${url}`,
|
||||
'Put this in your client config:',
|
||||
JSON.stringify(mcpConfig, 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);
|
||||
}
|
||||
42
src/mcp/tool.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 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 { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
import type { z } from 'zod';
|
||||
import type * as mcpServer from './server.js';
|
||||
|
||||
export type ToolSchema<Input extends z.Schema> = {
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
inputSchema: Input;
|
||||
type: 'readOnly' | 'destructive';
|
||||
};
|
||||
|
||||
export function toMcpTool(tool: ToolSchema<any>): mcpServer.Tool {
|
||||
return {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: zodToJsonSchema(tool.inputSchema, { strictUnions: true }) as mcpServer.Tool['inputSchema'],
|
||||
annotations: {
|
||||
title: tool.title,
|
||||
readOnlyHint: tool.type === 'readOnly',
|
||||
destructiveHint: tool.type === 'destructive',
|
||||
openWorldHint: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
146
src/program.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 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';
|
||||
import * as mcpServer from './mcp/server.js';
|
||||
import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js';
|
||||
import { packageJSON } from './utils/package.js';
|
||||
import { Context } from './context.js';
|
||||
import { contextFactory } from './browserContextFactory.js';
|
||||
import { runLoopTools } from './loopTools/main.js';
|
||||
import { ProxyBackend } from './mcp/proxyBackend.js';
|
||||
import { BrowserServerBackend } from './browserServerBackend.js';
|
||||
import { ExtensionContextFactory } from './extension/extensionContextFactory.js';
|
||||
|
||||
import { runVSCodeTools } from './vscode/host.js';
|
||||
import type { MCPProvider } from './mcp/proxyBackend.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('--extension', 'Connect to a running browser instance (Edge/Chrome only). Requires the "Playwright MCP Bridge" browser extension to be installed.')
|
||||
.option('--headless', 'run browser in headless mode, headed by default')
|
||||
.option('--host <host>', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
|
||||
.option('--ignore-https-errors', 'ignore https errors')
|
||||
.option('--isolated', 'keep the browser profile in memory, do not save it to disk.')
|
||||
.option('--image-responses <mode>', 'whether to send image responses to the client. Can be "allow" or "omit", Defaults to "allow".')
|
||||
.option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.')
|
||||
.option('--output-dir <path>', 'path to the directory for output files.')
|
||||
.option('--port <port>', 'port to listen on for SSE transport.')
|
||||
.option('--proxy-bypass <bypass>', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"')
|
||||
.option('--proxy-server <proxy>', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"')
|
||||
.option('--save-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('--connect-tool', 'Allow to switch between different browser connection methods.').hideHelp())
|
||||
.addOption(new Option('--vscode', 'VS Code tools.').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);
|
||||
const browserContextFactory = contextFactory(config);
|
||||
const extensionContextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir);
|
||||
|
||||
if (options.extension) {
|
||||
const serverBackendFactory: mcpServer.ServerBackendFactory = {
|
||||
name: 'Playwright w/ extension',
|
||||
nameInConfig: 'playwright-extension',
|
||||
version: packageJSON.version,
|
||||
create: () => new BrowserServerBackend(config, extensionContextFactory)
|
||||
};
|
||||
await mcpServer.start(serverBackendFactory, config.server);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.vscode) {
|
||||
await runVSCodeTools(config);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.loopTools) {
|
||||
await runLoopTools(config);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.connectTool) {
|
||||
const providers: MCPProvider[] = [
|
||||
{
|
||||
name: 'default',
|
||||
description: 'Starts standalone browser',
|
||||
connect: () => mcpServer.wrapInProcess(new BrowserServerBackend(config, browserContextFactory)),
|
||||
},
|
||||
{
|
||||
name: 'extension',
|
||||
description: 'Connect to a browser using the Playwright MCP extension',
|
||||
connect: () => mcpServer.wrapInProcess(new BrowserServerBackend(config, extensionContextFactory)),
|
||||
},
|
||||
];
|
||||
const factory: mcpServer.ServerBackendFactory = {
|
||||
name: 'Playwright w/ switch',
|
||||
nameInConfig: 'playwright-switch',
|
||||
version: packageJSON.version,
|
||||
create: () => new ProxyBackend(providers),
|
||||
};
|
||||
await mcpServer.start(factory, config.server);
|
||||
return;
|
||||
}
|
||||
|
||||
const factory: mcpServer.ServerBackendFactory = {
|
||||
name: 'Playwright',
|
||||
nameInConfig: 'playwright',
|
||||
version: packageJSON.version,
|
||||
create: () => new BrowserServerBackend(config, browserContextFactory)
|
||||
};
|
||||
await mcpServer.start(factory, config.server);
|
||||
});
|
||||
|
||||
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
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* 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) + '...';
|
||||
}
|
||||
176
src/sessionLog.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* 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 './utils/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
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* 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 './utils/log.js';
|
||||
import { ManualPromise } from './utils/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
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
2
src/tools/DEPS.list
Normal file
@@ -0,0 +1,2 @@
|
||||
[*]
|
||||
../utils/
|
||||
63
src/tools/common.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 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 { defineTabTool, defineTool } from './tool.js';
|
||||
|
||||
const close = defineTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_close',
|
||||
title: 'Close browser',
|
||||
description: 'Close the page',
|
||||
inputSchema: z.object({}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (context, params, response) => {
|
||||
await context.closeBrowserContext();
|
||||
response.setIncludeTabs();
|
||||
response.addCode(`await page.close()`);
|
||||
},
|
||||
});
|
||||
|
||||
const resize = defineTabTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_resize',
|
||||
title: 'Resize browser window',
|
||||
description: 'Resize the browser window',
|
||||
inputSchema: z.object({
|
||||
width: z.number().describe('Width of the browser window'),
|
||||
height: z.number().describe('Height of the browser window'),
|
||||
}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (tab, params, response) => {
|
||||
response.addCode(`await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`);
|
||||
|
||||
await tab.waitForCompletion(async () => {
|
||||
await tab.page.setViewportSize({ width: params.width, height: params.height });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
close,
|
||||
resize
|
||||
];
|
||||
36
src/tools/console.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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 { defineTabTool } from './tool.js';
|
||||
|
||||
const console = defineTabTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_console_messages',
|
||||
title: 'Get console messages',
|
||||
description: 'Returns all console messages',
|
||||
inputSchema: z.object({}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
handle: async (tab, params, response) => {
|
||||
tab.consoleMessages().map(message => response.addResult(message.toString()));
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
console,
|
||||
];
|
||||
55
src/tools/dialogs.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 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 { defineTabTool } from './tool.js';
|
||||
|
||||
const handleDialog = defineTabTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_handle_dialog',
|
||||
title: 'Handle a dialog',
|
||||
description: 'Handle a dialog',
|
||||
inputSchema: z.object({
|
||||
accept: z.boolean().describe('Whether to accept the dialog.'),
|
||||
promptText: z.string().optional().describe('The text of the prompt in case of a prompt dialog.'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (tab, params, response) => {
|
||||
response.setIncludeSnapshot();
|
||||
|
||||
const dialogState = tab.modalStates().find(state => state.type === 'dialog');
|
||||
if (!dialogState)
|
||||
throw new Error('No dialog visible');
|
||||
|
||||
tab.clearModalState(dialogState);
|
||||
await tab.waitForCompletion(async () => {
|
||||
if (params.accept)
|
||||
await dialogState.dialog.accept(params.promptText);
|
||||
else
|
||||
await dialogState.dialog.dismiss();
|
||||
});
|
||||
},
|
||||
|
||||
clearsModalState: 'dialog',
|
||||
});
|
||||
|
||||
export default [
|
||||
handleDialog,
|
||||
];
|
||||
62
src/tools/evaluate.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 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 { defineTabTool } from './tool.js';
|
||||
import * as javascript from '../utils/codegen.js';
|
||||
import { generateLocator } from './utils.js';
|
||||
|
||||
import type * as playwright from 'playwright';
|
||||
|
||||
const evaluateSchema = z.object({
|
||||
function: z.string().describe('() => { /* code */ } or (element) => { /* code */ } when element is provided'),
|
||||
element: z.string().optional().describe('Human-readable element description used to obtain permission to interact with the element'),
|
||||
ref: z.string().optional().describe('Exact target element reference from the page snapshot'),
|
||||
});
|
||||
|
||||
const evaluate = defineTabTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_evaluate',
|
||||
title: 'Evaluate JavaScript',
|
||||
description: 'Evaluate JavaScript expression on page or element',
|
||||
inputSchema: evaluateSchema,
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (tab, params, response) => {
|
||||
response.setIncludeSnapshot();
|
||||
|
||||
let locator: playwright.Locator | undefined;
|
||||
if (params.ref && params.element) {
|
||||
locator = await tab.refLocator({ ref: params.ref, element: params.element });
|
||||
response.addCode(`await page.${await generateLocator(locator)}.evaluate(${javascript.quote(params.function)});`);
|
||||
} else {
|
||||
response.addCode(`await page.evaluate(${javascript.quote(params.function)});`);
|
||||
}
|
||||
|
||||
await tab.waitForCompletion(async () => {
|
||||
const receiver = locator ?? tab.page as any;
|
||||
const result = await receiver._evaluateFunction(params.function);
|
||||
response.addResult(JSON.stringify(result, null, 2) || 'undefined');
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
evaluate,
|
||||
];
|
||||
52
src/tools/files.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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 { defineTabTool } from './tool.js';
|
||||
|
||||
const uploadFile = defineTabTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_file_upload',
|
||||
title: 'Upload files',
|
||||
description: 'Upload one or multiple files',
|
||||
inputSchema: z.object({
|
||||
paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (tab, params, response) => {
|
||||
response.setIncludeSnapshot();
|
||||
|
||||
const modalState = tab.modalStates().find(state => state.type === 'fileChooser');
|
||||
if (!modalState)
|
||||
throw new Error('No file chooser visible');
|
||||
|
||||
response.addCode(`await fileChooser.setFiles(${JSON.stringify(params.paths)})`);
|
||||
|
||||
tab.clearModalState(modalState);
|
||||
await tab.waitForCompletion(async () => {
|
||||
await modalState.fileChooser.setFiles(params.paths);
|
||||
});
|
||||
},
|
||||
clearsModalState: 'fileChooser',
|
||||
});
|
||||
|
||||
export default [
|
||||
uploadFile,
|
||||
];
|
||||
58
src/tools/install.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { fork } from 'child_process';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { z } from 'zod';
|
||||
import { defineTool } from './tool.js';
|
||||
|
||||
|
||||
const install = defineTool({
|
||||
capability: 'core-install',
|
||||
schema: {
|
||||
name: 'browser_install',
|
||||
title: 'Install the browser specified in the config',
|
||||
description: 'Install the browser specified in the config. Call this if you get an error about the browser not being installed.',
|
||||
inputSchema: z.object({}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context, params, response) => {
|
||||
const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.browserName ?? 'chrome';
|
||||
const cliUrl = import.meta.resolve('playwright/package.json');
|
||||
const cliPath = path.join(fileURLToPath(cliUrl), '..', 'cli.js');
|
||||
const child = fork(cliPath, ['install', channel], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const output: string[] = [];
|
||||
child.stdout?.on('data', data => output.push(data.toString()));
|
||||
child.stderr?.on('data', data => output.push(data.toString()));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.on('close', code => {
|
||||
if (code === 0)
|
||||
resolve();
|
||||
else
|
||||
reject(new Error(`Failed to install browser: ${output.join('')}`));
|
||||
});
|
||||
});
|
||||
response.setIncludeTabs();
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
install,
|
||||
];
|
||||
89
src/tools/keyboard.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 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 { defineTabTool } from './tool.js';
|
||||
import { elementSchema } from './snapshot.js';
|
||||
import { generateLocator } from './utils.js';
|
||||
import * as javascript from '../utils/codegen.js';
|
||||
|
||||
const pressKey = defineTabTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_press_key',
|
||||
title: 'Press a key',
|
||||
description: 'Press a key on the keyboard',
|
||||
inputSchema: z.object({
|
||||
key: z.string().describe('Name of the key to press or a character to generate, such as `ArrowLeft` or `a`'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (tab, params, response) => {
|
||||
response.setIncludeSnapshot();
|
||||
response.addCode(`// Press ${params.key}`);
|
||||
response.addCode(`await page.keyboard.press('${params.key}');`);
|
||||
|
||||
await tab.waitForCompletion(async () => {
|
||||
await tab.page.keyboard.press(params.key);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const typeSchema = elementSchema.extend({
|
||||
text: z.string().describe('Text to type into the element'),
|
||||
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
|
||||
slowly: z.boolean().optional().describe('Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.'),
|
||||
});
|
||||
|
||||
const type = defineTabTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_type',
|
||||
title: 'Type text',
|
||||
description: 'Type text into editable element',
|
||||
inputSchema: typeSchema,
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (tab, params, response) => {
|
||||
const locator = await tab.refLocator(params);
|
||||
|
||||
await tab.waitForCompletion(async () => {
|
||||
if (params.slowly) {
|
||||
response.setIncludeSnapshot();
|
||||
response.addCode(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`);
|
||||
await locator.pressSequentially(params.text);
|
||||
} else {
|
||||
response.addCode(`await page.${await generateLocator(locator)}.fill(${javascript.quote(params.text)});`);
|
||||
await locator.fill(params.text);
|
||||
}
|
||||
|
||||
if (params.submit) {
|
||||
response.setIncludeSnapshot();
|
||||
response.addCode(`await page.${await generateLocator(locator)}.press('Enter');`);
|
||||
await locator.press('Enter');
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
pressKey,
|
||||
type,
|
||||
];
|
||||
113
src/tools/mouse.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 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 { defineTabTool } from './tool.js';
|
||||
|
||||
const elementSchema = z.object({
|
||||
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
|
||||
});
|
||||
|
||||
const mouseMove = defineTabTool({
|
||||
capability: 'vision',
|
||||
schema: {
|
||||
name: 'browser_mouse_move_xy',
|
||||
title: 'Move mouse',
|
||||
description: 'Move mouse to a given position',
|
||||
inputSchema: elementSchema.extend({
|
||||
x: z.number().describe('X coordinate'),
|
||||
y: z.number().describe('Y coordinate'),
|
||||
}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (tab, params, response) => {
|
||||
response.addCode(`// Move mouse to (${params.x}, ${params.y})`);
|
||||
response.addCode(`await page.mouse.move(${params.x}, ${params.y});`);
|
||||
|
||||
await tab.waitForCompletion(async () => {
|
||||
await tab.page.mouse.move(params.x, params.y);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const mouseClick = defineTabTool({
|
||||
capability: 'vision',
|
||||
schema: {
|
||||
name: 'browser_mouse_click_xy',
|
||||
title: 'Click',
|
||||
description: 'Click left mouse button at a given position',
|
||||
inputSchema: elementSchema.extend({
|
||||
x: z.number().describe('X coordinate'),
|
||||
y: z.number().describe('Y coordinate'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (tab, params, response) => {
|
||||
response.setIncludeSnapshot();
|
||||
|
||||
response.addCode(`// Click mouse at coordinates (${params.x}, ${params.y})`);
|
||||
response.addCode(`await page.mouse.move(${params.x}, ${params.y});`);
|
||||
response.addCode(`await page.mouse.down();`);
|
||||
response.addCode(`await page.mouse.up();`);
|
||||
|
||||
await tab.waitForCompletion(async () => {
|
||||
await tab.page.mouse.move(params.x, params.y);
|
||||
await tab.page.mouse.down();
|
||||
await tab.page.mouse.up();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const mouseDrag = defineTabTool({
|
||||
capability: 'vision',
|
||||
schema: {
|
||||
name: 'browser_mouse_drag_xy',
|
||||
title: 'Drag mouse',
|
||||
description: 'Drag left mouse button to a given position',
|
||||
inputSchema: elementSchema.extend({
|
||||
startX: z.number().describe('Start X coordinate'),
|
||||
startY: z.number().describe('Start Y coordinate'),
|
||||
endX: z.number().describe('End X coordinate'),
|
||||
endY: z.number().describe('End Y coordinate'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (tab, params, response) => {
|
||||
response.setIncludeSnapshot();
|
||||
|
||||
response.addCode(`// Drag mouse from (${params.startX}, ${params.startY}) to (${params.endX}, ${params.endY})`);
|
||||
response.addCode(`await page.mouse.move(${params.startX}, ${params.startY});`);
|
||||
response.addCode(`await page.mouse.down();`);
|
||||
response.addCode(`await page.mouse.move(${params.endX}, ${params.endY});`);
|
||||
response.addCode(`await page.mouse.up();`);
|
||||
|
||||
await tab.waitForCompletion(async () => {
|
||||
await tab.page.mouse.move(params.startX, params.startY);
|
||||
await tab.page.mouse.down();
|
||||
await tab.page.mouse.move(params.endX, params.endY);
|
||||
await tab.page.mouse.up();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
mouseMove,
|
||||
mouseClick,
|
||||
mouseDrag,
|
||||
];
|
||||
79
src/tools/navigate.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 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, defineTabTool } from './tool.js';
|
||||
|
||||
const navigate = defineTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_navigate',
|
||||
title: 'Navigate to a URL',
|
||||
description: 'Navigate to a URL',
|
||||
inputSchema: z.object({
|
||||
url: z.string().describe('The URL to navigate to'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context, params, response) => {
|
||||
const tab = await context.ensureTab();
|
||||
await tab.navigate(params.url);
|
||||
|
||||
response.setIncludeSnapshot();
|
||||
response.addCode(`await page.goto('${params.url}');`);
|
||||
},
|
||||
});
|
||||
|
||||
const goBack = defineTabTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_navigate_back',
|
||||
title: 'Go back',
|
||||
description: 'Go back to the previous page',
|
||||
inputSchema: z.object({}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (tab, params, response) => {
|
||||
await tab.page.goBack();
|
||||
response.setIncludeSnapshot();
|
||||
response.addCode(`await page.goBack();`);
|
||||
},
|
||||
});
|
||||
|
||||
const goForward = defineTabTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_navigate_forward',
|
||||
title: 'Go forward',
|
||||
description: 'Go forward to the next page',
|
||||
inputSchema: z.object({}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
handle: async (tab, params, response) => {
|
||||
await tab.page.goForward();
|
||||
response.setIncludeSnapshot();
|
||||
response.addCode(`await page.goForward();`);
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
navigate,
|
||||
goBack,
|
||||
goForward,
|
||||
];
|
||||
49
src/tools/network.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { defineTabTool } from './tool.js';
|
||||
|
||||
import type * as playwright from 'playwright';
|
||||
|
||||
const requests = defineTabTool({
|
||||
capability: 'core',
|
||||
|
||||
schema: {
|
||||
name: 'browser_network_requests',
|
||||
title: 'List network requests',
|
||||
description: 'Returns all network requests since loading the page',
|
||||
inputSchema: z.object({}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (tab, params, response) => {
|
||||
const requests = tab.requests();
|
||||
[...requests.entries()].forEach(([req, res]) => response.addResult(renderRequest(req, res)));
|
||||
},
|
||||
});
|
||||
|
||||
function renderRequest(request: playwright.Request, response: playwright.Response | null) {
|
||||
const result: string[] = [];
|
||||
result.push(`[${request.method().toUpperCase()}] ${request.url()}`);
|
||||
if (response)
|
||||
result.push(`=> [${response.status()}] ${response.statusText()}`);
|
||||
return result.join(' ');
|
||||
}
|
||||
|
||||
export default [
|
||||
requests,
|
||||
];
|
||||
47
src/tools/pdf.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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 { defineTabTool } from './tool.js';
|
||||
|
||||
import * as javascript from '../utils/codegen.js';
|
||||
|
||||
const pdfSchema = z.object({
|
||||
filename: z.string().optional().describe('File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.'),
|
||||
});
|
||||
|
||||
const pdf = defineTabTool({
|
||||
capability: 'pdf',
|
||||
|
||||
schema: {
|
||||
name: 'browser_pdf_save',
|
||||
title: 'Save as PDF',
|
||||
description: 'Save page as PDF',
|
||||
inputSchema: pdfSchema,
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (tab, params, response) => {
|
||||
const fileName = await tab.context.outputFile(params.filename ?? `page-${new Date().toISOString()}.pdf`);
|
||||
response.addCode(`await page.pdf(${javascript.formatObject({ path: fileName })});`);
|
||||
response.addResult(`Saved page as ${fileName}`);
|
||||
await tab.page.pdf({ path: fileName });
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
pdf,
|
||||
];
|
||||
92
src/tools/screenshot.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 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 { defineTabTool } from './tool.js';
|
||||
import * as javascript from '../utils/codegen.js';
|
||||
import { generateLocator } from './utils.js';
|
||||
|
||||
import type * as playwright from 'playwright';
|
||||
|
||||
const screenshotSchema = z.object({
|
||||
type: z.enum(['png', 'jpeg']).default('png').describe('Image format for the screenshot. Default is png.'),
|
||||
filename: z.string().optional().describe('File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.'),
|
||||
element: z.string().optional().describe('Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.'),
|
||||
ref: z.string().optional().describe('Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.'),
|
||||
fullPage: z.boolean().optional().describe('When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Cannot be used with element screenshots.'),
|
||||
}).refine(data => {
|
||||
return !!data.element === !!data.ref;
|
||||
}, {
|
||||
message: 'Both element and ref must be provided or neither.',
|
||||
path: ['ref', 'element']
|
||||
}).refine(data => {
|
||||
return !(data.fullPage && (data.element || data.ref));
|
||||
}, {
|
||||
message: 'fullPage cannot be used with element screenshots.',
|
||||
path: ['fullPage']
|
||||
});
|
||||
|
||||
const screenshot = defineTabTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_take_screenshot',
|
||||
title: 'Take a screenshot',
|
||||
description: `Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.`,
|
||||
inputSchema: screenshotSchema,
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (tab, params, response) => {
|
||||
const fileType = params.type || 'png';
|
||||
const fileName = await tab.context.outputFile(params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
|
||||
const options: playwright.PageScreenshotOptions = {
|
||||
type: fileType,
|
||||
quality: fileType === 'png' ? undefined : 90,
|
||||
scale: 'css',
|
||||
path: fileName,
|
||||
...(params.fullPage !== undefined && { fullPage: params.fullPage })
|
||||
};
|
||||
const isElementScreenshot = params.element && params.ref;
|
||||
|
||||
const screenshotTarget = isElementScreenshot ? params.element : (params.fullPage ? 'full page' : 'viewport');
|
||||
response.addCode(`// Screenshot ${screenshotTarget} and save it as ${fileName}`);
|
||||
|
||||
// Only get snapshot when element screenshot is needed
|
||||
const locator = params.ref ? await tab.refLocator({ element: params.element || '', ref: params.ref }) : null;
|
||||
|
||||
if (locator)
|
||||
response.addCode(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
|
||||
else
|
||||
response.addCode(`await page.screenshot(${javascript.formatObject(options)});`);
|
||||
|
||||
const buffer = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
|
||||
response.addResult(`Took the ${screenshotTarget} screenshot and saved it as ${fileName}`);
|
||||
|
||||
// https://github.com/microsoft/playwright-mcp/issues/817
|
||||
// Never return large images to LLM, saving them to the file system is enough.
|
||||
if (!params.fullPage) {
|
||||
response.addImage({
|
||||
contentType: fileType === 'png' ? 'image/png' : 'image/jpeg',
|
||||
data: buffer
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default [
|
||||
screenshot,
|
||||
];
|
||||
166
src/tools/snapshot.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 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 { defineTabTool, defineTool } from './tool.js';
|
||||
import * as javascript from '../utils/codegen.js';
|
||||
import { generateLocator } from './utils.js';
|
||||
|
||||
const snapshot = defineTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_snapshot',
|
||||
title: 'Page snapshot',
|
||||
description: 'Capture accessibility snapshot of the current page, this is better than screenshot',
|
||||
inputSchema: z.object({}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (context, params, response) => {
|
||||
await context.ensureTab();
|
||||
response.setIncludeSnapshot();
|
||||
},
|
||||
});
|
||||
|
||||
export const elementSchema = z.object({
|
||||
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
|
||||
ref: z.string().describe('Exact target element reference from the page snapshot'),
|
||||
});
|
||||
|
||||
const clickSchema = elementSchema.extend({
|
||||
doubleClick: z.boolean().optional().describe('Whether to perform a double click instead of a single click'),
|
||||
button: z.enum(['left', 'right', 'middle']).optional().describe('Button to click, defaults to left'),
|
||||
});
|
||||
|
||||
const click = defineTabTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_click',
|
||||
title: 'Click',
|
||||
description: 'Perform click on a web page',
|
||||
inputSchema: clickSchema,
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (tab, params, response) => {
|
||||
response.setIncludeSnapshot();
|
||||
|
||||
const locator = await tab.refLocator(params);
|
||||
const button = params.button;
|
||||
const buttonAttr = button ? `{ button: '${button}' }` : '';
|
||||
|
||||
if (params.doubleClick)
|
||||
response.addCode(`await page.${await generateLocator(locator)}.dblclick(${buttonAttr});`);
|
||||
else
|
||||
response.addCode(`await page.${await generateLocator(locator)}.click(${buttonAttr});`);
|
||||
|
||||
|
||||
await tab.waitForCompletion(async () => {
|
||||
if (params.doubleClick)
|
||||
await locator.dblclick({ button });
|
||||
else
|
||||
await locator.click({ button });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const drag = defineTabTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_drag',
|
||||
title: 'Drag mouse',
|
||||
description: 'Perform drag and drop between two elements',
|
||||
inputSchema: z.object({
|
||||
startElement: z.string().describe('Human-readable source element description used to obtain the permission to interact with the element'),
|
||||
startRef: z.string().describe('Exact source element reference from the page snapshot'),
|
||||
endElement: z.string().describe('Human-readable target element description used to obtain the permission to interact with the element'),
|
||||
endRef: z.string().describe('Exact target element reference from the page snapshot'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (tab, params, response) => {
|
||||
response.setIncludeSnapshot();
|
||||
|
||||
const [startLocator, endLocator] = await tab.refLocators([
|
||||
{ ref: params.startRef, element: params.startElement },
|
||||
{ ref: params.endRef, element: params.endElement },
|
||||
]);
|
||||
|
||||
await tab.waitForCompletion(async () => {
|
||||
await startLocator.dragTo(endLocator);
|
||||
});
|
||||
|
||||
response.addCode(`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`);
|
||||
},
|
||||
});
|
||||
|
||||
const hover = defineTabTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_hover',
|
||||
title: 'Hover mouse',
|
||||
description: 'Hover over element on page',
|
||||
inputSchema: elementSchema,
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (tab, params, response) => {
|
||||
response.setIncludeSnapshot();
|
||||
|
||||
const locator = await tab.refLocator(params);
|
||||
response.addCode(`await page.${await generateLocator(locator)}.hover();`);
|
||||
|
||||
await tab.waitForCompletion(async () => {
|
||||
await locator.hover();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const selectOptionSchema = elementSchema.extend({
|
||||
values: z.array(z.string()).describe('Array of values to select in the dropdown. This can be a single value or multiple values.'),
|
||||
});
|
||||
|
||||
const selectOption = defineTabTool({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_select_option',
|
||||
title: 'Select option',
|
||||
description: 'Select an option in a dropdown',
|
||||
inputSchema: selectOptionSchema,
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (tab, params, response) => {
|
||||
response.setIncludeSnapshot();
|
||||
|
||||
const locator = await tab.refLocator(params);
|
||||
response.addCode(`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`);
|
||||
|
||||
await tab.waitForCompletion(async () => {
|
||||
await locator.selectOption(params.values);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
snapshot,
|
||||
click,
|
||||
drag,
|
||||
hover,
|
||||
selectOption,
|
||||
];
|
||||
101
src/tools/tabs.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { defineTool } from './tool.js';
|
||||
|
||||
const listTabs = defineTool({
|
||||
capability: 'core-tabs',
|
||||
|
||||
schema: {
|
||||
name: 'browser_tab_list',
|
||||
title: 'List tabs',
|
||||
description: 'List browser tabs',
|
||||
inputSchema: z.object({}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (context, params, response) => {
|
||||
await context.ensureTab();
|
||||
response.setIncludeTabs();
|
||||
},
|
||||
});
|
||||
|
||||
const selectTab = defineTool({
|
||||
capability: 'core-tabs',
|
||||
|
||||
schema: {
|
||||
name: 'browser_tab_select',
|
||||
title: 'Select a tab',
|
||||
description: 'Select a tab by index',
|
||||
inputSchema: z.object({
|
||||
index: z.number().describe('The index of the tab to select'),
|
||||
}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (context, params, response) => {
|
||||
await context.selectTab(params.index);
|
||||
response.setIncludeSnapshot();
|
||||
},
|
||||
});
|
||||
|
||||
const newTab = defineTool({
|
||||
capability: 'core-tabs',
|
||||
|
||||
schema: {
|
||||
name: 'browser_tab_new',
|
||||
title: 'Open a new tab',
|
||||
description: 'Open a new tab',
|
||||
inputSchema: z.object({
|
||||
url: z.string().optional().describe('The URL to navigate to in the new tab. If not provided, the new tab will be blank.'),
|
||||
}),
|
||||
type: 'readOnly',
|
||||
},
|
||||
|
||||
handle: async (context, params, response) => {
|
||||
const tab = await context.newTab();
|
||||
if (params.url)
|
||||
await tab.navigate(params.url);
|
||||
response.setIncludeSnapshot();
|
||||
},
|
||||
});
|
||||
|
||||
const closeTab = defineTool({
|
||||
capability: 'core-tabs',
|
||||
|
||||
schema: {
|
||||
name: 'browser_tab_close',
|
||||
title: 'Close a tab',
|
||||
description: 'Close a tab',
|
||||
inputSchema: z.object({
|
||||
index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
|
||||
}),
|
||||
type: 'destructive',
|
||||
},
|
||||
|
||||
handle: async (context, params, response) => {
|
||||
await context.closeTab(params.index);
|
||||
response.setIncludeSnapshot();
|
||||
},
|
||||
});
|
||||
|
||||
export default [
|
||||
listTabs,
|
||||
newTab,
|
||||
selectTab,
|
||||
closeTab,
|
||||
];
|
||||
70
src/tools/tool.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 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 { z } from 'zod';
|
||||
import type { Context } from '../context.js';
|
||||
import type * as playwright from 'playwright';
|
||||
import type { ToolCapability } from '../../config.js';
|
||||
import type { Tab } from '../tab.js';
|
||||
import type { Response } from '../response.js';
|
||||
import type { ToolSchema } from '../mcp/tool.js';
|
||||
|
||||
export type FileUploadModalState = {
|
||||
type: 'fileChooser';
|
||||
description: string;
|
||||
fileChooser: playwright.FileChooser;
|
||||
};
|
||||
|
||||
export type DialogModalState = {
|
||||
type: 'dialog';
|
||||
description: string;
|
||||
dialog: playwright.Dialog;
|
||||
};
|
||||
|
||||
export type ModalState = FileUploadModalState | DialogModalState;
|
||||
|
||||
export type Tool<Input extends z.Schema = z.Schema> = {
|
||||
capability: ToolCapability;
|
||||
schema: ToolSchema<Input>;
|
||||
handle: (context: Context, params: z.output<Input>, response: Response) => Promise<void>;
|
||||
};
|
||||
|
||||
export function defineTool<Input extends z.Schema>(tool: Tool<Input>): Tool<Input> {
|
||||
return tool;
|
||||
}
|
||||
|
||||
export type TabTool<Input extends z.Schema = z.Schema> = {
|
||||
capability: ToolCapability;
|
||||
schema: ToolSchema<Input>;
|
||||
clearsModalState?: ModalState['type'];
|
||||
handle: (tab: Tab, params: z.output<Input>, response: Response) => Promise<void>;
|
||||
};
|
||||
|
||||
export function defineTabTool<Input extends z.Schema>(tool: TabTool<Input>): Tool<Input> {
|
||||
return {
|
||||
...tool,
|
||||
handle: async (context, params, response) => {
|
||||
const tab = context.currentTabOrDie();
|
||||
const modalStates = tab.modalStates().map(state => state.type);
|
||||
if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState))
|
||||
response.addError(`Error: The tool "${tool.schema.name}" can only be used when there is related modal state present.\n` + tab.modalStatesMarkdown().join('\n'));
|
||||
else if (!tool.clearsModalState && modalStates.length)
|
||||
response.addError(`Error: Tool "${tool.schema.name}" does not handle the modal state.\n` + tab.modalStatesMarkdown().join('\n'));
|
||||
else
|
||||
return tool.handle(tab, params, response);
|
||||
},
|
||||
};
|
||||
}
|
||||